/* 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 = `
Camera standby. Click Enable camera to preview frames.
Try Before You Choose
Preview SPECTICO frames on your face in real time. Video is processed locally in your browser and is never recorded or uploaded.
`;
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);
}
};