This commit is contained in:
2026-04-24 11:20:27 +08:00
parent e7c1611f6b
commit 09359a1647
20 changed files with 1565 additions and 259 deletions

View File

@ -0,0 +1,173 @@
(function () {
'use strict';
if (!document.querySelector('.customization-3d-wrapper')) return;
const get3DViewer = () => window.Customization3DViewer;
const load3DModel = (modelUrl, productId) => {
const viewer = get3DViewer();
return viewer ? viewer.loadModel(modelUrl, productId) : Promise.resolve(false);
};
const clear3DModel = () => {
const viewer = get3DViewer();
return viewer ? viewer.clearModel() : Promise.resolve();
};
const show3DEmpty = () => {
get3DViewer()?.showEmpty();
};
const get3DModelUrl = (productId, variantId, wrapper) => {
const viewer = get3DViewer();
return viewer ? viewer.getModelUrl(productId, variantId, wrapper) : Promise.resolve(null);
};
const get3DHotspots = () => {
const hotspots = window.CUSTOMIZATION_3D_HOTSPOTS;
if (!Array.isArray(hotspots)) return [];
return hotspots
.filter((item) => item && typeof item === 'object')
.map((item) => {
const meshName = String(item.meshName || '').trim();
if (!meshName) return null;
let offset = [0, 0, 0];
if (Array.isArray(item.offset) && item.offset.length >= 3) {
const parsed = item.offset.slice(0, 3).map((v) => Number(v));
if (parsed.every(Number.isFinite)) {
const maxAbs = Math.max(...parsed.map((v) => Math.abs(v)));
offset = parsed;
}
}
const next = {
id: String(item.id || meshName),
name: String(item.name || item.id || meshName),
meshName,
offset,
};
const color = String(item.color || '').trim();
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) next.color = color;
const r = Number(item.radius);
if (Number.isFinite(r) && r > 0) {
next.radius = Math.min(Math.max(r, 0.5), 30);
} else {
const defaultRadius = Number(window.CUSTOMIZATION_3D_HOTSPOT_RADIUS_DEFAULT);
next.radius = Number.isFinite(defaultRadius) && defaultRadius > 0
? Math.min(30, defaultRadius)
: 18;
}
const icon = String(item.icon || '').trim();
if (icon && (/^https?:\/\//i.test(icon) || icon.startsWith('//'))) {
next.icon = icon;
}
if (item.payload && typeof item.payload === 'object' && !Array.isArray(item.payload)) {
next.payload = item.payload;
}
return next;
})
.filter(Boolean);
};
const getHotspotActionConfig = (detail = {}) => {
const all = window.CUSTOMIZATION_3D_HOTSPOT_ACTIONS;
if (!all || typeof all !== 'object') return null;
return all[detail.id] || all[detail.name] || null;
};
const handle3DHotspotClick = (event) => {
const detail = event?.detail || {};
const viewer = get3DViewer();
if (!viewer) return;
if (typeof window.CUSTOMIZATION_3D_ON_HOTSPOT_CLICK === 'function') {
try {
window.CUSTOMIZATION_3D_ON_HOTSPOT_CLICK(detail, viewer);
} catch (err) {
console.warn('[Customization] CUSTOMIZATION_3D_ON_HOTSPOT_CLICK failed:', err);
}
}
const action = getHotspotActionConfig(detail);
if (action?.door) {
viewer.door?.toggle(action.door);
}
if (action?.clipping) {
const clip = action.clipping;
if (typeof clip.height === 'number') {
viewer.clipping?.setY(
clip.height,
clip.keepBelow !== false,
Array.isArray(clip.meshNames) ? clip.meshNames : []
);
}
}
};
const setup3DEventBridge = () => {
if (document.documentElement.dataset.customization3dEventsBoundCopy === '1') return;
document.documentElement.dataset.customization3dEventsBoundCopy = '1';
let hotspotRenderTimer = null;
const renderConfiguredHotspots = () => {
if (hotspotRenderTimer) clearTimeout(hotspotRenderTimer);
hotspotRenderTimer = setTimeout(() => {
hotspotRenderTimer = null;
const hotspots = get3DHotspots();
const viewer = get3DViewer();
viewer?.hotspot?.clear?.();
if (!hotspots.length) return;
viewer?.hotspot?.render(hotspots);
}, 200);
};
document.addEventListener('3d:scene:ready', renderConfiguredHotspots);
document.addEventListener('3d:hotspots:update', renderConfiguredHotspots);
document.addEventListener('3d:hotspot:click', handle3DHotspotClick);
};
const setupWheelScrollLockOn3DContainer = () => {
const container = document.querySelector('[data-3d-container]');
if (!container || container.dataset.customization3dWheelLockCopy === '1') return;
container.dataset.customization3dWheelLockCopy = '1';
container.addEventListener(
'wheel',
(e) => {
if (!container.contains(e.target)) return;
e.preventDefault();
},
{ capture: true, passive: false }
);
};
const refreshHotspots = () => {
document.dispatchEvent(new CustomEvent('3d:hotspots:update', { bubbles: true }));
};
window.Customization3DInteractions = {
load3DModel,
clear3DModel,
show3DEmpty,
get3DModelUrl,
get3DHotspots,
refreshHotspots,
};
const init3DInteractions = () => {
setup3DEventBridge();
setupWheelScrollLockOn3DContainer();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init3DInteractions);
} else {
init3DInteractions();
}
})();

View File

@ -0,0 +1,455 @@
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);
}
})();

224
test/demo.html Normal file
View File

@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Viewer Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
margin-bottom: 20px;
color: #333;
}
.controls {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.controls button {
padding: 10px 20px;
margin-right: 10px;
margin-bottom: 10px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
.controls button:hover {
background: #0056b3;
}
.controls input {
padding: 8px;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
width: 400px;
}
.customization-3d-wrapper {
position: relative;
width: 100%;
height: 600px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
[data-3d-container] {
width: 100%;
height: 100%;
position: relative;
}
#preview-3d-viewer {
width: 100%;
height: 100%;
}
[data-3d-loading] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.95);
z-index: 10;
}
[data-3d-loading] .progress-wrapper {
width: 200px;
}
[data-3d-progress-bar] {
width: 0%;
height: 4px;
background: #007bff;
border-radius: 2px;
transition: width 0.3s;
}
[data-3d-progress-text] {
margin-top: 10px;
font-size: 14px;
color: #666;
text-align: center;
}
[data-3d-empty] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 16px;
background: #fafafa;
}
</style>
</head>
<body>
<div class="container">
<h1>3D Viewer Demo</h1>
<div class="controls">
<div style="margin-bottom: 15px;">
<input type="text" id="modelUrl" placeholder="输入模型 URL (GLB/GLTF)"
value="https://sdk.zguiy.com/resurces/model/model.glb">
</div>
<button onclick="loadModel()">加载模型</button>
<button onclick="clearModel()">清除模型</button>
</div>
<div class="customization-3d-wrapper">
<div data-3d-container>
<div id="preview-3d-viewer"></div>
</div>
<div data-3d-loading style="display:none;">
<div class="progress-wrapper">
<div data-3d-progress-bar></div>
<div data-3d-progress-text>0%</div>
</div>
</div>
<div data-3d-empty style="display:flex;">
暂无模型,请加载一个 3D 模型
</div>
</div>
</div>
<!-- SDK 脚本 -->
<script src="https://sdk.zguiy.com/zt/assets/index.global.js"></script>
<!-- 3D 查看器核心 -->
<script src="customization-3d-viewer.js"></script>
<!-- 3D 交互层 -->
<script src="customization-3d-copy.js"></script>
<!-- 配置和交互脚本 -->
<script>
// 配置环境贴图
window.CUSTOMIZATION_3D_ENV_URL = 'https://sdk.zguiy.com/resurces/hdr/hdr.env';
// 配置热点
window.CUSTOMIZATION_3D_HOTSPOTS = [
{
id: "h1",
name: "卷帘门",
meshName: "Valve_01",
icon: "https://bpic.588ku.com/element_pic/20/06/30/d1046b01afc0b9586844350d131f4daf.jpg!/fw/253/quality/90/unsharp/true/compress/true",
offset: [25, 25, 0],
radius: 20,
color: "#21c7ff",
payload: { type: "valve", code: "A" },
},
];
// 热点点击回调
window.CUSTOMIZATION_3D_ON_HOTSPOT_CLICK = (detail, viewer) => {
console.log('热点被点击:', detail);
};
// 加载模型
async function loadModel() {
const modelUrl = document.getElementById('modelUrl').value.trim();
if (!modelUrl) {
alert('请输入模型 URL');
return;
}
try {
await window.Customization3DInteractions.load3DModel(modelUrl, 'demo');
} catch (err) {
console.error('模型加载错误:', err);
}
}
// 清除模型
async function clearModel() {
try {
await window.Customization3DInteractions.clear3DModel();
} catch (err) {
console.error('清除模型错误:', err);
}
}
</script>
</body>
</html>