/* SPECTICO Virtual Try-On - Enhanced browser-side landmark tracking + 3D eyewear preview. * Integrates directly with SPECTICO storefront architecture while preserving standalone module usage. */ const CONFIG = window.SpecticoVTOConfig || {}; const RECOMMENDED_BROWSERS = 'a recent version of Chrome, Edge, Firefox, or Safari'; // Prioritize local vendored MediaPipe assets for zero external network dependency, with CDN fallback const LOCAL_MEDIAPIPE_URL = '/spectico/mediapipe/vision_bundle.mjs'; const LOCAL_MEDIAPIPE_WASM = '/spectico/mediapipe/wasm'; const LOCAL_MODEL_URL = '/spectico/mediapipe/models/face_landmarker.task'; const CDN_MEDIAPIPE_URL = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${CONFIG.mediapipeVersion || '1.1.1'}/vision_bundle.mjs`; const CDN_MEDIAPIPE_WASM = CONFIG.wasmUrl || 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.1.1/wasm'; const CDN_MODEL_URL = CONFIG.mediapipeModelUrl || 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task'; // Three.js CDN URLs for reliable module loading without requiring HTML importmap const THREE_MODULE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; const GLTF_LOADER_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js'; const COLOURS = [ { name: 'Charcoal', value: '#282624' }, { name: 'Warm Tortoise', value: '#70452d' }, { name: 'Champagne Gold', value: '#b99a63' }, { name: 'Silver Titanium', value: '#aeb4b8' }, { name: 'Burgundy Crimson', value: '#692b38' } ]; let threePromise = null; let visionPromise = null; let currentSession = null; let modal = null; const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const lerp = (a, b, t) => a + (b - a) * t; const normaliseAngle = (angle) => { let a = angle; while (a > Math.PI) a -= Math.PI * 2; while (a < -Math.PI) a += Math.PI * 2; return a; }; /** * Resolve product configuration from a DOM trigger button or SPECTICO store catalogue */ function parseConfig(buttonOrConfig) { if (typeof buttonOrConfig === 'object' && !buttonOrConfig.tagName) { return sanitizeConfig(buttonOrConfig); } try { const raw = buttonOrConfig?.dataset?.specticoVto || '{}'; const fromData = JSON.parse(raw); return sanitizeConfig(fromData); } catch (error) { console.warn('[SPECTICO VTO] Invalid product configuration payload', error); return sanitizeConfig({}); } } function sanitizeConfig(data = {}) { const shapeStr = String(data.shape || 'rectangle').toLowerCase(); let normalizedShape = 'rectangle'; if (shapeStr.includes('round') || shapeStr.includes('circle') || shapeStr.includes('pantos')) { normalizedShape = 'round'; } else if (shapeStr.includes('aviator') || shapeStr.includes('pilot')) { normalizedShape = 'aviator'; } else if (shapeStr.includes('cat')) { normalizedShape = 'cat-eye'; } return { product_id: String(data.product_id || data.id || '0'), name: String(data.name || 'Spectico Eyewear'), price: data.price ? Number(data.price) : null, model_url: String(data.model_url || ''), overlay_url: String(data.overlay_url || ''), shape: normalizedShape, color: /^#[0-9a-f]{6}$/i.test(data.color || '') ? data.color : '#282624', frame_width_mm: clamp(Number(data.frame_width_mm) || 138, 100, 170), lens_width_mm: clamp(Number(data.lens_width_mm) || 52, 35, 70), lens_height_mm: clamp(Number(data.lens_height_mm) || 40, 25, 65), bridge_width_mm: clamp(Number(data.bridge_width_mm) || 18, 10, 30), temple_length_mm: clamp(Number(data.temple_length_mm) || 145, 100, 165), frame_thickness_mm: clamp(Number(data.frame_thickness_mm) || 3, 1, 8), offset_x_mm: clamp(Number(data.offset_x_mm) || 0, -30, 30), offset_y_mm: clamp(Number(data.offset_y_mm) || 0, -30, 30), offset_z_mm: clamp(Number(data.offset_z_mm) || 0, -30, 30), rotation_x_deg: clamp(Number(data.rotation_x_deg) || 0, -180, 180), rotation_y_deg: clamp(Number(data.rotation_y_deg) || 0, -180, 180), rotation_z_deg: clamp(Number(data.rotation_z_deg) || 0, -180, 180), fit_adjustment: 1.0 }; } /** * Resolves product metadata directly from the live SPECTICO Store Engine if available */ function resolveProductFromStore(productId) { if (typeof window === 'undefined') return sanitizeConfig({ product_id: productId }); const store = window.specticoStore; const product = store?.products?.find?.(p => String(p.id) === String(productId)); if (!product) { return sanitizeConfig({ product_id: productId, model_url: `assets/tryon-models/${productId}.gltf`, overlay_url: `assets/tryon-frames/${productId}.svg` }); } const colorHex = product.colorHex || (product.material && /gold/i.test(product.material) ? '#b99a63' : '#282624'); return sanitizeConfig({ product_id: product.id, name: product.name, price: product.price, shape: product.shape, color: colorHex, frame_width_mm: product.frameWidthMm || 138, lens_width_mm: product.lensWidthMm || 52, lens_height_mm: product.lensHeightMm || 40, bridge_width_mm: product.bridgeWidthMm || 18, temple_length_mm: product.templeLengthMm || 145, model_url: product.tryOnModel3d || `assets/tryon-models/${product.id}.gltf`, overlay_url: product.tryOnOverlay2d || `assets/tryon-frames/${product.id}.svg` }); } function supportsWebGL2() { try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2', { failIfMajorPerformanceCaveat: false }); if (gl) { const lose = gl.getExtension('WEBGL_lose_context'); lose?.loseContext?.(); return true; } } catch (_) {} return false; } function supportsImportMaps() { if (typeof HTMLScriptElement === 'undefined' || !('supports' in HTMLScriptElement)) return false; try { return HTMLScriptElement.supports('importmap'); } catch (_) { return false; } } async function requestCameraStream() { const mediaDevices = navigator.mediaDevices; if (!window.isSecureContext) { throw new Error('Camera access requires a secure HTTPS page. Please open the SPECTICO website using https://.'); } if (!mediaDevices?.getUserMedia) { throw new Error(`This browser does not expose camera access. Please update ${RECOMMENDED_BROWSERS} and allow camera permission.`); } const preferred = { video: { facingMode: { ideal: 'user' }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, audio: false }; try { return await mediaDevices.getUserMedia(preferred); } catch (error) { if (error?.name === 'OverconstrainedError' || error?.name === 'ConstraintNotSatisfiedError' || error?.name === 'TypeError') { return mediaDevices.getUserMedia({ video: true, audio: false }); } throw error; } } function initializeOverlayOnly(session) { return new Promise((resolve, reject) => { const image = modal.querySelector('.svto-overlay-frame'); const canvas = modal.querySelector('.svto-renderer'); session.overlayOnly = true; session.frameRoot = null; const loaded = () => { session.overlayImage = image; image.style.display = 'block'; image.style.opacity = '1'; canvas.style.display = 'none'; setStatus('2D preview active. Turn your head slowly.'); resolve(); }; image.onload = loaded; image.onerror = () => reject(new Error('The product frame image could not load. Check that the asset URL is accessible.')); image.crossOrigin = 'anonymous'; image.src = session.config.overlay_url; if (image.complete && image.naturalWidth > 0) loaded(); }); } function ensureModal() { if (modal) return modal; modal = document.createElement('div'); modal.className = 'svto-modal'; modal.hidden = true; modal.innerHTML = ` `; document.body.appendChild(modal); modal.querySelectorAll('[data-svto-close]').forEach(button => button.addEventListener('click', closeModal)); modal.addEventListener('click', event => { if (event.target === modal) closeModal(); }); modal.querySelector('[data-svto-start]').addEventListener('click', startCamera); modal.querySelector('[data-svto-snapshot]').addEventListener('click', saveSnapshot); modal.querySelectorAll('[data-svto-shape]').forEach(button => button.addEventListener('click', () => { if (!currentSession) return; currentSession.config.shape = button.dataset.svtoShape; modal.querySelectorAll('[data-svto-shape]').forEach(item => item.setAttribute('aria-pressed', String(item === button))); if (!currentSession.config.model_url && !currentSession.config.overlay_url) rebuildFrame(); })); modal.querySelector('[data-svto-fit]').addEventListener('input', event => { if (currentSession) currentSession.config.fit_adjustment = Number(event.target.value) / 100; }); return modal; } function setStatus(message) { const node = modal?.querySelector('.svto-status'); if (node && node.textContent !== message) node.textContent = message; } function openModal(config, triggerButton = null) { ensureModal(); closeSession(); const parsedConfig = sanitizeConfig(config); currentSession = { config: parsedConfig, triggerButton, stream: null, rafId: 0, landmarker: null, renderer: null, scene: null, camera: null, frameRoot: null, baseModel: null, overlayImage: null, lastVideoTime: -1, lastInferAt: 0, smooth: null, trackingLost: 0, three: null }; modal.hidden = false; document.documentElement.style.overflow = 'hidden'; modal.querySelector('.svto-consent').hidden = false; modal.querySelector('[data-svto-start]').disabled = false; modal.querySelector('[data-svto-start]').textContent = 'Enable Camera'; modal.querySelector('[data-svto-fit]').value = '100'; const productBadge = modal.querySelector('#svto-product-label'); if (productBadge) { productBadge.textContent = parsedConfig.name ? `${parsedConfig.name} (${parsedConfig.frame_width_mm}mm)` : 'Calibrated Eyewear'; } fillColourControls(parsedConfig.color); modal.querySelectorAll('[data-svto-shape]').forEach(btn => { btn.setAttribute('aria-pressed', String(btn.dataset.svtoShape === parsedConfig.shape)); }); modal.querySelector('.svto-shape-section').hidden = Boolean(parsedConfig.model_url || parsedConfig.overlay_url); modal.querySelector('.svto-colour-section').hidden = Boolean(parsedConfig.overlay_url && !parsedConfig.model_url); modal.querySelector('.svto-overlay-frame').style.display = 'none'; modal.querySelector('.svto-renderer').style.display = 'block'; setStatus('Camera standby. Click Enable camera to preview frames.'); } function fillColourControls(selected) { const container = modal.querySelector('[data-svto-colours]'); if (!container) return; container.replaceChildren(); COLOURS.forEach(colour => { const button = document.createElement('button'); button.type = 'button'; button.className = 'svto-colour'; button.style.background = colour.value; button.setAttribute('aria-label', colour.name); button.setAttribute('title', colour.name); button.setAttribute('aria-pressed', String(colour.value.toLowerCase() === selected.toLowerCase())); button.addEventListener('click', () => { if (!currentSession) return; currentSession.config.color = colour.value; container.querySelectorAll('button').forEach(item => item.setAttribute('aria-pressed', String(item === button))); if (!currentSession.config.model_url && !currentSession.config.overlay_url) rebuildFrame(); else recolourLoadedModel(colour.value); }); container.appendChild(button); }); } /** * Robust Three.js loading with direct CDN ESM module fallback (zero importmap dependency) */ async function loadThree() { if (window.THREE && window.THREE.GLTFLoader) { return { THREE: window.THREE, GLTFLoader: window.THREE.GLTFLoader }; } if (!threePromise) { threePromise = (async () => { if (supportsImportMaps()) { try { const THREE = await import('three'); const loaderModule = await import('three/addons/loaders/GLTFLoader.js'); return { THREE, GLTFLoader: loaderModule.GLTFLoader }; } catch (_) {} } // Direct CDN module loading without requiring importmap const THREE = await import(THREE_MODULE_URL); const loaderModule = await import(GLTF_LOADER_URL); return { THREE, GLTFLoader: loaderModule.GLTFLoader }; })().catch(error => { threePromise = null; throw new Error(`The 3D engine could not load. Check network/CSP settings, then retry. ${error?.message || ''}`); }); } return threePromise; } /** * Robust MediaPipe loading trying local vendored assets first, then CDN */ async function loadVision() { if (!visionPromise) { visionPromise = (async () => { try { const localModule = await import(LOCAL_MEDIAPIPE_URL); if (localModule && localModule.FaceLandmarker) return localModule; } catch (_) {} return await import(CDN_MEDIAPIPE_URL); })(); } return visionPromise; } async function startCamera() { if (!currentSession) return; const session = currentSession; const consent = modal.querySelector('.svto-consent'); const startButton = modal.querySelector('[data-svto-start]'); startButton.disabled = true; startButton.innerHTML = 'Starting camera…'; setStatus('Requesting camera permission…'); try { const hasWebGL2 = supportsWebGL2(); const has2DFallback = Boolean(session.config.overlay_url); if (!hasWebGL2 && !has2DFallback) { throw new Error(`This device does not support WebGL 2 required for 3D try-on. Please update ${RECOMMENDED_BROWSERS}; product details remain accessible.`); } if (!hasWebGL2 && has2DFallback) { session.config.model_url = ''; session.overlayOnly = true; } session.stream = await requestCameraStream(); const video = modal.querySelector('.svto-video'); video.setAttribute('playsinline', ''); video.setAttribute('webkit-playsinline', ''); video.muted = true; video.srcObject = session.stream; await video.play(); if (!video.videoWidth) { await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('The camera did not start. Please check your browser camera settings.')), 8000); video.addEventListener('loadedmetadata', () => { clearTimeout(timer); resolve(); }, { once: true }); video.addEventListener('error', () => { clearTimeout(timer); reject(new Error('The camera preview could not be displayed by this browser.')); }, { once: true }); }); } consent.hidden = true; setStatus('Loading face tracking… keep your face centered.'); if (session.overlayOnly) { if (!session.config.overlay_url) throw new Error('3D is unavailable on this device and no 2D fallback asset was found.'); await initializeOverlayOnly(session); } else { await initializeRenderer(session); } await initializeTracker(session); if (currentSession !== session) return; setStatus(session.overlayOnly ? 'Face tracked · 2D preview mode active.' : 'Face tracked · move slowly left and right to inspect the fit.'); session.rafId = requestAnimationFrame(() => renderLoop(session)); } catch (error) { console.error('[SPECTICO VTO] Camera or engine startup failed:', error); stopStream(session); if (session.rafId) cancelAnimationFrame(session.rafId); try { session.landmarker?.close?.(); } catch (_) {} session.landmarker = null; if (session.renderer) { session.scene?.traverse(object => { if (object.geometry) object.geometry.dispose(); if (object.material) (Array.isArray(object.material) ? object.material : [object.material]).forEach(m => m.dispose()); }); session.renderer.dispose(); session.renderer = null; } session.scene = null; session.camera = null; session.frameRoot = null; session.baseModel = null; if (session.resizeHandler) { window.removeEventListener('resize', session.resizeHandler); window.removeEventListener('orientationchange', session.resizeHandler); window.visualViewport?.removeEventListener('resize', session.resizeHandler); session.resizeHandler = null; } startButton.disabled = false; startButton.textContent = 'Try Again'; consent.hidden = false; const message = /Permission|NotAllowed|denied/i.test(`${error.name || ''} ${error.message || ''}`) ? 'Camera permission was denied. Please allow camera access in your browser address bar and try again.' : (error.message || 'Could not start the preview. Check connection and try again.'); setStatus(message); } } async function initializeRenderer(session) { const { THREE, GLTFLoader } = await loadThree(); if (currentSession !== session) return; session.three = THREE; const stage = modal.querySelector('.svto-stage'); const canvas = modal.querySelector('.svto-renderer'); const width = Math.max(1, stage.clientWidth); const height = Math.max(1, stage.clientHeight); const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, preserveDrawingBuffer: true, powerPreference: 'high-performance' }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(width, height, false); renderer.setClearColor(0x000000, 0); renderer.outputColorSpace = THREE.SRGBColorSpace; session.renderer = renderer; session.scene = new THREE.Scene(); session.camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, 0.1, 4000); session.camera.position.set(0, 0, 1200); session.scene.add(new THREE.AmbientLight(0xffffff, 2.2)); const key = new THREE.DirectionalLight(0xffffff, 1.4); key.position.set(-150, 220, 250); session.scene.add(key); session.frameRoot = new THREE.Group(); session.scene.add(session.frameRoot); if (session.config.model_url) { setStatus('Loading 3D eyewear model…'); try { const loader = new GLTFLoader(); const gltf = await new Promise((resolve, reject) => loader.load(session.config.model_url, resolve, undefined, reject)); if (currentSession !== session) return; const model = gltf.scene; model.rotation.set( session.config.rotation_x_deg * Math.PI / 180, session.config.rotation_y_deg * Math.PI / 180, session.config.rotation_z_deg * Math.PI / 180 ); model.updateMatrixWorld(true); const bounds = new THREE.Box3().setFromObject(model); const size = bounds.getSize(new THREE.Vector3()); const center = bounds.getCenter(new THREE.Vector3()); if (!size.x || !Number.isFinite(size.x)) throw new Error('GLB model has no measurable dimensions.'); model.position.sub(center); model.scale.setScalar(session.config.frame_width_mm / size.x); model.traverse(object => { if (object.isMesh) { object.castShadow = false; object.receiveShadow = false; if (object.material) object.material.side = THREE.DoubleSide; } }); model.userData.specticoOriginalMaterials = []; model.traverse(object => { if (!object.isMesh || !object.material) return; const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.forEach(material => model.userData.specticoOriginalMaterials.push({ mesh: object, material, colour: material.color?.clone?.() })); }); session.frameRoot.add(model); session.baseModel = model; session.modelIsGLB = true; recolourLoadedModel(session.config.color); } catch (error) { console.warn('[SPECTICO VTO] GLTF/GLB model failed to load; using procedural eyewear instead.', error); session.config.model_url = ''; session.config.overlay_url = ''; modal.querySelector('.svto-overlay-frame').style.display = 'none'; modal.querySelector('.svto-renderer').style.display = 'block'; modal.querySelector('.svto-shape-section').hidden = false; modal.querySelector('.svto-colour-section').hidden = false; buildProceduralFrame(session); setStatus('Showing tailored 3D procedural frame preview.'); } } else if (session.config.overlay_url) { const image = modal.querySelector('.svto-overlay-frame'); image.onload = () => { session.overlayImage = image; }; image.onerror = () => { setStatus('The product image could not load. Showing procedural 3D frame instead.'); session.config.overlay_url = ''; image.style.display = 'none'; modal.querySelector('.svto-renderer').style.display = 'block'; buildProceduralFrame(session); }; image.src = session.config.overlay_url; session.frameRoot.visible = false; modal.querySelector('.svto-renderer').style.display = 'none'; image.style.display = 'block'; } else { buildProceduralFrame(session); } if (session.config.model_url) modal.querySelector('.svto-renderer').style.display = 'block'; session.resizeHandler = () => resizeRenderer(session); window.addEventListener('resize', session.resizeHandler, { passive: true }); window.addEventListener('orientationchange', session.resizeHandler, { passive: true }); window.visualViewport?.addEventListener('resize', session.resizeHandler, { passive: true }); } function roundedRectShape(THREE, width, height, radius) { const shape = new THREE.Shape(); const r = Math.min(radius, width / 2, height / 2); shape.moveTo(-width / 2 + r, -height / 2); shape.lineTo(width / 2 - r, -height / 2); shape.quadraticCurveTo(width / 2, -height / 2, width / 2, -height / 2 + r); shape.lineTo(width / 2, height / 2 - r); shape.quadraticCurveTo(width / 2, height / 2, width / 2 - r, height / 2); shape.lineTo(-width / 2 + r, height / 2); shape.quadraticCurveTo(-width / 2, height / 2, -width / 2, height / 2 - r); shape.lineTo(-width / 2, -height / 2 + r); shape.quadraticCurveTo(-width / 2, -height / 2, -width / 2 + r, -height / 2); return shape; } function circularShape(THREE, width, height, inset = 0) { const radius = Math.max(1, Math.min(width, height) / 2 - inset); const shape = new THREE.Shape(); shape.absarc(0, 0, radius, 0, Math.PI * 2, false); return shape; } function aviatorShape(THREE, width, height, inset = 0) { const w = Math.max(1, width - inset * 2), h = Math.max(1, height - inset * 2); const s = new THREE.Shape(); s.moveTo(-w * 0.5, h * 0.22); s.quadraticCurveTo(-w * 0.5, h * 0.48, -w * 0.22, h * 0.48); s.quadraticCurveTo(0, h * 0.52, w * 0.22, h * 0.48); s.quadraticCurveTo(w * 0.5, h * 0.48, w * 0.5, h * 0.22); s.quadraticCurveTo(w * 0.48, -h * 0.05, w * 0.37, -h * 0.31); s.quadraticCurveTo(w * 0.20, -h * 0.50, 0, -h * 0.50); s.quadraticCurveTo(-w * 0.20, -h * 0.50, -w * 0.37, -h * 0.31); s.quadraticCurveTo(-w * 0.48, -h * 0.05, -w * 0.5, h * 0.22); return s; } function catEyeShape(THREE, width, height, inset = 0) { const w = Math.max(1, width - inset * 2), h = Math.max(1, height - inset * 2); const s = new THREE.Shape(); s.moveTo(-w * 0.48, h * 0.15); s.quadraticCurveTo(-w * 0.52, h * 0.50, -w * 0.15, h * 0.42); s.quadraticCurveTo(w * 0.15, h * 0.42, w * 0.48, h * 0.55); s.quadraticCurveTo(w * 0.52, h * 0.20, w * 0.36, -h * 0.25); s.quadraticCurveTo(w * 0.15, -h * 0.48, 0, -h * 0.48); s.quadraticCurveTo(-w * 0.30, -h * 0.48, -w * 0.48, h * 0.15); return s; } function frameLensShape(THREE, shape, width, height, thickness, inset = 0) { const w = width - inset * 2, h = height - inset * 2; if (shape === 'round') return circularShape(THREE, w, h, 0); if (shape === 'aviator') return aviatorShape(THREE, width, height, inset); if (shape === 'cat-eye') return catEyeShape(THREE, width, height, inset); return roundedRectShape(THREE, w, h, Math.max(3, h * 0.14)); } function buildProceduralFrame(session) { const THREE = session.three; if (!THREE || !session.frameRoot) return; session.frameRoot.traverse(object => { if (object.geometry) object.geometry.dispose(); if (object.material) (Array.isArray(object.material) ? object.material : [object.material]).forEach(m => m.dispose()); }); session.frameRoot.clear(); session.baseModel = null; session.modelIsGLB = false; const c = session.config; const lensW = c.lens_width_mm; const lensH = c.lens_height_mm; const bridge = c.bridge_width_mm; const rim = c.frame_thickness_mm; const depth = Math.max(1.5, rim * 0.7); const centres = [-(lensW / 2 + bridge / 2), lensW / 2 + bridge / 2]; const isMetal = c.color.toLowerCase() === '#b99a63' || c.color.toLowerCase() === '#aeb4b8'; const frameMaterial = new THREE.MeshStandardMaterial({ color: c.color, roughness: isMetal ? 0.22 : 0.35, metalness: isMetal ? 0.65 : 0.12 }); const lensMaterial = new THREE.MeshPhysicalMaterial({ color: 0xe9e8e1, transparent: true, opacity: 0.18, roughness: 0.10, metalness: 0, side: THREE.DoubleSide, depthWrite: false }); const shapeBuilder = (w, h, inset = 0) => frameLensShape(THREE, c.shape, w, h, rim, inset); centres.forEach((x, index) => { const outer = shapeBuilder(lensW, lensH, 0); outer.holes.push(shapeBuilder(lensW, lensH, rim * 1.35)); const geometry = new THREE.ExtrudeGeometry(outer, { depth, bevelEnabled: true, bevelSegments: 2, steps: 1, bevelSize: 0.45, bevelThickness: 0.35, curveSegments: 16 }); geometry.translate(x, 0, -depth / 2); const rimMesh = new THREE.Mesh(geometry, frameMaterial); rimMesh.name = index === 0 ? 'frame-rim-left' : 'frame-rim-right'; session.frameRoot.add(rimMesh); const lensShape = shapeBuilder(lensW, lensH, rim * 1.35); const lensGeometry = new THREE.ShapeGeometry(lensShape, 24); lensGeometry.translate(x, 0, -depth * 0.14); const lensMesh = new THREE.Mesh(lensGeometry, lensMaterial); lensMesh.name = index === 0 ? 'lens-left' : 'lens-right'; session.frameRoot.add(lensMesh); const hingeX = x + (index === 0 ? -lensW / 2 : lensW / 2); const hinge = new THREE.Mesh(new THREE.BoxGeometry(rim * 2.2, rim * 1.7, depth * 1.6), frameMaterial); hinge.position.set(hingeX, 0, 0); hinge.name = index === 0 ? 'temple-hinge-left' : 'temple-hinge-right'; session.frameRoot.add(hinge); const direction = index === 0 ? -1 : 1; const templePoints = [ new THREE.Vector3(hingeX, 0, 0), new THREE.Vector3(hingeX + direction * 5, -1, -c.temple_length_mm * 0.20), new THREE.Vector3(hingeX + direction * 7, -2.5, -c.temple_length_mm * 0.56), new THREE.Vector3(hingeX + direction * 6, -5, -c.temple_length_mm * 0.91) ]; const templeCurve = new THREE.CatmullRomCurve3(templePoints); const templeGeometry = new THREE.TubeGeometry(templeCurve, 24, Math.max(0.9, rim * 0.42), 7, false); const temple = new THREE.Mesh(templeGeometry, frameMaterial); temple.name = index === 0 ? 'temple-left' : 'temple-right'; session.frameRoot.add(temple); }); const bridgeCurve = new THREE.CatmullRomCurve3([ new THREE.Vector3(-bridge / 2, 0, 0), new THREE.Vector3(-bridge * 0.24, rim * 1.8, depth * 0.15), new THREE.Vector3(bridge * 0.24, rim * 1.8, depth * 0.15), new THREE.Vector3(bridge / 2, 0, 0) ]); session.frameRoot.add(new THREE.Mesh(new THREE.TubeGeometry(bridgeCurve, 18, Math.max(1, rim * 0.48), 8, false), frameMaterial)); session.frameRoot.visible = true; session.overlayImage = null; } function rebuildFrame() { if (!currentSession?.three) return; buildProceduralFrame(currentSession); } function recolourLoadedModel(colour) { const session = currentSession; if (!session?.baseModel || !session.modelIsGLB || !session.three) return; session.baseModel.traverse(object => { if (!object.isMesh || !object.material) return; const meshName = `${object.name || ''} ${(Array.isArray(object.material) ? object.material : [object.material]).map(m => m.name || '').join(' ')}`.toLowerCase(); if (!/(frame|rim|temple|front|acetate|metal|hinge)/.test(meshName)) return; const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.forEach(material => { if (material.color) { material.color.set(colour); material.needsUpdate = true; } }); }); } async function initializeTracker(session) { const { FilesetResolver, FaceLandmarker } = await loadVision(); if (currentSession !== session) return; let vision; try { vision = await FilesetResolver.forVisionTasks(LOCAL_MEDIAPIPE_WASM); } catch (_) { vision = await FilesetResolver.forVisionTasks(CDN_MEDIAPIPE_WASM); } if (currentSession !== session) return; const modelAssetPath = CONFIG.mediapipeModelUrl || LOCAL_MODEL_URL; const options = { baseOptions: { modelAssetPath, delegate: 'GPU' }, runningMode: 'VIDEO', numFaces: 1, minFaceDetectionConfidence: 0.55, minFacePresenceConfidence: 0.55, minTrackingConfidence: 0.55, outputFaceBlendshapes: false, outputFacialTransformationMatrixes: false }; try { session.landmarker = await FaceLandmarker.createFromOptions(vision, options); } catch (gpuError) { console.warn('[SPECTICO VTO] GPU delegate unavailable; retrying on CPU.', gpuError); options.baseOptions.delegate = 'CPU'; try { session.landmarker = await FaceLandmarker.createFromOptions(vision, options); } catch (_) { // Fallback to CDN model URL if local model failed to load options.baseOptions.modelAssetPath = CDN_MODEL_URL; session.landmarker = await FaceLandmarker.createFromOptions(vision, options); } } } function mapLandmark(lm, video, stage, mirror = true) { const vw = video.videoWidth, vh = video.videoHeight; const sw = stage.clientWidth, sh = stage.clientHeight; const scale = Math.max(sw / vw, sh / vh); const drawnW = vw * scale, drawnH = vh * scale; const offsetX = (sw - drawnW) / 2, offsetY = (sh - drawnH) / 2; let x = lm.x * drawnW + offsetX; const y = lm.y * drawnH + offsetY; if (mirror) x = sw - x; return { x, y, z: lm.z * vw }; } /** * 3D Face Pose Estimation: * Calculates Midpoint (X, Y), Interpupillary Scale (span), Roll, Yaw, AND Pitch angles. */ function estimatePose(landmarks, video, stage) { const a = mapLandmark(landmarks[33], video, stage, true); const b = mapLandmark(landmarks[263], video, stage, true); const left = a.x <= b.x ? a : b; const right = a.x <= b.x ? b : a; const nose = mapLandmark(landmarks[1], video, stage, true); const span = Math.hypot(right.x - left.x, right.y - left.y); if (!Number.isFinite(span) || span < 12) return null; const midX = (left.x + right.x) / 2; const midY = (left.y + right.y) / 2; const roll = Math.atan2(-(right.y - left.y), right.x - left.x); // Approximate Yaw from horizontal nose displacement relative to eye midpoint const yaw = clamp(((nose.x - midX) / span) * 3.6, -0.85, 0.85); // Approximate Pitch from vertical nose displacement relative to eye midpoint (norm ~ 0.40) const verticalRatio = (nose.y - midY) / span; const pitch = clamp((verticalRatio - 0.40) * 2.8, -0.55, 0.55); return { x: midX, y: midY + span * 0.045, span, roll, yaw, pitch }; } function renderLoop(session) { if (currentSession !== session || (!session.renderer && !session.overlayOnly)) return; session.rafId = requestAnimationFrame(() => renderLoop(session)); const video = modal.querySelector('.svto-video'); const stage = modal.querySelector('.svto-stage'); if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || !video.videoWidth) { session.renderer?.render(session.scene, session.camera); return; } const now = performance.now(); if (video.currentTime !== session.lastVideoTime && now - session.lastInferAt > 33 && session.landmarker) { session.lastVideoTime = video.currentTime; session.lastInferAt = now; try { const result = session.landmarker.detectForVideo(video, now); const landmarks = result.faceLandmarks?.[0]; if (landmarks) { const pose = estimatePose(landmarks, video, stage); if (pose) { session.trackingLost = 0; if (!session.smooth) { session.smooth = { ...pose }; } // Adaptive temporal smoothing: low-motion alpha reduces jitter, high-motion alpha avoids lag const motion = Math.hypot(pose.x - session.smooth.x, pose.y - session.smooth.y); const tPos = motion < 1.2 ? 0.20 : 0.44; const tAngle = 0.28; session.smooth.x = lerp(session.smooth.x, pose.x, tPos); session.smooth.y = lerp(session.smooth.y, pose.y, tPos); session.smooth.span = lerp(session.smooth.span, pose.span, tPos); session.smooth.roll += normaliseAngle(pose.roll - session.smooth.roll) * tAngle; session.smooth.yaw = lerp(session.smooth.yaw, pose.yaw, tAngle); session.smooth.pitch = lerp(session.smooth.pitch || 0, pose.pitch || 0, tAngle); applyPose(session, session.smooth, stage); setStatus('Face tracked · turn slowly to check the temple and bridge fit.'); } } else { session.trackingLost++; if (session.trackingLost > 4) { if (session.frameRoot) session.frameRoot.visible = false; const overlay = modal.querySelector('.svto-overlay-frame'); if (overlay) overlay.style.opacity = '0'; setStatus('Looking for a face. Face the camera directly.'); } } } catch (error) { console.warn('[SPECTICO VTO] Tracking tick error:', error); setStatus('Tracking paused briefly. Keep the camera steady.'); } } session.renderer?.render(session.scene, session.camera); } function applyPose(session, pose, stage) { const { config, frameRoot } = session; const canvasWidth = stage.clientWidth; const canvasHeight = stage.clientHeight; if (session.renderer && ( session.renderer.domElement.width !== Math.round(canvasWidth * Math.min(window.devicePixelRatio || 1, 2)) || session.renderer.domElement.height !== Math.round(canvasHeight * Math.min(window.devicePixelRatio || 1, 2)) )) { resizeRenderer(session); } if (frameRoot) { const projectedCorrection = Math.max(0.55, Math.cos(pose.yaw)); const desiredWidth = pose.span * 1.66 * config.fit_adjustment / projectedCorrection; const scale = desiredWidth / Math.max(1, config.frame_width_mm); frameRoot.position.set( pose.x - canvasWidth / 2 + config.offset_x_mm * scale, canvasHeight / 2 - pose.y + config.offset_y_mm * scale, config.offset_z_mm * scale ); frameRoot.scale.setScalar(scale); frameRoot.rotation.set(pose.pitch || 0, pose.yaw, pose.roll); frameRoot.visible = !config.overlay_url || Boolean(config.model_url); } const overlay = modal.querySelector('.svto-overlay-frame'); if (config.overlay_url && !config.model_url && session.overlayImage) { const image = session.overlayImage; const width = pose.span * 1.66 * config.fit_adjustment; const aspect = (image.naturalWidth && image.naturalHeight) ? image.naturalHeight / image.naturalWidth : 0.42; overlay.style.width = `${width}px`; overlay.style.height = `${width * aspect}px`; overlay.style.left = `${pose.x - width / 2}px`; overlay.style.top = `${pose.y - width * aspect / 2}px`; overlay.style.transform = `rotate(${-pose.roll}rad)`; overlay.style.opacity = '1'; } } function resizeRenderer(session) { if (!session?.renderer || !session.camera) return; const stage = modal.querySelector('.svto-stage'); const width = Math.max(1, stage.clientWidth), height = Math.max(1, stage.clientHeight); session.renderer.setSize(width, height, false); session.camera.left = -width / 2; session.camera.right = width / 2; session.camera.top = height / 2; session.camera.bottom = -height / 2; session.camera.updateProjectionMatrix(); } function drawVideoCover(ctx, video, width, height) { const vw = video.videoWidth, vh = video.videoHeight; const scale = Math.max(width / vw, height / vh); const sw = vw * scale, sh = vh * scale; const sx = (width - sw) / 2, sy = (height - sh) / 2; ctx.save(); ctx.translate(width, 0); ctx.scale(-1, 1); ctx.drawImage(video, sx, sy, sw, sh); ctx.restore(); } function saveSnapshot() { if (!currentSession?.stream) { setStatus('Start camera before saving a preview.'); return; } const video = modal.querySelector('.svto-video'); const stage = modal.querySelector('.svto-stage'); const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight); const canvas = document.createElement('canvas'); canvas.width = w * 2; canvas.height = h * 2; const ctx = canvas.getContext('2d'); try { ctx.scale(2, 2); drawVideoCover(ctx, video, w, h); if (currentSession.renderer && !currentSession.config.overlay_url) { ctx.drawImage(currentSession.renderer.domElement, 0, 0, w, h); } const overlay = modal.querySelector('.svto-overlay-frame'); if (currentSession.config.overlay_url && overlay.complete && overlay.naturalWidth) { const rect = overlay.getBoundingClientRect(); const sr = stage.getBoundingClientRect(); const x = rect.left - sr.left, y = rect.top - sr.top, ww = rect.width, hh = rect.height; const angle = -currentSession.smooth?.roll || 0; ctx.save(); ctx.translate(x + ww / 2, y + hh / 2); ctx.rotate(angle); ctx.drawImage(overlay, -ww / 2, -hh / 2, ww, hh); ctx.restore(); } canvas.toBlob(blob => { if (!blob) { setStatus('Could not generate preview image.'); return; } const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `spectico-tryon-${currentSession.config.product_id || 'frame'}-${Date.now()}.png`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setStatus('Snapshot saved to your device. No photo was uploaded.'); }, 'image/png'); } catch (error) { console.error('[SPECTICO VTO] Could not save snapshot:', error); setStatus('Snapshot could not be saved due to canvas export restriction.'); } } function stopStream(session) { if (session?.stream) { session.stream.getTracks().forEach(track => track.stop()); session.stream = null; } const video = modal?.querySelector('.svto-video'); if (video) video.srcObject = null; } function closeSession() { const session = currentSession; if (!session) return; if (session.rafId) cancelAnimationFrame(session.rafId); if (session.resizeHandler) { window.removeEventListener('resize', session.resizeHandler); window.removeEventListener('orientationchange', session.resizeHandler); window.visualViewport?.removeEventListener('resize', session.resizeHandler); } stopStream(session); try { session.landmarker?.close?.(); } catch (_) {} if (session.renderer) { session.scene?.traverse(object => { if (object.geometry) object.geometry.dispose(); if (object.material) { const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.forEach(m => m.dispose()); } }); session.renderer.dispose(); } currentSession = null; } function closeModal() { const triggerButton = currentSession?.triggerButton; closeSession(); if (modal) { modal.hidden = true; document.documentElement.style.overflow = ''; const video = modal.querySelector('.svto-video'); if (video) video.srcObject = null; } triggerButton?.focus?.(); } document.addEventListener('keydown', event => { if (event.key === 'Escape' && modal && !modal.hidden) closeModal(); }); document.addEventListener('click', event => { const button = event.target.closest('.spectico-vto-trigger'); if (!button) return; event.preventDefault(); openModal(parseConfig(button), button); }); window.addEventListener('pagehide', closeSession); // Expose public API on window for SPECTICO storefront window.specticoVTO = { openModal, closeModal, startCamera, saveSnapshot, parseConfig, resolveProductFromStore, openForProduct(productId, triggerElement = null) { const config = resolveProductFromStore(productId); openModal(config, triggerElement); } };