diff --git a/assets/百叶1.glb b/assets/百叶1.glb new file mode 100644 index 0000000..da9daf3 Binary files /dev/null and b/assets/百叶1.glb differ diff --git a/assets/百叶2.glb b/assets/百叶2.glb new file mode 100644 index 0000000..dc62dd4 Binary files /dev/null and b/assets/百叶2.glb differ diff --git a/assets/百叶3.glb b/assets/百叶3.glb new file mode 100644 index 0000000..9d58ad8 Binary files /dev/null and b/assets/百叶3.glb differ diff --git a/assets/百叶4.glb b/assets/百叶4.glb new file mode 100644 index 0000000..1a9b6b4 Binary files /dev/null and b/assets/百叶4.glb differ diff --git a/examples/btn_热点.png b/examples/btn_热点.png new file mode 100644 index 0000000..704efe7 Binary files /dev/null and b/examples/btn_热点.png differ diff --git a/examples/global-demo.html b/examples/global-demo.html index 304b183..9dfcdf1 100644 --- a/examples/global-demo.html +++ b/examples/global-demo.html @@ -62,6 +62,29 @@ sdkKernel.on('model:click', (data) => { console.log('模型点击事件', data); }); + sdkKernel.on('all:ready', (data) => { + console.log('所有模块加载完,', data); + sdkKernel.material.apply({ + target: 'Material__2', + attribute: 'alpha', + value: 0.5, + }); + + + sdkKernel.hotspot.render([ + { + 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" }, + }, + ]); + }); + } diff --git a/examples/module-demo.html b/examples/module-demo.html index bdf2c33..6da9084 100644 --- a/examples/module-demo.html +++ b/examples/module-demo.html @@ -2,70 +2,185 @@ - - - SDK 模块化加载示例 + + + 3D Model Showcase SDK - TS + - - + // const config = { + // container: document.querySelector('#renderDom'), + // modelUrlList: ['/assets/model.glb'], + // env: { envPath: '/assets/hdr.env', intensity: 1.2, rotationY: 0.3, background: false }, + // }; - - + + kernel.on('model:loaded', (data) => { + console.log('模型加载完成', data); + // 隐藏进度条 + const progressContainer = document.getElementById('progress-container'); + if (progressContainer) { + progressContainer.style.display = 'none'; + } + + + + }); + + kernel.on('all:ready', (data) => { + console.log('所有模块加载完,', data); + kernel.material.apply({ + target: 'Material__2', + attribute: 'alpha', + value: 0.5, + }); + + + kernel.hotspot.render([ + { + id: "h1", + name: "卷帘门", + meshName: "Valve_01", + icon: "./btn_热点.png", + offset: [25, 25, 0], + radius: 20, + color: "#21c7ff", + payload: { type: "valve", code: "A" }, + }, + ]); + }); + + + kernel.on('model:click', (data) => { + console.log('模型点击事件', data); + console.log(data); + + }); + + kernel.on('hotspot:click', (data) => { + console.log('热点被点击:', data); + const { id, name } = data + if (name === "卷帘门") { + kernel.door.toggle({ upY: 28, downY: 0, speed: 12 }); + + // Y轴剖切,只作用于卷帘门网格,保留下方,剖掉上方 + const clipHeight = 28; // 调整这个值找到合适的剖切高度 + console.log('设置剖切:', clipHeight); + kernel.clipping.setY(clipHeight, true, ['Box005.001', 'Box006.001']); + } + }); + + + // 添加模型到场景 + await kernel.model.add('/models/car.glb'); + + // 销毁模型 + kernel.model.destroy('car'); + + // 替换模型 + await kernel.model.replace('car', '/models/new-car.glb'); + + + \ No newline at end of file diff --git a/index.html b/index.html index f8d0441..4d25b21 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,12 @@ #app { width: 100vw; height: 100vh; + display: flex; + position: relative; + } + + #canvas-container { + flex: 1; position: relative; } @@ -30,6 +36,157 @@ display: block; } + #config-panel { + width: 320px; + background: rgba(30, 30, 45, 0.95); + backdrop-filter: blur(10px); + overflow-y: auto; + padding: 20px; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3); + } + + #config-panel::-webkit-scrollbar { + width: 6px; + } + + #config-panel::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + } + + #config-panel::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 3px; + } + + .config-title { + color: #fff; + font-size: 18px; + font-weight: bold; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 2px solid rgba(255, 255, 255, 0.2); + } + + .config-category { + margin-bottom: 15px; + border-radius: 8px; + overflow: hidden; + background: rgba(255, 255, 255, 0.05); + } + + .category-header { + padding: 12px 15px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.3s; + user-select: none; + } + + .category-header:hover { + background: rgba(255, 255, 255, 0.15); + } + + .category-header.active { + background: rgba(76, 175, 80, 0.3); + } + + .category-title { + font-size: 14px; + font-weight: 600; + } + + .category-arrow { + transition: transform 0.3s; + font-size: 12px; + } + + .category-arrow.expanded { + transform: rotate(180deg); + } + + .category-content { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: grid-template-rows 0.3s ease, padding 0.3s ease; + padding: 0 15px; + } + + .category-content.expanded { + grid-template-rows: 1fr; + padding: 15px; + } + + .category-content>* { + min-height: 0; + } + + .option-item { + margin-bottom: 12px; + } + + .option-label { + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + margin-bottom: 8px; + display: block; + } + + .option-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .option-btn { + padding: 8px 16px; + border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.05); + color: #fff; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.3s; + } + + .option-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.5); + } + + .option-btn.selected { + background: rgba(76, 175, 80, 0.6); + border-color: #4CAF50; + } + + .option-checkbox { + display: flex; + align-items: center; + padding: 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s; + } + + .option-checkbox:hover { + background: rgba(255, 255, 255, 0.05); + } + + .option-checkbox input[type="checkbox"] { + margin-right: 8px; + cursor: pointer; + } + + .option-checkbox label { + color: rgba(255, 255, 255, 0.8); + font-size: 12px; + cursor: pointer; + } + /* 进度条样式 */ #progress-container { position: absolute; @@ -63,108 +220,210 @@
- - + diff --git a/index.js b/index.js new file mode 100644 index 0000000..0e6ef88 --- /dev/null +++ b/index.js @@ -0,0 +1,108 @@ +import { kernel } from './src/main.ts'; + +// import { kernel } from 'https://sdk.zguiy.com/zt/assets/index.js'; + +// const config = { +// container: document.querySelector('#renderDom'), +// modelUrlList: ['/assets/model.glb'], +// env: { envPath: '/assets/hdr.env', intensity: 1.2, rotationY: 0.3, background: false }, +// }; + +const config = { + container: document.querySelector('#renderDom'), + modelUrlList: ['https://sdk.zguiy.com/resurces/model/model.glb'], + env: { envPath: 'https://sdk.zguiy.com/resurces/hdr/hdr.env', intensity: 1.2, rotationY: 0.3, background: true }, +}; + +kernel.init(config); + + + +kernel.on('model:load:progress', (data) => { + console.log('模型加载事件', data); + + + + const progress = data.progress || 0; + const progressBar = document.getElementById('progress-bar'); + const progressText = document.getElementById('progress-text'); + const progressContainer = document.getElementById('progress-container'); + + if (progressContainer) { + progressContainer.style.display = 'block'; + } + if (progressBar) { + progressBar.style.width = `${progress * 100}%`; + } + if (progressText) { + progressText.textContent = `${Math.round(progress * 100)}%`; + } +}); + + + +kernel.on('model:loaded', (data) => { + console.log('模型加载完成', data); + // 隐藏进度条 + const progressContainer = document.getElementById('progress-container'); + if (progressContainer) { + progressContainer.style.display = 'none'; + } + + + +}); + +kernel.on('all:ready', (data) => { + console.log('所有模块加载完,', data); + kernel.material.apply({ + target: 'Material__2', + attribute: 'alpha', + value: 0.5, + }); + + + kernel.hotspot.render([ + { + 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" }, + }, + ]); +}); + + +kernel.on('model:click', (data) => { + console.log('模型点击事件', data); + console.log(data); + +}); + +kernel.on('hotspot:click', (data) => { + console.log('热点被点击:', data); + const { id, name } = data + if (name === "卷帘门") { + kernel.door.toggle({ upY: 28, downY: 0, speed: 12 }); + + // Y轴剖切,只作用于卷帘门网格,保留下方,剖掉上方 + const clipHeight = 28; // 调整这个值找到合适的剖切高度 + console.log('设置剖切:', clipHeight); + kernel.clipping.setY(clipHeight, true, ['Box005.001', 'Box006.001']); + } +}); + + +// 添加模型到场景 +// await kernel.model.add('https://sdk.zguiy.com/resurces/model/百叶1.glb'); + +// 销毁模型 +// kernel.model.destroy('car'); + +// 替换模型 +// await kernel.model.replace('car', '/models/new-car.glb'); + diff --git a/mock-arkit-server.js b/mock-arkit-server.js deleted file mode 100644 index 2ce0310..0000000 --- a/mock-arkit-server.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * 模拟 ARKit 数据的 WebSocket 服务器 - * 运行: node mock-arkit-server.js - */ - -const WebSocket = require('ws'); - -const PORT = 8765; -const wss = new WebSocket.Server({ port: PORT }); - -// ARKit 52 个 blendShape 完整列表 -const ARKIT_BLENDSHAPES = [ - 'eyeBlinkLeft', 'eyeLookDownLeft', 'eyeLookInLeft', 'eyeLookOutLeft', 'eyeLookUpLeft', 'eyeSquintLeft', 'eyeWideLeft', - 'eyeBlinkRight', 'eyeLookDownRight', 'eyeLookInRight', 'eyeLookOutRight', 'eyeLookUpRight', 'eyeSquintRight', 'eyeWideRight', - 'jawForward', 'jawLeft', 'jawRight', 'jawOpen', - 'mouthClose', 'mouthFunnel', 'mouthPucker', 'mouthLeft', 'mouthRight', - 'mouthSmileLeft', 'mouthSmileRight', 'mouthFrownLeft', 'mouthFrownRight', - 'mouthDimpleLeft', 'mouthDimpleRight', 'mouthStretchLeft', 'mouthStretchRight', - 'mouthRollLower', 'mouthRollUpper', 'mouthShrugLower', 'mouthShrugUpper', - 'mouthPressLeft', 'mouthPressRight', 'mouthLowerDownLeft', 'mouthLowerDownRight', - 'mouthUpperUpLeft', 'mouthUpperUpRight', - 'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight', - 'cheekPuff', 'cheekSquintLeft', 'cheekSquintRight', - 'noseSneerLeft', 'noseSneerRight', 'tongueOut' -]; - -// 表情预设 -const expressions = [ - { name: '微笑', data: { mouthSmileLeft: 0.8, mouthSmileRight: 0.8, cheekSquintLeft: 0.3, cheekSquintRight: 0.3 } }, - { name: '张嘴说话', data: { jawOpen: 0.5, mouthFunnel: 0.3 } }, - { name: '惊讶', data: { eyeWideLeft: 0.9, eyeWideRight: 0.9, jawOpen: 0.6, browInnerUp: 0.7 } }, - { name: '皱眉', data: { browDownLeft: 0.8, browDownRight: 0.8, eyeSquintLeft: 0.3, eyeSquintRight: 0.3 } }, - { name: '嘟嘴', data: { mouthPucker: 0.9, mouthFunnel: 0.4 } }, - { name: '吐舌', data: { tongueOut: 0.7, jawOpen: 0.4 } }, - { name: '生气', data: { noseSneerLeft: 0.7, noseSneerRight: 0.7, browDownLeft: 0.6, browDownRight: 0.6, jawOpen: 0.2 } }, - { name: '悲伤', data: { mouthFrownLeft: 0.7, mouthFrownRight: 0.7, browInnerUp: 0.5, eyeSquintLeft: 0.2, eyeSquintRight: 0.2 } }, - { name: '中性', data: {} }, -]; - -let currentExprIndex = 0; -let transitionProgress = 0; -let blinkTimer = 0; -let isBlinking = false; - -function lerp(a, b, t) { - return a + (b - a) * t; -} - -function generateBlendShapes() { - const current = expressions[currentExprIndex].data; - const next = expressions[(currentExprIndex + 1) % expressions.length].data; - - // 初始化所有 52 个 blendShape 为 0 - const blendShapes = {}; - ARKIT_BLENDSHAPES.forEach(name => blendShapes[name] = 0); - - // 插值当前和下一个表情 - for (const key of ARKIT_BLENDSHAPES) { - const currentVal = current[key] || 0; - const nextVal = next[key] || 0; - blendShapes[key] = lerp(currentVal, nextVal, transitionProgress); - } - - // 自然眨眼(每3-5秒眨一次) - blinkTimer++; - if (!isBlinking && blinkTimer > 90 + Math.random() * 60) { - isBlinking = true; - blinkTimer = 0; - } - if (isBlinking) { - const blinkProgress = blinkTimer / 6; - if (blinkProgress < 1) { - blendShapes.eyeBlinkLeft = Math.sin(blinkProgress * Math.PI); - blendShapes.eyeBlinkRight = Math.sin(blinkProgress * Math.PI); - } else { - isBlinking = false; - blinkTimer = 0; - } - } - - // 添加微小的随机抖动(更自然) - blendShapes.jawOpen += (Math.random() - 0.5) * 0.02; - blendShapes.browInnerUp += (Math.random() - 0.5) * 0.01; - - // 表情过渡 - transitionProgress += 0.015; - if (transitionProgress >= 1) { - transitionProgress = 0; - currentExprIndex = (currentExprIndex + 1) % expressions.length; - console.log(`切换到表情: ${expressions[currentExprIndex].name}`); - } - - return blendShapes; -} - -wss.on('connection', (ws) => { - console.log('客户端已连接'); - - const interval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - const data = generateBlendShapes(); - ws.send(JSON.stringify(data)); - } - }, 33); // ~30fps - - ws.on('close', () => { - console.log('客户端断开'); - clearInterval(interval); - }); -}); - -console.log(`ARKit 模拟服务器运行在 ws://localhost:${PORT}`); -console.log('表情循环: ' + expressions.map(e => e.name).join(' -> ')); diff --git a/src/babylonjs/AppHotspot.ts b/src/babylonjs/AppHotspot.ts index f96d3de..b798108 100644 --- a/src/babylonjs/AppHotspot.ts +++ b/src/babylonjs/AppHotspot.ts @@ -148,9 +148,9 @@ export class AppHotspot extends Monobehiver { } // 释放sprite资源 - if (point.sprite) { - point.sprite.dispose(); - } + // if (point.sprite) { + // point.sprite.dispose(); + // } } // 清空热点池 diff --git a/src/babylonjs/AppModel.ts b/src/babylonjs/AppModel.ts index ae86f3f..c6037a0 100644 --- a/src/babylonjs/AppModel.ts +++ b/src/babylonjs/AppModel.ts @@ -149,6 +149,54 @@ export class AppModel extends Monobehiver { this.skeletonMerged = false; } + /** + * 添加模型到场景 + * @param modelUrl 模型URL路径 + */ + async addModel(modelUrl: string): Promise { + const handleProgress = (event: ISceneLoaderProgressEvent): void => { + const progress = event.lengthComputable && event.total > 0 + ? Math.min(1, event.loaded / event.total) + : 0; + EventBridge.modelLoadProgress({ + loaded: progress, + total: 1, + url: modelUrl, + progress, + percentage: Number((progress * 100).toFixed(2)), + detail: { + url: modelUrl, + lengthComputable: event.lengthComputable, + loadedBytes: event.loaded, + totalBytes: event.total + } + }); + }; + + const result = await this.loadSingleModel(modelUrl, handleProgress); + + if (result.success) { + EventBridge.modelLoaded({ urls: [modelUrl] }); + } else { + EventBridge.modelLoadError({ url: modelUrl, error: result.error }); + } + + return result; + } + + /** + * 替换模型:销毁旧模型并加载新模型 + * @param modelName 要替换的模型名称 + * @param newModelUrl 新模型的URL路径 + */ + async replaceModel(modelName: string, newModelUrl: string): Promise { + // 先销毁旧模型 + this.destroyModel(modelName); + + // 加载新模型 + return await this.addModel(newModelUrl); + } + /** * 销毁指定模型 * @param modelName 模型名称 diff --git a/src/event/bridge.ts b/src/event/bridge.ts index 16072f6..fd91f69 100644 --- a/src/event/bridge.ts +++ b/src/event/bridge.ts @@ -41,7 +41,7 @@ export class EventBridge { // Listeners static onModelLoadProgress(callback: (payload: ModelLoadProgressPayload) => void, context?: unknown): Emitter { - return on("model:load:progress", callback, context); + return on("model:load:progress", callback, context); } static onModelLoadError(callback: (payload: ModelLoadErrorPayload) => void, context?: unknown): Emitter { @@ -59,14 +59,14 @@ export class EventBridge { static onSceneReady(callback: (payload: SceneReadyPayload) => void, context?: unknown): Emitter { return on("scene:ready", callback, context); } - static onAllReady(callback: (payload: SceneReadyPayload) => void, context?: unknown): Emitter { + static onAllReady(callback: (payload: SceneReadyPayload) => void, context?: unknown): Emitter { return on("all:ready", callback, context); } static onHotspotClick(callback: (payload: HotspotClickPayload) => void, context?: unknown): Emitter { return on("hotspot:click", callback, context); } static onceSceneReady(callback: (payload: SceneReadyPayload) => void, context?: unknown): Emitter { - return once("scene:ready", callback, context); + return once("scene:ready", callback, context); } static off(eventName?: string, callback?: (...args: unknown[]) => void): Emitter { diff --git a/src/hotspot/HotSpot.ts b/src/hotspot/HotSpot.ts index 6b5eb0d..824375f 100644 --- a/src/hotspot/HotSpot.ts +++ b/src/hotspot/HotSpot.ts @@ -48,12 +48,12 @@ class HotSpot { Point_Event(prams: HotspotPrams) { const iconPath = prams.icon + // 为每个热点创建独立的材质 const texture = new Texture(iconPath, this.mainApp.appScene.object) texture.hasAlpha = true texture.getAlphaFromRGB = false - const material = new StandardMaterial(`hotspotMaterial_${Math.random()}`, this.mainApp.appScene.object) material.diffuseTexture = texture material.emissiveTexture = texture @@ -62,17 +62,12 @@ class HotSpot { material.transparencyMode = 2 // ALPHABLEND 模式 material.disableLighting = true material.backFaceCulling = false - + // 检查纹理是否已加载 - if (texture.isReady()) { - // 纹理已准备好,立即创建热点 - this.createPointPlane(prams, material) - } else { - // 纹理未准备好,等待加载完成 - texture.onLoadObservable.addOnce(() => { + this.createPointPlane(prams, material) - }) - } + + } // 创建点平面的具体实现 diff --git a/src/kernel/Adapter.ts b/src/kernel/Adapter.ts index 39ed7f3..aa71224 100644 --- a/src/kernel/Adapter.ts +++ b/src/kernel/Adapter.ts @@ -14,10 +14,26 @@ export class KernelAdapter { /** 模型管理 */ model = { /** - * 销毁指定模�? * @param modelName 模型名称 + * 添加模型到场景 + * @param modelUrl 模型URL路径 + */ + add: async (modelUrl: string): Promise => { + await this.mainApp.appModel.addModel(modelUrl); + }, + /** + * 销毁指定模型 + * @param modelName 模型名称 */ destroy: (modelName: string): void => { this.mainApp.appModel.destroyModel(modelName); + }, + /** + * 替换模型 + * @param modelName 要替换的模型名称 + * @param newModelUrl 新模型的URL路径 + */ + replace: async (modelName: string, newModelUrl: string): Promise => { + await this.mainApp.appModel.replaceModel(modelName, newModelUrl); } }; diff --git a/test.zip b/test.zip new file mode 100644 index 0000000..d71dce8 Binary files /dev/null and b/test.zip differ diff --git a/test/customization-3d-copy.js b/test/customization-3d-copy.js new file mode 100644 index 0000000..fd1ef78 --- /dev/null +++ b/test/customization-3d-copy.js @@ -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(); + } +})(); diff --git a/test/customization-3d-viewer.js b/test/customization-3d-viewer.js new file mode 100644 index 0000000..c395c08 --- /dev/null +++ b/test/customization-3d-viewer.js @@ -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); + } +})(); diff --git a/test/demo.html b/test/demo.html new file mode 100644 index 0000000..9d4af5c --- /dev/null +++ b/test/demo.html @@ -0,0 +1,224 @@ + + + + + + + 3D Viewer Demo + + + + +
+

3D Viewer Demo

+ +
+
+ +
+ + +
+ +
+
+
+
+ +
+
+
+
0%
+
+
+ +
+ 暂无模型,请加载一个 3D 模型 +
+
+
+ + + + + + + + + + + + + + + diff --git a/设计 b/设计 new file mode 100644 index 0000000..f8fb782 --- /dev/null +++ b/设计 @@ -0,0 +1,3 @@ +1.右侧添加选装选配的ui,分类折叠,棚子尺寸棚子/类型/百叶/配色 ,每个折叠下面都有四个属性 ,百叶是多选,其他的都是单选 +2.页面布局也改下,分画布和UI两部分 +3.UI的事件预留好 \ No newline at end of file