456 lines
14 KiB
JavaScript
456 lines
14 KiB
JavaScript
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);
|
|
}
|
|
})();
|