/** * AGV 播放页:只读加载配置 + 组合开关动作,无编辑逻辑。 */ import * as THREE from "three"; import { PropertyBinding } from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js"; import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; import { createPlaybackApi } from "./api.js"; import { connectRemotePlayback } from "./remote-bridge.js"; const AGV_BASE = "../agv/"; const $ = (id) => document.getElementById(id); const clone = (o) => JSON.parse(JSON.stringify(o)); const DEFAULT_PARAMETERS = { defaultWheelSpeed: 0.6, wheelSpinMultiplier: 3, liftOffsets: [0, 0.05, 0.1, 0.15], cameraScanAmplitudeDeg: 15, cameraScanHz: 0.8, liftTweenDuration: 1.2, wheelSteerTweenDuration: 0.8, liftMotionFactorFull: 1000, motionPreviewHz: 0.4, motionPreviewAmplitudeDeg: 18, }; function mergeParameters(...layers) { const out = clone(DEFAULT_PARAMETERS); for (const layer of layers) { if (!layer) continue; Object.assign(out, layer); if (layer.liftOffsets) out.liftOffsets = [...layer.liftOffsets]; } return out; } function resolveConfigParams(cfg) { return mergeParameters(cfg?.parameters); } // 复用向量,避免每帧分配 const _liftAxis = new THREE.Vector3(); const _liftWorld = new THREE.Vector3(); const _liftParentInv = new THREE.Matrix4(); // --- 场景 --- const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.sortObjects = true; renderer.setPixelRatio(devicePixelRatio); renderer.setSize(innerWidth, innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; $("canvas-wrap").appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0f1117); scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture; scene.add(new THREE.AmbientLight(0xffffff, 0.45)); const key = new THREE.DirectionalLight(0xffffff, 1.2); key.position.set(3, 6, 2); scene.add(key); scene.add(new THREE.GridHelper(5, 20, 0x2a3142, 0x1a1f2b)); const camera = new THREE.PerspectiveCamera(48, innerWidth / innerHeight, 0.02, 80); camera.position.set(1.6, 1.1, 1.6); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.18, 0); controls.enableDamping = true; const draco = new DRACOLoader(); draco.setDecoderPath("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/libs/draco/gltf/"); const loader = new GLTFLoader(); loader.setDRACOLoader(draco); // --- 状态 --- let config = null; let runtime = null; const parts = {}; let partBases = {}; let baseTransforms = null; let sceneRoot = null; const active = new Set(); let liftDist = {}; let liftTween = null; let manifest = { models: [] }; let currentModelId = ""; // --- 基准 + 动作覆盖 --- function getBaseline(cfg) { return cfg?.baseline || cfg?.components || {}; } function isWheelComp(comp) { return comp?.type === "wheel" || comp?.motion?.role === "wheel"; } function mergeComp(base, patch) { const out = { ...base, ...patch }; if (patch.light) out.light = { ...base.light, ...patch.light }; if (patch.action) out.action = { ...base.action, ...patch.action }; if (patch.motion !== undefined && !isWheelComp(base)) { out.motion = patch.motion ? { ...base.motion, ...patch.motion } : null; } if (isWheelComp(base)) out.motion = base.motion ? { ...base.motion } : out.motion; out.glbNode = base.glbNode; out.hasMesh = base.hasMesh; return out; } function actionComponents(action) { return action?.components || action?.overrides || {}; } function migrateRootParametersToActions(cfg) { const root = cfg?.parameters; if (!root || !cfg.actions || !Object.keys(cfg.actions).length) return; for (const action of Object.values(cfg.actions)) { if (!action.parameters) action.parameters = clone(root); } delete cfg.parameters; } function normalizeConfig(cfg) { if (!cfg.baseline) cfg.baseline = clone(cfg.components || {}); for (const action of Object.values(cfg.actions || {})) { if (action.overrides && !action.components) { action.components = action.overrides; delete action.overrides; } } migrateRootParametersToActions(cfg); delete cfg.components; } function playbackConfig() { if (!config) return null; const components = clone(getBaseline(config)); const paramLayers = []; for (const id of active) { const action = config.actions?.[id]; if (!action) continue; paramLayers.push(action.parameters); const ac = actionComponents(action); for (const [name, patch] of Object.entries(ac)) { if (components[name]) components[name] = mergeComp(components[name], patch); } } return { ...config, components, parameters: mergeParameters(...paramLayers) }; } function baselineConfig() { return { ...config, components: clone(getBaseline(config)), parameters: clone(DEFAULT_PARAMETERS) }; } function isLiftAction(action) { const ac = actionComponents(action); return Object.values(ac).some((p) => p.motion?.role === "lift" || (p.action && ("liftEnd" in p.action || "liftStart" in p.action || "motionFactor" in p.action)), ); } function hasLiftActive() { for (const id of active) if (isLiftAction(config.actions[id])) return true; return false; } function namesInAction(actionId) { return Object.keys(actionComponents(config.actions?.[actionId] || {})); } /** 是否仍有其它已开动作覆盖该组件 */ function isComponentCoveredByActive(name, exceptId = null) { for (const id of active) { if (id === exceptId) continue; if (actionComponents(config.actions[id])[name]) return true; } return false; } function isLiftPart(name) { if (getBaseline(config)[name]?.motion?.role === "lift") return true; for (const action of Object.values(config.actions || {})) { const p = actionComponents(action)[name]; if (p?.motion?.role === "lift" || p?.action?.liftEnd != null || p?.action?.liftStart != null) return true; } return false; } function allLiftPartNames() { const names = new Set(); for (const [name, comp] of Object.entries(getBaseline(config))) { if (comp.motion?.role === "lift") names.add(name); } for (const action of Object.values(config.actions || {})) { for (const [name, p] of Object.entries(actionComponents(action))) { if (p?.motion?.role === "lift" || p?.action?.liftEnd != null || p?.action?.liftStart != null) names.add(name); } } Object.keys(liftDist).forEach((n) => names.add(n)); return names; } function liftSpecForPart(name, cfg) { const merged = cfg?.components?.[name]; if (merged?.motion?.role === "lift") return merged; const base = getBaseline(config)[name]; for (const action of Object.values(config.actions || {})) { const p = actionComponents(action)[name]; if (p?.motion?.role === "lift" || p?.action?.liftEnd != null) { return base ? mergeComp(base, p) : p; } } return merged || base; } function baselineLiftDist(name) { const comp = getBaseline(config)[name]; if (!comp || comp.motion?.role !== "lift") return 0; return liftEnd(comp, baselineConfig()); } /** 记录当前画面上的举升高度(开动作时 liftDist 可能未同步) */ function captureCurrentLiftDist() { if (liftTween) return { ...liftDist }; const cfg = playbackConfig(); const cur = { ...liftDist }; const targets = liftTargets(cfg); for (const name of allLiftPartNames()) { if (targets[name] != null) cur[name] = targets[name]; else if (cur[name] == null) cur[name] = baselineLiftDist(name); } return cur; } function resetPartMaterialsToNeutral(name) { const baseComp = getBaseline(config)[name]; const node = parts[name]; if (!node || baseComp?.type === "light" || baseComp?.motion?.role === "light") return; node.traverse((o) => { if (!o.isMesh) return; let m = o.material; if (Array.isArray(m)) m = m[0]; if (!m) return; m.emissive.setHex(0); m.emissiveIntensity = 0; }); } /** 将组件恢复为基准(举升件位置由 tween 控制,不瞬间落位) */ function restoreComponentToBaseline(name) { const node = parts[name]; const pb = partBases[name]; const baseComp = getBaseline(config)[name]; if (!node || !pb || !baseComp) return; if (!isLiftPart(name)) { node.position.copy(pb.position); } node.rotation.copy(pb.rotation); if (baseComp.type === "wheel" || baseComp.motion?.role === "wheel") { clearWheelSpinState(name); const spinAxis = resolveWheelSpinAxis(baseComp, partBases[name], node, baseComp.motion?.axis); syncWheelSteerState(name, node, baseComp?.action?.wheelSteerAxis || defaultSteerAxis(spinAxis)); } restoreNodeOpacity(node); resetPartMaterialsToNeutral(name); } function restoreClosedActionComponents(actionId) { for (const name of namesInAction(actionId)) { if (!isComponentCoveredByActive(name)) restoreComponentToBaseline(name); } } // --- 运行时索引 --- function buildRuntime(cfg) { const lightMats = {}; const compByRole = { light: [], wheel: [], lift: [], lidar: [], camera: [], emergency: [] }; for (const [name, comp] of Object.entries(cfg.components || {})) { const node = parts[name]; if (!node) continue; const role = comp.motion?.role; if (comp.type === "light" || role === "light") { const mats = []; node.traverse((o) => { if (!o.isMesh) return; let m = o.material; if (Array.isArray(m)) m = m[0]; if (!m?.isMeshStandardMaterial) { o.material = new THREE.MeshStandardMaterial({ color: 0, emissive: 0, metalness: 0, roughness: 1, toneMapped: false }); m = o.material; } else { m = m.clone(); m.color.setHex(0); m.emissive.setHex(0); o.material = m; } mats.push(m); }); if (mats.length) lightMats[name] = mats; compByRole.light.push(name); } else if (comp.type === "wheel" || role === "wheel") { compByRole.wheel.push({ name, axis: comp.motion?.axis || "x" }); } else if (role === "lift") compByRole.lift.push(name); else if (role === "lidar") compByRole.lidar.push(name); else if (role === "camera") compByRole.camera.push(name); else if (role === "emergency") compByRole.emergency.push(name); } return { lightMats, compByRole, groups: cfg.groups || {} }; } function captureBases(cfg) { if (sceneRoot) sceneRoot.updateWorldMatrix(true, true); const bases = {}; for (const name of Object.keys(cfg.components || {})) { const node = parts[name]; if (!node) continue; const entry = { position: node.position.clone(), quaternion: node.quaternion.clone(), rotation: node.rotation.clone(), worldPosition: node.getWorldPosition(new THREE.Vector3()), }; Object.assign(entry, computeWheelPartMeta(node)); bases[name] = entry; } return bases; } function resetParts(cfg) { if (!baseTransforms) return; for (const [key, comp] of Object.entries(cfg.components || {})) { const glb = comp.glbNode || key; const node = parts[key]; const base = getBaseTransform(baseTransforms, glb); if (node && base) { node.position.copy(base.position); node.quaternion.copy(base.quaternion); node.scale.copy(base.scale); resetWheelSpinGroup(key); } } partBases = captureBases(cfg); } function normalizeGlbNodeName(name) { return String(name ?? "").replace(/\u00a0/g, " ").trim(); } function sanitizeGlbNodeName(name) { return PropertyBinding.sanitizeNodeName(String(name ?? "")); } function glbNameLookupKeys(glbName) { const name = String(glbName ?? ""); const norm = normalizeGlbNodeName(name); const keys = new Set(); for (const v of [name, norm, name.trim()]) { if (!v) continue; keys.add(v); keys.add(sanitizeGlbNodeName(v)); keys.add(sanitizeGlbNodeName(` ${v}`)); if (!v.startsWith("_")) keys.add(`_${sanitizeGlbNodeName(v)}`); } return keys; } function indexGlbNodeKeyPlain(partsByName, key, obj) { if (!key || !obj) return; for (const k of glbNameLookupKeys(key)) { if (!partsByName[k]) partsByName[k] = obj; } } function indexObject3D(obj, partsByName) { if (!obj) return; if (obj.name) indexGlbNodeKeyPlain(partsByName, obj.name, obj); if (obj.userData?.name) indexGlbNodeKeyPlain(partsByName, obj.userData.name, obj); } function buildPartsFromGltfParser(parser) { const partsByName = {}; if (!parser?.json?.nodes || !parser.associations) return partsByName; for (const [obj, assoc] of parser.associations) { if (assoc.nodes === undefined) continue; const nodeDef = parser.json.nodes[assoc.nodes]; if (!nodeDef?.name) continue; indexObject3D(obj, partsByName); indexGlbNodeKeyPlain(partsByName, nodeDef.name, obj); } return partsByName; } function rememberBaseTransform(map, obj) { const entry = { position: obj.position.clone(), quaternion: obj.quaternion.clone(), scale: obj.scale.clone(), }; if (obj.name) { for (const k of glbNameLookupKeys(obj.name)) map.set(k, entry); } if (obj.userData?.name) { for (const k of glbNameLookupKeys(obj.userData.name)) map.set(k, entry); } } function getBaseTransform(baseTransforms, glbName) { if (!baseTransforms || glbName == null) return null; for (const k of glbNameLookupKeys(glbName)) { const hit = baseTransforms.get(k); if (hit) return hit; } return null; } function loadGlbRoot(gltf, rootName) { const sceneRoot = gltf.scene; let modelRoot = rootName ? sceneRoot.getObjectByName(rootName) : null; if (!modelRoot) modelRoot = sceneRoot.children[0] || sceneRoot; const baseTransforms = new Map(); const partsByName = buildPartsFromGltfParser(gltf.parser); sceneRoot.traverse((c) => { indexObject3D(c, partsByName); rememberBaseTransform(baseTransforms, c); }); return { sceneRoot, modelRoot, baseTransforms, partsByName }; } function lookupGlbNodeInRoot(root, partsByName, glbName) { if (!glbName) return null; for (const k of glbNameLookupKeys(glbName)) { if (partsByName?.[k]) return partsByName[k]; const hit = root?.getObjectByName?.(k); if (hit) return hit; } let found = null; const want = new Set(glbNameLookupKeys(glbName)); root?.traverse?.((child) => { if (found) return; if (child.name && want.has(child.name)) { found = child; return; } const ud = child.userData?.name; if (ud) { for (const k of glbNameLookupKeys(ud)) { if (want.has(k)) { found = child; return; } } } }); return found; } function mapParts(cfg, modelRoot, partsByName = null) { for (const k of Object.keys(parts)) delete parts[k]; for (const [key, comp] of Object.entries(cfg.components || {})) { const obj = lookupGlbNodeInRoot(modelRoot, partsByName, comp.glbNode || key); if (obj) parts[key] = obj; } } const _wheelSpinAngles = {}; const _wheelSteerAngles = {}; const _materialBaseOpacity = new Map(); const _meshVisualState = new Map(); const DEFAULT_TRANSPARENT_OPACITY = 0.25; function rememberMeshVisual(mesh) { if (!mesh?.isMesh || _meshVisualState.has(mesh.uuid)) return; _meshVisualState.set(mesh.uuid, { visible: mesh.visible }); } function ensureMeshOwnMaterial(mesh) { if (!mesh?.isMesh || mesh.userData._opacityOwnMaterial) return; if (Array.isArray(mesh.material)) { mesh.material = mesh.material.map((m) => m?.clone?.() ?? m); } else if (mesh.material?.clone) { mesh.material = mesh.material.clone(); } mesh.userData._opacityOwnMaterial = true; } function componentOpacity(comp) { const v = comp?.action?.opacity; return v == null ? 1 : Math.min(1, Math.max(0, Number(v))); } function rememberMaterialOpacity(mat) { if (!mat || _materialBaseOpacity.has(mat.uuid)) return; _materialBaseOpacity.set(mat.uuid, { transparent: mat.transparent, opacity: mat.opacity, depthWrite: mat.depthWrite }); } function restoreMaterialOpacity(mat) { if (!mat) return; const saved = _materialBaseOpacity.get(mat.uuid); if (!saved) return; mat.transparent = saved.transparent; mat.opacity = saved.opacity; mat.depthWrite = saved.depthWrite; mat.needsUpdate = true; _materialBaseOpacity.delete(mat.uuid); } function restoreNodeOpacity(node) { if (!node) return; node.traverse((o) => { if (!o.isMesh) return; const saved = _meshVisualState.get(o.uuid); if (saved) o.visible = saved.visible; const mats = Array.isArray(o.material) ? o.material : [o.material]; mats.forEach(restoreMaterialOpacity); }); } function setNodeOpacity(node, opacity) { if (!node) return; const hide = opacity <= 0.01; node.traverse((o) => { if (!o.isMesh) return; rememberMeshVisual(o); if (hide) { o.visible = false; return; } o.visible = _meshVisualState.get(o.uuid)?.visible ?? true; ensureMeshOwnMaterial(o); const mats = Array.isArray(o.material) ? o.material : [o.material]; for (const mat of mats) { if (!mat) continue; rememberMaterialOpacity(mat); if (opacity >= 1) { restoreMaterialOpacity(mat); continue; } mat.transparent = true; mat.opacity = opacity; mat.depthWrite = false; mat.needsUpdate = true; } }); } function applyComponentOpacity(cfg) { if (!cfg?.components) return; for (const [name, comp] of Object.entries(cfg.components)) { if (comp?.action?.opacity == null) continue; const node = parts[name]; if (node) setNodeOpacity(node, componentOpacity(comp)); } } function clearWheelSpinState(name) { delete _wheelSpinAngles[name]; } function readSteerAngleRad(part, steerAxis) { const steerGroup = part?.userData?._wheelSteerGroup; if (!steerGroup) return 0; if (steerAxis === "x") return steerGroup.rotation.x; if (steerAxis === "z") return steerGroup.rotation.z; return steerGroup.rotation.y; } function syncWheelSteerState(name, part, steerAxis) { _wheelSteerAngles[name] = readSteerAngleRad(part, steerAxis); } function stepWheelSteerAngle(name, part, steerAxis, targetRad, dt, duration) { const dur = Math.max(duration, 0.05); let cur = _wheelSteerAngles[name]; if (cur == null) cur = readSteerAngleRad(part, steerAxis); const t = 1 - Math.exp(-6 * dt / dur); cur += (targetRad - cur) * t; if (Math.abs(targetRad - cur) < 1e-5) cur = targetRad; _wheelSteerAngles[name] = cur; return cur; } function defaultSteerAxis(spinAxis) { return spinAxis === "y" ? "z" : "y"; } function wheelSteerAngleRad(comp) { const deg = comp?.action?.wheelSteerAngleDeg; return deg != null ? THREE.MathUtils.degToRad(Number(deg)) : 0; } const _wheelBaseQ = new THREE.Quaternion(); const _wheelSteerQ = new THREE.Quaternion(); const _wheelSpinQ = new THREE.Quaternion(); const _wheelAfterSteerQ = new THREE.Quaternion(); const _wheelAxis = new THREE.Vector3(); const _wheelPivot = new THREE.Vector3(); const _wheelCenter = new THREE.Vector3(); const _wheelAxisParent = new THREE.Vector3(); const _wheelDecompScale = new THREE.Vector3(); const _wheelMBase = new THREE.Matrix4(); const _wheelMToC = new THREE.Matrix4(); const _wheelMFromC = new THREE.Matrix4(); const _wheelMSpin = new THREE.Matrix4(); const _wheelMFinal = new THREE.Matrix4(); const _wheelLocalBox = new THREE.Box3(); const _wheelLocalM = new THREE.Matrix4(); function wheelAxisVector(axis) { if (axis === "x") return _wheelAxis.set(1, 0, 0); if (axis === "z") return _wheelAxis.set(0, 0, 1); return _wheelAxis.set(0, 1, 0); } function computeWheelPartMeta(node) { node.updateWorldMatrix(true, true); _wheelLocalM.copy(node.matrixWorld).invert(); _wheelLocalBox.makeEmpty(); node.traverse((o) => { if (!o.isMesh || !o.geometry) return; o.geometry.computeBoundingBox(); if (!o.geometry.boundingBox) return; const bb = o.geometry.boundingBox.clone(); bb.applyMatrix4(_wheelLocalM.clone().multiply(o.matrixWorld)); _wheelLocalBox.union(bb); }); if (_wheelLocalBox.isEmpty()) { return { spinPivotLocal: new THREE.Vector3(), inferredSpinAxis: "x" }; } const spinPivotLocal = _wheelLocalBox.getCenter(new THREE.Vector3()); const sx = _wheelLocalBox.max.x - _wheelLocalBox.min.x; const sy = _wheelLocalBox.max.y - _wheelLocalBox.min.y; const sz = _wheelLocalBox.max.z - _wheelLocalBox.min.z; let inferredSpinAxis = "z"; let min = sz; if (sx < min) { min = sx; inferredSpinAxis = "x"; } if (sy < min) inferredSpinAxis = "y"; return { spinPivotLocal: spinPivotLocal.clone(), inferredSpinAxis }; } function resolveWheelSpinAxis(comp, base, wrapper, fallback = "x") { return comp?.motion?.axis || wrapper?.userData?._wheelSpinAxis || base?.inferredSpinAxis || fallback || "x"; } function installWheelPivot(node, configKey) { if (node.userData._wheelPivotReady) return node.userData._wheelWrapper || node; const meta = computeWheelPartMeta(node); const c = meta.spinPivotLocal; const spinAxis = meta.inferredSpinAxis || "z"; const mark = (wrapper, spinGroup, steerGroup = null) => { wrapper.userData._wheelPivotReady = true; wrapper.userData._wheelWrapper = wrapper; wrapper.userData._wheelSpinGroup = spinGroup; wrapper.userData._wheelSteerGroup = steerGroup; wrapper.userData._wheelSpinAxis = spinAxis; wrapper.userData._wheelPivotOffset = c.clone(); node.userData._wheelPivotReady = true; node.userData._wheelWrapper = wrapper; node.userData._wheelSpinGroup = spinGroup; node.userData._wheelSteerGroup = steerGroup; }; if (c.lengthSq() < 1e-8) { mark(node, node); return node; } const parent = node.parent; if (!parent) { mark(node, node); return node; } const origPos = node.position.clone(); const origQuat = node.quaternion.clone(); const origScale = node.scale.clone(); const wrapper = new THREE.Group(); wrapper.name = `${node.name || configKey}__wheel`; const steerGroup = new THREE.Group(); steerGroup.name = `${node.name || configKey}__steer`; steerGroup.position.copy(c); const spinGroup = new THREE.Group(); spinGroup.name = `${node.name || configKey}__spin`; parent.remove(node); wrapper.position.copy(origPos); wrapper.quaternion.copy(origQuat); wrapper.scale.copy(origScale); parent.add(wrapper); wrapper.add(steerGroup); steerGroup.add(spinGroup); node.position.copy(c).negate(); node.quaternion.identity(); node.scale.set(1, 1, 1); spinGroup.add(node); mark(wrapper, spinGroup, steerGroup); return wrapper; } function installAllWheelPivots(config) { for (const [key, comp] of Object.entries(config.components || {})) { if (comp.type !== "wheel" && comp.motion?.role !== "wheel") continue; const node = parts[key]; if (!node) continue; parts[key] = installWheelPivot(node, key); } } function resetWheelSpinGroup(configKey) { const spinGroup = parts[configKey]?.userData?._wheelSpinGroup; if (spinGroup) spinGroup.rotation.set(0, 0, 0); } function setLocalAxisRotation(obj, axis, rad) { obj.rotation.set(0, 0, 0); if (!rad) return; if (axis === "x") obj.rotation.x = rad; else if (axis === "z") obj.rotation.z = rad; else obj.rotation.y = rad; } function applyWheelRotation(wrapper, base, spinAxis, steerAxis, steerRad, spinRad) { const spinGroup = wrapper.userData?._wheelSpinGroup || wrapper; const steerGroup = wrapper.userData?._wheelSteerGroup; const axis = spinAxis || wrapper.userData?._wheelSpinAxis || "z"; wrapper.position.copy(base.position); if (base?.quaternion) wrapper.quaternion.copy(base.quaternion); else wrapper.quaternion.setFromEuler(base.rotation); if (steerGroup) { setLocalAxisRotation(steerGroup, steerAxis, steerRad); } else if (steerRad) { if (base?.quaternion) _wheelAfterSteerQ.copy(base.quaternion); else _wheelAfterSteerQ.setFromEuler(base.rotation); _wheelSteerQ.setFromAxisAngle(wheelAxisVector(steerAxis), steerRad); _wheelAfterSteerQ.multiply(_wheelSteerQ); wrapper.quaternion.copy(_wheelAfterSteerQ); } setLocalAxisRotation(spinGroup, axis, spinRad); } // --- 运动应用 --- const colors = new Map(); function color(hex) { if (!hex?.startsWith("#")) hex = "#00FF55"; if (!colors.has(hex)) colors.set(hex, new THREE.Color(hex)); return colors.get(hex); } function liftEnd(comp, cfg) { const a = comp?.action || {}; let end = Number(a.liftEnd ?? 0.15); if (a.liftStart == null && a.liftEnd == null && a.motionFactor != null) { const params = resolveConfigParams(cfg); const stroke = Math.max(...params.liftOffsets, 0.05); end = stroke * Math.min(1, Number(a.motionFactor) / params.liftMotionFactorFull); } return Number.isNaN(end) ? 0 : end; } function liftTargets(cfg) { const map = {}; for (const name of allLiftPartNames()) { const comp = cfg.components[name]; if (comp?.motion?.role === "lift") map[name] = liftEnd(comp, cfg); } return map; } function partsWithRole(cfg, role) { const names = []; for (const [name, comp] of Object.entries(cfg?.components || {})) { const r = comp.motion?.role; if (r === role) names.push(name); else if (role === "light" && comp.type === "light") names.push(name); else if (role === "wheel" && comp.type === "wheel") names.push(name); } return names; } function liftApplyNames() { const names = new Set(allLiftPartNames()); if (liftTween) { Object.keys(liftTween.from).forEach((n) => names.add(n)); Object.keys(liftTween.to).forEach((n) => names.add(n)); } return names; } function applyLift(cfg, distMap) { for (const name of liftApplyNames()) { const node = parts[name]; const base = partBases[name]; const spec = liftSpecForPart(name, cfg); if (!node || !base?.worldPosition || !spec) continue; const d = distMap[name] ?? 0; const axis = spec.motion?.axis || "y"; // 配置值为世界空间米;本地坐标需按节点朝向与父级缩放换算 node.rotation.copy(base.rotation); node.position.copy(base.position); node.updateWorldMatrix(true, false); if (axis === "x") _liftAxis.set(1, 0, 0); else if (axis === "z") _liftAxis.set(0, 0, 1); else _liftAxis.set(0, 1, 0); _liftAxis.transformDirection(node.matrixWorld).normalize(); _liftWorld.copy(base.worldPosition).addScaledVector(_liftAxis, d); if (node.parent) { node.parent.updateWorldMatrix(true, false); _liftParentInv.copy(node.parent.matrixWorld).invert(); node.position.copy(_liftWorld.applyMatrix4(_liftParentInv)); } else { node.position.copy(_liftWorld); } } } function liftTweenDuration() { return resolveConfigParams(playbackConfig()).liftTweenDuration; } function tweenLift(from, to) { liftDist = { ...from }; liftTween = { from: { ...from }, to: { ...to }, t: 0, dur: liftTweenDuration() }; for (const name of Object.keys(to)) { if (liftDist[name] == null) liftDist[name] = from[name] ?? 0; if (liftTween.from[name] == null) liftTween.from[name] = liftDist[name]; if (liftTween.to[name] == null) liftTween.to[name] = liftDist[name]; } } function stepLift(dt) { if (!liftTween) return; liftTween.t += dt; const u = Math.min(1, liftTween.t / liftTween.dur); const e = u * u * (3 - 2 * u); const names = new Set([...Object.keys(liftTween.from), ...Object.keys(liftTween.to)]); for (const name of names) { const a = liftTween.from[name] ?? 0; const b = liftTween.to[name] ?? 0; liftDist[name] = a + (b - a) * e; } if (u >= 1) { liftDist = { ...liftTween.to }; liftTween = null; } } function lightStrength(spec, time, idx) { const toStrength = (v) => Math.min(2, Math.max(0, Number(v ?? 100)) / 100); const eff = spec.effect || "solid"; if (eff === "blink" || eff === "strobe") { const hz = spec.hz ?? 4; const on = toStrength(spec.intensityOn ?? spec.intensity ?? 100); const off = toStrength(spec.intensityOff ?? 0); return Math.floor(time * hz) % 2 === 0 ? on : off; } if (eff === "breath") { const p = spec.period ?? 2; const min = toStrength(spec.intensityMin ?? 20); const max = toStrength(spec.intensityMax ?? spec.intensity ?? 100); const w = 0.5 + 0.5 * Math.sin((time / p) * Math.PI * 2); return min + (max - min) * w; } if (eff.includes("flow") || eff === "blink_segment") { const ph = ((time * (spec.speed ?? 0.5)) + idx * 0.15) % 1; const on = toStrength(spec.intensityOn ?? spec.intensity ?? 100); const off = toStrength(spec.intensityOff ?? 15); return ph < (spec.duty ?? 0.3) ? on : off; } return toStrength(spec.intensity ?? 100); } function applyLights(cfg, rt, time) { for (const [name, mats] of Object.entries(rt.lightMats)) { const spec = cfg.components[name]?.light; if (!spec) continue; const c = color(spec.color); const idx = (rt.groups.lights || []).indexOf(name); const s = lightStrength(spec, time, Math.max(0, idx)); for (const m of mats) { m.emissive.copy(c).multiplyScalar(s); m.emissiveIntensity = 1; } } } function applyWheels(cfg, dt) { const wheelParams = resolveConfigParams(cfg); const mult = wheelParams.wheelSpinMultiplier; const speed = wheelParams.defaultWheelSpeed; const steerDur = wheelParams.wheelSteerTweenDuration; for (const name of partsWithRole(cfg, "wheel")) { const comp = cfg.components[name]; const node = parts[name]; const base = partBases[name]; if (!node || !base) continue; const spinAxis = resolveWheelSpinAxis(comp, base, node, "x"); const steerAxis = comp?.action?.wheelSteerAxis || defaultSteerAxis(spinAxis); const f = comp?.action?.wheelSpeedFactor ?? 1; const d = speed * dt * mult * f; _wheelSpinAngles[name] = (_wheelSpinAngles[name] ?? 0) + d; const steerRad = stepWheelSteerAngle(name, node, steerAxis, wheelSteerAngleRad(comp), dt, steerDur); applyWheelRotation(node, base, spinAxis, steerAxis, steerRad, _wheelSpinAngles[name]); } } function applyEmergency(cfg, time) { for (const name of partsWithRole(cfg, "emergency")) { const spec = cfg.components[name]?.action?.emergency; const node = parts[name]; if (!spec || !node) continue; node.traverse((o) => { if (!o.isMesh) return; let m = o.material; if (Array.isArray(m)) m = m[0]; if (!m) return; if (spec.effect === "off") { m.emissive.setHex(0); m.emissiveIntensity = 0; return; } m.emissive.copy(color(spec.color || "#ff0000")); const hz = spec.hz ?? 4; m.emissiveIntensity = (spec.effect === "blink" || spec.effect === "strobe") ? (Math.floor(time * hz) % 2 === 0 ? 1.2 : 0) : 1.2; }); } } function applyCamera(cfg, time) { for (const name of partsWithRole(cfg, "camera")) { const spec = cfg.components[name]?.action?.camera; const cam = parts[name]; const base = partBases[name]; if (!spec || !cam) continue; if (spec.scan !== false && base) { const camParams = resolveConfigParams(cfg); const amp = THREE.MathUtils.degToRad(camParams.cameraScanAmplitudeDeg); const hz = camParams.cameraScanHz; cam.rotation.copy(base.rotation); cam.rotation.y += Math.sin(time * hz) * amp; } } } function applyLidar(cfg, time) { const motionParams = resolveConfigParams(cfg); const hz = motionParams.motionPreviewHz; const amp = THREE.MathUtils.degToRad(motionParams.motionPreviewAmplitudeDeg); for (const name of partsWithRole(cfg, "lidar")) { const comp = cfg.components[name]; if (comp?.motion?.kind !== "rotate") continue; const node = parts[name]; const base = partBases[name]; if (!node || !base) continue; const axis = comp.motion?.axis || "y"; const ang = Math.sin(time * hz * Math.PI * 2) * amp; node.rotation.copy(base.rotation); if (axis === "x") node.rotation.x += ang; else if (axis === "z") node.rotation.z += ang; else node.rotation.y += ang; } } function currentLift(cfg) { if (liftTween) return { ...liftDist }; const cur = { ...liftDist }; if (hasLiftActive()) { Object.assign(cur, liftTargets(cfg)); } return cur; } function computeLiftTargets() { const cfg = playbackConfig(); const targets = liftTargets(cfg); const to = {}; for (const name of allLiftPartNames()) { to[name] = targets[name] ?? baselineLiftDist(name); } return to; } function syncLiftToPlayback(fromSnapshot) { const from = { ...(fromSnapshot || liftDist) }; const to = computeLiftTargets(); const names = new Set([...Object.keys(from), ...Object.keys(to)]); const changed = [...names].some((n) => Math.abs((from[n] ?? 0) - (to[n] ?? 0)) > 1e-5); if (changed) tweenLift(from, to); else liftDist = { ...to }; } /** 立即按当前 playback 刷新视觉,避免关动作后残留上一帧 */ function snapVisuals(cfg, t) { applyLift(cfg, currentLift(cfg)); applyLights(cfg, runtime, t); applyEmergency(cfg, t); const base = baselineConfig(); for (const name of partsWithRole(base, "lidar")) { const comp = cfg.components[name]; if (comp?.motion?.role === "lidar" && comp?.motion?.kind === "rotate") continue; const node = parts[name]; const pb = partBases[name]; if (node && pb) node.rotation.copy(pb.rotation); } for (const name of partsWithRole(base, "camera")) { if (cfg.components[name]?.action?.camera) continue; const cam = parts[name]; const pb = partBases[name]; if (cam && pb) cam.rotation.copy(pb.rotation); } applyComponentOpacity(cfg); } // --- UI --- function renderActions() { const box = $("actionTags"); box.innerHTML = ""; if (!config?.actions) return; for (const [id, act] of Object.entries(config.actions)) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = act.label || id; btn.className = `${act.uiClass || "mode-idle"}${active.has(id) ? " active" : ""}`; btn.addEventListener("click", () => toggle(id)); box.appendChild(btn); } $("activeTag").textContent = active.size ? `已开启:${[...active].map((id) => config.actions[id]?.label || id).join(" + ")}` : "已开启:无"; } function actionError(code, message) { return { ok: false, error: { code, message } }; } function setActionActive(id, on) { if (!config?.actions?.[id]) { return actionError("ACTION_NOT_FOUND", `动作不存在: ${id}`); } if (on && active.has(id)) return { ok: true, active: true }; if (!on && !active.has(id)) return { ok: true, active: false }; const liftFrom = captureCurrentLiftDist(); if (on) active.add(id); else { active.delete(id); restoreClosedActionComponents(id); } const liftRelated = on ? isLiftAction(config.actions[id]) : namesInAction(id).some(isLiftPart); if (liftRelated || hasLiftActive() || liftTween) syncLiftToPlayback(liftFrom); snapVisuals(playbackConfig(), clock.getElapsedTime()); renderActions(); notifyRemoteState(); return { ok: true, active: on }; } function toggle(id) { const r = setActionActive(id, !active.has(id)); if (!r.ok) return; } function playAction(id) { return setActionActive(id, true); } function stopAction(id) { return setActionActive(id, false); } function clearAll() { const liftFrom = captureCurrentLiftDist(); const closing = [...active]; active.clear(); for (const id of closing) restoreClosedActionComponents(id); syncLiftToPlayback(liftFrom); snapVisuals(playbackConfig(), clock.getElapsedTime()); renderActions(); notifyRemoteState(); } function listActions() { if (!config?.actions) return []; return Object.entries(config.actions).map(([id, act]) => ({ id, label: act.label || id, uiClass: act.uiClass || "mode-idle", active: active.has(id), })); } function listModels() { return (manifest.models || []).map((m) => ({ id: m.id, name: m.name || m.id, file: m.file, config: m.config || `${m.id}.json`, root: m.root, })); } function getPlaybackState() { return { modelId: currentModelId, ready: !!(config && runtime && sceneRoot), activeActions: [...active], actions: listActions(), models: listModels(), }; } function setStatus(html) { $("status").innerHTML = html; } function findModelEntry(modelId) { return (manifest.models || []).find((m) => m.id === modelId) || null; } function loadModel(entry) { if (!entry?.file) return Promise.reject(new Error("无效的模型条目")); setStatus("加载中..."); active.clear(); liftTween = null; liftDist = {}; _materialBaseOpacity.clear(); _meshVisualState.clear(); if (sceneRoot) { scene.remove(sceneRoot); sceneRoot = null; } currentModelId = entry.id; runtime = null; const cfgFile = entry.config || `${entry.id}.json`; return fetch(`${AGV_BASE}${cfgFile}?t=${Date.now()}`) .then((cfgRes) => { if (!cfgRes.ok) throw new Error(`缺少配置 ${cfgFile}`); return cfgRes.json(); }) .then((cfg) => { config = cfg; normalizeConfig(config); return new Promise((resolve, reject) => { loader.load(`${AGV_BASE}${entry.file}`, (gltf) => { const loaded = loadGlbRoot(gltf, entry.root || config.model?.root); sceneRoot = loaded.sceneRoot; baseTransforms = loaded.baseTransforms; scene.add(sceneRoot); const base = baselineConfig(); mapParts(base, loaded.sceneRoot, loaded.partsByName); installAllWheelPivots(base); resetParts(base); runtime = buildRuntime(base); renderActions(); setStatus(`已加载 ${entry.name || entry.id}
${Object.keys(config.actions || {}).length} 个动作`); notifyRemoteState(); resolve({ modelId: entry.id }); }, undefined, (e) => { setStatus(`GLB 加载失败
${e.message}`); reject(e); }); }); }) .catch((e) => { setStatus(`${e.message}`); throw e; }); } function loadModelById(modelId) { const entry = findModelEntry(modelId); if (!entry) return Promise.reject(new Error(`模型不存在: ${modelId}`)); const sel = $("modelSelect"); if (sel) sel.value = modelId; return loadModel(entry); } const playbackController = { loadModel: loadModelById, playAction, stopAction, clearActions: clearAll, getState: getPlaybackState, }; const playbackApi = createPlaybackApi(playbackController, { allowedOrigins: null }); playbackApi.attach(window); let remoteBridge = null; function notifyRemoteState() { remoteBridge?.reportLocalState?.(); } function renderRemoteStatus({ text, kind }) { const el = $("remoteStatus"); if (!el) return; el.textContent = `远程:${text}`; el.className = kind || ""; } remoteBridge = connectRemotePlayback(window.AgvPlaybackApi, { onStatus: renderRemoteStatus }); function parseInitialActions(search) { const raw = search.get("actions") || search.get("action"); if (!raw) return []; return raw.split(/[,+\s]+/).map((s) => s.trim()).filter(Boolean); } async function init() { const search = new URLSearchParams(location.search); const q = search.get("model"); try { const res = await fetch(`${AGV_BASE}manifest.json?t=${Date.now()}`); manifest = await res.json(); const sel = $("modelSelect"); sel.innerHTML = ""; for (const m of manifest.models || []) { const o = document.createElement("option"); o.value = m.id; o.textContent = m.name || m.id; sel.appendChild(o); } sel.addEventListener("change", () => { const entry = manifest.models.find((m) => m.id === sel.value); if (entry) loadModel(entry); }); $("clearAll").addEventListener("click", clearAll); const list = manifest.models || []; if (!list.length) { setStatus('无模型'); return; } const serverState = await remoteBridge.fetchServerState(); const serverHasState = (serverState?.revision || 0) > 0; const initialActions = parseInitialActions(search); if (serverHasState && serverState.modelId) { const idx = Math.max(0, list.findIndex((m) => m.id === serverState.modelId)); sel.selectedIndex = idx; await loadModelById(serverState.modelId); await remoteBridge.syncFromServer(); } else { const idx = q ? Math.max(0, list.findIndex((m) => m.id === q)) : 0; sel.selectedIndex = idx; await loadModel(list[idx]); for (const id of initialActions) { const r = playAction(id); if (!r.ok) { setStatus(`${r.error.message}`); break; } } if (initialActions.length) notifyRemoteState(); } } catch (e) { setStatus(`${e.message}
请运行 python serve.py`); } } const clock = new THREE.Clock(); function loop() { requestAnimationFrame(loop); const dt = clock.getDelta(); const t = clock.getElapsedTime(); if (config && runtime) { stepLift(dt); const cfg = playbackConfig(); applyLift(cfg, currentLift(cfg)); applyLights(cfg, runtime, t); applyWheels(cfg, dt); applyEmergency(cfg, t); applyCamera(cfg, t); applyLidar(cfg, t); applyComponentOpacity(cfg); } controls.update(); renderer.render(scene, camera); } loop(); init(); addEventListener("resize", () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); });