window.Customization3DViewer = (function () { 'use strict'; const FALLBACK_ENV_URL = 'https://sdk.zguiy.com/resurces/hdr/hdr.env'; const CANVAS_ID = 'preview-3d-viewer'; const RUNTIME_MODEL_ID = 'main-model'; let kernel = null; let sdkInitialized = false; let initPromise = null; let sdkCapabilityLogged = false; let sdkEventsBound = false; let currentModelId = null; let currentModelUrl = null; const hasFn = (obj, key) => !!obj && typeof obj[key] === 'function'; const awaitIfPromise = (v) => (v?.then ? v : Promise.resolve(v)); const el = { loading: () => document.querySelector('[data-3d-loading]'), empty: () => document.querySelector('[data-3d-empty]'), container: () => document.querySelector('[data-3d-container]'), progressBar: () => document.querySelector('[data-3d-progress-bar]'), progressText: () => document.querySelector('[data-3d-progress-text]'), }; const ensureCanvas = () => { const existing = document.getElementById(CANVAS_ID); if (!existing) return null; if (existing.tagName === 'CANVAS') return existing; const canvas = document.createElement('canvas'); canvas.id = CANVAS_ID; canvas.className = existing.className || 'preview-3d-viewer'; canvas.style.cssText = 'width:100%;height:100%;display:block;'; existing.innerHTML = ''; existing.appendChild(canvas); return canvas; }; const resizeCanvas = (canvas) => { if (!canvas) return; const dpr = window.devicePixelRatio || 1; const { width, height } = canvas.getBoundingClientRect(); const w = Math.max(1, Math.round(width * dpr)); const h = Math.max(1, Math.round(height * dpr)); if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; }; const poll = (check, maxWait = 5000, interval = 100) => new Promise((resolve) => { if (check()) { resolve(true); return; } const start = Date.now(); const ticker = setInterval(() => { if (check()) { clearInterval(ticker); resolve(true); } else if (Date.now() - start >= maxWait) { clearInterval(ticker); resolve(false); } }, interval); }); const waitForSDK = () => poll(() => !!window.faceSDK?.kernel); const waitForContainer = () => { const canvas = ensureCanvas(); if (!canvas) return Promise.resolve(false); return poll(() => { const { width, height } = canvas.getBoundingClientRect(); return width > 0 && height > 0 && canvas.offsetParent !== null; }).then((ready) => { if (ready) resizeCanvas(canvas); return ready; }); }; const showLoading = (progress = 0) => { const pct = Math.round(progress); if (el.loading()) el.loading().style.display = 'flex'; if (el.empty()) el.empty().style.display = 'none'; if (el.progressBar()) el.progressBar().style.width = `${pct}%`; if (el.progressText()) el.progressText().textContent = `${pct}%`; }; const showEmpty = () => { if (el.loading()) el.loading().style.display = 'none'; if (el.empty()) el.empty().style.display = 'flex'; if (el.container()) el.container().classList.remove('has-model'); currentModelId = null; currentModelUrl = null; }; const showModelReady = () => { if (el.progressBar()) el.progressBar().style.width = '100%'; if (el.progressText()) el.progressText().textContent = '100%'; setTimeout(() => { if (el.loading()) el.loading().style.display = 'none'; if (el.empty()) el.empty().style.display = 'none'; if (el.container()) el.container().classList.add('has-model'); }, 300); }; const getEnvUrl = () => window.CUSTOMIZATION_3D_ENV_URL || FALLBACK_ENV_URL; const buildInitConfig = (canvas, modelUrlList = []) => ({ container: canvas, modelUrlList, env: { // envPath: getEnvUrl(), envPath: 'https://sdk.zguiy.com/resurces/hdr/hdr.env', intensity: 1.2, rotationY: 0.3, background: true, }, }); const getSDKCapability = () => { const k = kernel || window.faceSDK?.kernel; return { 'kernel.init': hasFn(k, 'init'), 'kernel.on': hasFn(k, 'on'), 'kernel.off': hasFn(k, 'off'), 'camera.set': hasFn(k?.camera, 'set'), 'camera.animateTo': hasFn(k?.camera, 'animateTo'), 'lights.update': hasFn(k?.lights, 'update'), 'environment.setHDRI': hasFn(k?.environment, 'setHDRI'), 'hotspot.render': hasFn(k?.hotspot, 'render'), 'hotspot.on': hasFn(k?.hotspot, 'on'), 'model.load': hasFn(k?.model, 'load'), 'model.replace': hasFn(k?.model, 'replace'), 'model.destroy': hasFn(k?.model, 'destroy'), 'model.on': hasFn(k?.model, 'on'), 'material.apply': hasFn(k?.material, 'apply'), 'material.batch': hasFn(k?.material, 'batch'), 'material.reset': hasFn(k?.material, 'reset'), 'debug': hasFn(k, 'debug'), }; }; const useRuntimeModelAPI = () => { const cap = getSDKCapability(); return cap['model.load'] && cap['model.replace']; }; const logCapabilityOnce = () => { if (sdkCapabilityLogged) return; sdkCapabilityLogged = true; const cap = getSDKCapability(); const strategy = useRuntimeModelAPI() ? 'runtime model API (load / replace / destroy)' : 'kernel.init(modelUrlList) fallback'; console.groupCollapsed('[3D] faceSDK capability report'); console.table(cap); console.log('[3D] active load strategy:', strategy); console.groupEnd(); }; const bindSDKEvents = () => { if (!hasFn(kernel, 'on') || sdkEventsBound) return; sdkEventsBound = true; kernel.on('model:load:progress', ({ progress = 0 } = {}) => showLoading(progress)); kernel.on('model:loaded', () => showModelReady()); kernel.on('model:replaced', () => showModelReady()); kernel.on('all:ready', (data) => { document.dispatchEvent(new CustomEvent('3d:scene:ready', { detail: data, bubbles: true })); }); kernel.on('model:click', (data) => document.dispatchEvent(new CustomEvent('3d:model:click', { detail: data, bubbles: true })) ); kernel.on('hotspot:click', (data) => document.dispatchEvent(new CustomEvent('3d:hotspot:click', { detail: data, bubbles: true })) ); if (hasFn(kernel?.hotspot, 'on')) { kernel.hotspot.on('click', (data) => document.dispatchEvent(new CustomEvent('3d:hotspot:click', { detail: data, bubbles: true })) ); kernel.hotspot.on('hover', (data) => document.dispatchEvent(new CustomEvent('3d:hotspot:hover', { detail: data, bubbles: true })) ); kernel.hotspot.on('rendered', () => document.dispatchEvent(new CustomEvent('3d:hotspot:rendered', { bubbles: true })) ); } if (hasFn(kernel, 'on')) { kernel.on('camera:changed', (state) => document.dispatchEvent(new CustomEvent('3d:camera:changed', { detail: state, bubbles: true })) ); } kernel.on('env:error', (err) => console.warn('[3D] Environment map error:', err)); }; const initSDK = async () => { if (sdkInitialized) return true; if (initPromise) return initPromise; initPromise = (async () => { try { const [sdkReady, containerReady] = await Promise.all([ waitForSDK(), waitForContainer(), ]); if (!sdkReady || !containerReady) { throw new Error('[3D] SDK or container not ready'); } const canvas = ensureCanvas(); if (!canvas) throw new Error('[3D] Canvas element not found'); if (canvas.getBoundingClientRect().width === 0) { canvas.style.minWidth = '100px'; canvas.style.minHeight = '100px'; } resizeCanvas(canvas); await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r)); kernel = window.faceSDK.kernel; logCapabilityOnce(); bindSDKEvents(); await awaitIfPromise(kernel.init(buildInitConfig(canvas, []))); sdkInitialized = true; return true; } catch (err) { console.error('[3D] Initialization failed:', err); return false; } finally { initPromise = null; } })(); return initPromise; }; const loadModel = async (modelUrl, productId = null) => { if (!modelUrl) { showEmpty(); return false; } if (!sdkInitialized && !await initSDK()) { showEmpty(); return false; } const modelId = productId ? `product-${productId}` : RUNTIME_MODEL_ID; if (currentModelId === modelId && currentModelUrl === modelUrl) return true; try { showLoading(0); resizeCanvas(ensureCanvas()); if (useRuntimeModelAPI()) { if (currentModelUrl) { await awaitIfPromise( kernel.model.replace(RUNTIME_MODEL_ID, { url: modelUrl, draco: true }) ); } else { await awaitIfPromise( kernel.model.load({ id: RUNTIME_MODEL_ID, url: modelUrl, draco: true }) ); } } else { bindSDKEvents(); await awaitIfPromise( kernel.init(buildInitConfig(ensureCanvas(), [modelUrl])) ); } currentModelId = modelId; currentModelUrl = modelUrl; return true; } catch (err) { console.error('[3D] loadModel failed:', err); showEmpty(); return false; } }; const clearModel = async () => { if (sdkInitialized && hasFn(kernel?.model, 'destroy')) { try { await awaitIfPromise(kernel.model.destroy(RUNTIME_MODEL_ID)); } catch (err) { console.warn('[3D] clearModel: model destroy failed:', err); } } currentModelId = null; currentModelUrl = null; showEmpty(); }; const getModelUrl = async (productId, _variantId = null, wrapper = null) => { const card = wrapper || document.querySelector(`.product-card-clickable[data-product-id="${productId}"]`); if (card) { const url = card.dataset.model3dUrl || card.dataset.modelUrl; if (url) return url; } const handle = card?.dataset.productHandle; if (handle) { try { const res = await fetch(`/products/${encodeURIComponent(handle)}.js`); if (res.ok) { const product = await res.json(); const media = (product.media || []).find(m => m.media_type === 'model'); if (media) { const url = media.sources?.[0]?.url || media.src; if (url) return url; } const metaUrl = product.metafields?.custom?.model_3d_url; if (metaUrl) return metaUrl; } } catch (err) { console.warn('[3D] getModelUrl: product fetch failed:', err); } } return window.CUSTOMIZATION_3D_FALLBACK_MODEL_URL || null; }; const hotspot = { render: (items = []) => { if (!hasFn(kernel?.hotspot, 'render')) { console.warn('[3D] hotspot.render not available in current SDK version'); return false; } kernel.hotspot.render(items); return true; }, clear: () => { if (hasFn(kernel?.hotspot, 'render')) kernel.hotspot.render([]); }, }; const material = { apply: (target, preset) => { if (!hasFn(kernel?.material, 'apply')) { console.warn('[3D] material.apply not available in current SDK version'); return false; } kernel.material.apply({ target, material: preset }); return true; }, batch: (entries = []) => { if (!hasFn(kernel?.material, 'batch')) { console.warn('[3D] material.batch not available in current SDK version'); return false; } kernel.material.batch(entries); return true; }, reset: (target) => { if (!hasFn(kernel?.material, 'reset')) { console.warn('[3D] material.reset not available in current SDK version'); return false; } kernel.material.reset(target); return true; }, }; const camera = { set: (config) => { if (!hasFn(kernel?.camera, 'set')) { console.warn('[3D] camera.set not available in current SDK version'); return false; } kernel.camera.set(config); return true; }, animateTo: (config, options) => { if (!hasFn(kernel?.camera, 'animateTo')) { console.warn('[3D] camera.animateTo not available in current SDK version'); return false; } kernel.camera.animateTo(config, options); return true; }, }; const lights = { update: (name, config) => { if (!hasFn(kernel?.lights, 'update')) { console.warn('[3D] lights.update not available in current SDK version'); return false; } kernel.lights.update(name, config); return true; }, }; const door = { toggle: (config = {}) => { if (!hasFn(kernel?.door, 'toggle')) { console.warn('[3D] door.toggle not available in current SDK version'); return false; } kernel.door.toggle(config); return true; }, }; const clipping = { setY: (height, keepBelow = true, meshNames = []) => { if (!hasFn(kernel?.clipping, 'setY')) { console.warn('[3D] clipping.setY not available in current SDK version'); return false; } kernel.clipping.setY(height, keepBelow, meshNames); return true; }, }; return { init: initSDK, loadModel, clearModel, showEmpty, getModelUrl, getSDKCapability, isInitialized: () => sdkInitialized, getCurrentModelId: () => currentModelId, hotspot, material, camera, lights, door, clipping, }; })(); (function () { const tryInit = () => { if (document.querySelector('.customization-3d-wrapper')) { window.Customization3DViewer.init(); } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(tryInit, 300)); } else { setTimeout(tryInit, 300); } })();