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"; const TYPE_TAGS = { static: "静态", body: "车体", light: "灯光", wheel: "轮子", actuator: "机构", sensor: "传感", safety: "安全" }; const TYPE_ICONS = { static: "▣", body: "▦", light: "◉", wheel: "◎", actuator: "↕", sensor: "◈", safety: "⚠" }; const DEFAULT_TRANSPARENT_OPACITY = 0.25; const _materialBaseOpacity = new Map(); const _meshVisualState = new Map(); /** GLB 节点名 -> Object3D(加载后填充) */ let _glbNodesIndex = new Map(); let _glbPartsByName = {}; function typeTag(type) { return TYPE_TAGS[type] || type; } function normalizeGlbNodeName(name) { return String(name ?? "").replace(/\u00a0/g, " ").trim(); } /** Three.js GLTFLoader 会把 glTF 名 sanitize 后写入 object.name,原始名在 userData.name */ 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 indexGlbNodeKey(index, key, obj) { if (!key || !obj) return; for (const k of glbNameLookupKeys(key)) { if (!index.has(k)) index.set(k, obj); } } function indexGlbNodeKeyPlain(partsByName, key, obj) { if (!key || !obj) return; for (const k of glbNameLookupKeys(key)) { if (!partsByName[k]) partsByName[k] = obj; } } function indexObject3D(obj, indexOrPlain) { if (!obj) return; const isMap = indexOrPlain instanceof Map; const add = isMap ? (k, o) => indexGlbNodeKey(indexOrPlain, k, o) : (k, o) => indexGlbNodeKeyPlain(indexOrPlain, k, o); if (obj.name) add(obj.name, obj); if (obj.userData?.name) add(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 loadBlenderGLB(gltf, rootName) { const sceneRoot = gltf.scene; let modelRoot = rootName ? sceneRoot.getObjectByName(rootName) : null; if (!modelRoot) { modelRoot = sceneRoot.getObjectByName("StealthAGV") ?? (sceneRoot.children.length === 1 ? sceneRoot.children[0] : sceneRoot); } const parts = buildPartsFromGltfParser(gltf.parser); sceneRoot.traverse((child) => indexObject3D(child, parts)); const baseTransforms = new Map(); sceneRoot.traverse((child) => rememberBaseTransform(baseTransforms, child)); return { sceneRoot, modelRoot, parts, baseTransforms }; } function resetPart(part, baseTransforms, name) { const base = getBaseTransform(baseTransforms, name); if (!part || !base) return; part.position.copy(base.position); part.quaternion.copy(base.quaternion); part.scale.copy(base.scale); } function forEachMesh(node, fn) { node.traverse((o) => { if (o.isMesh) fn(o); }); } function ensureStandardMaterial(mesh) { if (!mesh?.isMesh) return null; let m = mesh.material; if (Array.isArray(m)) m = m[0]; if (!m) return null; if (!m.isMeshStandardMaterial) { mesh.material = new THREE.MeshStandardMaterial({ color: m.color?.clone?.() ?? new THREE.Color(0xffffff), metalness: m.metalness ?? 0.3, roughness: m.roughness ?? 0.5, emissive: m.emissive?.clone?.() ?? new THREE.Color(0), emissiveIntensity: m.emissiveIntensity ?? 1, map: m.map ?? null, transparent: m.transparent, opacity: m.opacity, }); m = mesh.material; } return m; } function cloneEmissiveMaterial(mesh) { if (!mesh?.isMesh) return null; const base = ensureStandardMaterial(mesh); if (!base) return null; const mat = base.clone(); mat.color.setHex(0x000000); mat.metalness = 0; mat.roughness = 1; mat.envMapIntensity = 0; mat.toneMapped = false; mat.emissive = new THREE.Color(0x000000); mat.emissiveIntensity = 1; mesh.material = mat; return mat; } function buildGlbNodesIndex(root, partsByName = null) { const index = new Map(); if (partsByName) { for (const [key, obj] of Object.entries(partsByName)) indexGlbNodeKey(index, key, obj); } if (root) root.traverse((child) => indexObject3D(child, index)); return index; } function lookupInGlbStore(store, glbName) { if (!store) return null; for (const k of glbNameLookupKeys(glbName)) { const hit = store instanceof Map ? store.get(k) : store[k]; if (hit) return hit; } return null; } function lookupGlbNode(glbName) { if (glbName == null || glbName === "") return null; return lookupInGlbStore(_glbNodesIndex, glbName) ?? lookupInGlbStore(_glbPartsByName, glbName) ?? lookupInSceneByGlbName(glbName); } function lookupInSceneByGlbName(glbName) { if (!sceneRoot) return null; let found = null; const want = new Set(glbNameLookupKeys(glbName)); sceneRoot.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 registerPartBinding(configKey, obj, glbName) { parts[configKey] = obj; for (const k of glbNameLookupKeys(glbName)) glbToConfigKey[k] = configKey; if (obj.name) glbToConfigKey[obj.name] = configKey; if (obj.userData?.name) { for (const k of glbNameLookupKeys(obj.userData.name)) glbToConfigKey[k] = configKey; } } function buildPartsFromConfig(config) { const partsOut = {}; const glbToConfigKeyOut = {}; const missing = []; for (const [configKey, comp] of Object.entries(config.components || {})) { const glbName = comp.glbNode || configKey; const obj = lookupGlbNode(glbName); if (obj) { partsOut[configKey] = obj; for (const k of glbNameLookupKeys(glbName)) glbToConfigKeyOut[k] = configKey; if (obj.name) glbToConfigKeyOut[obj.name] = configKey; if (obj.userData?.name) { for (const k of glbNameLookupKeys(obj.userData.name)) glbToConfigKeyOut[k] = configKey; } } else { missing.push({ configKey, glbName }); } } return { parts: partsOut, glbToConfigKey: glbToConfigKeyOut, missing }; } function suggestGlbNodeNames(glbName, limit = 4) { const q = normalizeGlbNodeName(glbName).toLowerCase(); if (!q || !_glbNodesIndex.size) return []; const hits = []; for (const key of _glbNodesIndex.keys()) { const k = normalizeGlbNodeName(key).toLowerCase(); if (k.includes(q) || q.includes(k)) hits.push(key); } return [...new Set(hits)].slice(0, limit); } function bindPartConfigKey(configKey) { if (parts[configKey]) return parts[configKey]; const comp = getBaseline(agvConfig)?.[configKey]; if (!comp) return null; const glbName = comp.glbNode || configKey; const obj = lookupGlbNode(glbName); if (!obj) return null; registerPartBinding(configKey, obj, glbName); return obj; } function rebindAllPartsFromConfig() { if (!agvConfig) return { parts: {}, glbToConfigKey: {}, missing: [] }; const baselineCfg = withComponents(agvConfig, getBaseline(agvConfig)); for (const k of Object.keys(parts)) delete parts[k]; const mapped = buildPartsFromConfig(baselineCfg); Object.assign(parts, mapped.parts); glbToConfigKey = mapped.glbToConfigKey; return mapped; } function findConfigKeyFromObject(obj, keyMap) { let p = obj; while (p) { if (p.name) { for (const k of glbNameLookupKeys(p.name)) { if (keyMap[k]) return keyMap[k]; } } const ud = p.userData?.name; if (ud) { for (const k of glbNameLookupKeys(ud)) { if (keyMap[k]) return keyMap[k]; } } p = p.parent; } return ""; } const _colorCache = new Map(); const DEFAULT_LIGHT = { color: "#00FF55", effect: "solid", intensity: 100 }; const DEFAULT_LIFT_START = 0; const DEFAULT_LIFT_END = 0.15; const DEFAULT_ACTION_LABEL = "动作1"; 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, liftPreviewHz: 0.35, motionPreviewHz: 0.4, motionPreviewAmplitudeDeg: 18, actionDraftPreviewSec: 0, }; /** 动作草稿预览结束时刻(clock.getElapsedTime),0 表示未预览 */ let actionDraftPreviewUntil = 0; function parseColor(hex) { if (typeof hex !== "string" || !hex.trim()) hex = "#00FF55"; if (!hex.startsWith("#")) hex = `#${hex}`; if (hex.length === 4) hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`; if (hex.length !== 7) hex = "#00FF55"; if (!_colorCache.has(hex)) _colorCache.set(hex, new THREE.Color(hex)); return _colorCache.get(hex).clone(); } function buildRuntime(config, parts) { const lightMats = {}; const compByRole = { light: [], wheel: [], lift: [], lidar: [], camera: [], emergency: [] }; for (const [name, comp] of Object.entries(config.components || {})) { const node = parts[name]; if (!node) continue; const role = comp.motion?.role; if (comp.type === "light" || role === "light") { const mats = []; forEachMesh(node, (mesh) => { const mat = cloneEmissiveMaterial(mesh); if (mat) mats.push(mat); }); 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", caster: comp.tags?.includes("caster") }); } 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: config.groups || {} }; } function resolveLightForPart(config, partName) { const comp = config.components?.[partName]; if (!comp) return null; let spec; if (comp.light) spec = { ...DEFAULT_LIGHT, ...comp.light }; else if (comp.type === "light" || comp.motion?.role === "light") spec = { ...DEFAULT_LIGHT }; else return null; if (!isActionDraftPreviewActive() && partName === selectedComponentName && comp.type === "light") { spec = { ...spec, ...readLightSpecFromUi() }; } return spec; } function toEmissiveStrength(v) { return Math.min(2, Math.max(0, Number(v ?? 100)) / 100); } function setMatEmissive(mat, color, strength) { mat.emissive.copy(color).multiplyScalar(Math.max(0, strength)); mat.emissiveIntensity = 1; } function evalIntensity(spec, time) { const effect = spec.effect || "solid"; if (effect === "breath") { const period = spec.period ?? 2; const min = toEmissiveStrength(spec.intensityMin ?? 20); const max = toEmissiveStrength(spec.intensityMax ?? spec.intensity ?? 100); const w = 0.5 + 0.5 * Math.sin((time / period) * Math.PI * 2); return min + (max - min) * w; } if (effect === "blink" || effect === "strobe") { const hz = spec.hz ?? 4; return Math.floor(time * hz) % 2 === 0 ? toEmissiveStrength(spec.intensityOn ?? spec.intensity ?? 100) : toEmissiveStrength(spec.intensityOff ?? 0); } return toEmissiveStrength(spec.intensity ?? 100); } function evalFlow(spec, time, partIndex, forward = true) { const speed = spec.speed ?? 0.5; const duty = spec.duty ?? 0.3; const ph = ((time * speed) + partIndex * 0.15) % 1; const local = forward ? ph : (1 - ph); const on = toEmissiveStrength(spec.intensityOn ?? spec.intensity ?? 100); const off = toEmissiveStrength(spec.intensityOff ?? 15); return local < duty ? on : off; } function mergeParameters(...layers) { const out = cloneJson(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); } function maxLiftStroke(config) { const offsets = resolveConfigParams(config).liftOffsets; return Math.max(...offsets, 0.05); } function getLiftRange(comp, config) { const a = comp?.action || {}; let start = Number(a.liftStart ?? DEFAULT_LIFT_START); let end = Number(a.liftEnd ?? DEFAULT_LIFT_END); if (a.liftStart == null && a.liftEnd == null && a.motionFactor != null) { const stroke = maxLiftStroke(config); const full = resolveConfigParams(config).liftMotionFactorFull; end = stroke * Math.min(1, Math.max(0, Number(a.motionFactor) / full)); start = 0; } if (Number.isNaN(start)) start = 0; if (Number.isNaN(end)) end = DEFAULT_LIFT_END; return { start, end }; } function liftPreviewPhase(time, config) { const hz = resolveConfigParams(config).liftPreviewHz; return 0.5 + 0.5 * Math.sin(time * hz * Math.PI * 2); } function computeLiftDistancesBaseline(config, runtime, time) { const map = {}; if (!runtime?.compByRole?.lift?.length) return map; const phase = liftPreviewPhase(time, config); for (const name of runtime.compByRole.lift) { const { start, end } = getLiftRange(config?.components?.[name], config); map[name] = start + (end - start) * phase; } return map; } function resolveLiftDistance(distOrMap, name) { if (distOrMap != null && typeof distOrMap === "object") return distOrMap[name] ?? 0; return typeof distOrMap === "number" ? distOrMap : 0; } const _liftAxisApp = new THREE.Vector3(); const _liftWorldApp = new THREE.Vector3(); const _liftParentInvApp = new THREE.Matrix4(); function applyLift(config, runtime, parts, bases, distOrMap) { for (const name of runtime.compByRole.lift) { const node = parts[name]; if (!node) continue; const comp = config.components?.[name]; const base = bases[name]; if (!base?.worldPosition) continue; const delta = resolveLiftDistance(distOrMap, name); const axis = comp?.motion?.axis || "y"; node.rotation.copy(base.rotation); node.position.copy(base.position); node.updateWorldMatrix(true, false); if (axis === "x") _liftAxisApp.set(1, 0, 0); else if (axis === "z") _liftAxisApp.set(0, 0, 1); else _liftAxisApp.set(0, 1, 0); _liftAxisApp.transformDirection(node.matrixWorld).normalize(); _liftWorldApp.copy(base.worldPosition).addScaledVector(_liftAxisApp, delta); if (node.parent) { node.parent.updateWorldMatrix(true, false); _liftParentInvApp.copy(node.parent.matrixWorld).invert(); node.position.copy(_liftWorldApp.applyMatrix4(_liftParentInvApp)); } else { node.position.copy(_liftWorldApp); } } } function wheelSpeedFactor(comp) { return comp?.action?.wheelSpeedFactor ?? comp?.action?.speedFactor ?? 1; } /** 滚动轴以外的转向轴(如 X 轴滚动的轮子用 Y 轴转向) */ 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 _wheelSpinAngles = {}; const _wheelSteerAngles = {}; 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); } /** 转向角指数趋近目标,约 duration 秒内到位;取消/回基准时同样平滑 */ 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 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._ownMaterial) 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._ownMaterial = 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(config) { if (!config?.components) return; for (const [name, comp] of Object.entries(config.components)) { if (comp?.action?.opacity == null) continue; const node = parts[name]; if (node) setNodeOpacity(node, componentOpacity(comp)); } } 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); } /** GLB 节点原点到网格几何中心的偏移;原点不在轮芯时直接旋转会公转 */ 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"; } /** 在几何中心插入 spinGroup,自转只作用在该组上(与坐标轴一致) */ 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; } /** wrapper=基准位姿;steerGroup 在轮心转向;spinGroup 在轮心自转 */ 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); } function applyLights(config, runtime, time) { for (const [partName, matOrList] of Object.entries(runtime.lightMats)) { const spec = resolveLightForPart(config, partName); if (!spec) continue; const mats = Array.isArray(matOrList) ? matOrList : [matOrList]; const color = parseColor(spec.color || "#00FF55"); const effect = spec.effect || "solid"; for (const mat of mats) { let strength; if (effect === "flow_forward") { const idx = (runtime.groups.lights || []).indexOf(partName); strength = evalFlow(spec, time, Math.max(0, idx), true); } else if (effect === "flow_backward") { const idx = (runtime.groups.lights || []).indexOf(partName); strength = evalFlow(spec, time, Math.max(0, idx), false); } else if (effect === "blink_segment") { const idx = (runtime.groups.lights || []).indexOf(partName); strength = evalFlow(spec, time, idx, true); } else { strength = evalIntensity(spec, time); } setMatEmissive(mat, color, strength); } } } function applyEmergency(config, runtime, time, parts) { for (const name of runtime.compByRole.emergency) { const spec = config.components?.[name]?.action?.emergency; if (!spec) continue; const node = parts[name]; if (!node) continue; forEachMesh(node, (mesh) => { const mat = ensureStandardMaterial(mesh); if (!mat) return; if (spec.effect === "off") { mat.emissive.set(0x000000); mat.emissiveIntensity = 0; return; } mat.emissive.copy(parseColor(spec.color || "#FF0000")); if (spec.effect === "blink" || spec.effect === "strobe") { const hz = spec.hz ?? 4; mat.emissiveIntensity = Math.floor(time * hz) % 2 === 0 ? (spec.intensityOn ?? 1.2) : 0; } else { mat.emissiveIntensity = spec.intensityOn ?? 1.2; } }); } } function applyCamera(config, runtime, time, parts, bases) { for (const name of runtime.compByRole.camera) { const comp = config.components?.[name]; const spec = comp?.action?.camera; if (!spec) continue; const cam = parts[name]; if (!cam) continue; cam.traverse((o) => { if (o.isMesh && /led/i.test(o.name)) { const mat = ensureStandardMaterial(o); if (mat && spec.ledBreath) { mat.emissive.set(parseColor(spec.ledColor || "#00AFFF")); mat.emissiveIntensity = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(time * Math.PI)); } } }); if (spec.scan !== false) { const camParams = resolveConfigParams(config); const amp = THREE.MathUtils.degToRad(camParams.cameraScanAmplitudeDeg); const hz = camParams.cameraScanHz; const baseRot = bases[name]?.rotation; if (baseRot) cam.rotation.copy(baseRot); const baseY = baseRot?.y ?? bases[name]?.rotationY ?? 0; cam.rotation.y = baseY + Math.sin(time * hz) * amp; } } } function applyWheels(config, runtime, dt, parts, speedSlider) { if (!runtime.compByRole.wheel.length) return; const wheelParams = resolveConfigParams(config); const mult = wheelParams.wheelSpinMultiplier; const steerDur = wheelParams.wheelSteerTweenDuration; for (const { name, axis, caster } of runtime.compByRole.wheel) { const node = parts[name]; const base = partBases[name]; if (!node || !base) continue; const comp = config.components?.[name]; const spinAxis = resolveWheelSpinAxis(comp, base, node, axis); const steerAxis = comp?.action?.wheelSteerAxis || defaultSteerAxis(spinAxis); let w = speedSlider; if (caster) w *= 1.4; const delta = w * dt * mult * wheelSpeedFactor(comp); _wheelSpinAngles[name] = (_wheelSpinAngles[name] ?? 0) + delta; const steerRad = stepWheelSteerAngle(name, node, steerAxis, wheelSteerAngleRad(comp), dt, steerDur); applyWheelRotation(node, base, spinAxis, steerAxis, steerRad, _wheelSpinAngles[name]); } } function applyRotateMotionPreview(config, runtime, parts, bases, time) { const motionParams = resolveConfigParams(config); const hz = motionParams.motionPreviewHz; const amp = THREE.MathUtils.degToRad(motionParams.motionPreviewAmplitudeDeg); for (const name of runtime.compByRole.lidar) { const comp = config.components?.[name]; if (comp?.motion?.kind !== "rotate") continue; const node = parts[name]; const base = bases[name]; if (!node || !base) continue; const angle = Math.sin(time * hz * Math.PI * 2) * amp; const axis = comp?.motion?.axis || "y"; if (base.rotation) node.rotation.copy(base.rotation); else node.rotation.set(0, base.rotationY ?? 0, 0); if (axis === "x") node.rotation.x += angle; else if (axis === "z") node.rotation.z += angle; else node.rotation.y += angle; } } function captureBases(parts, config) { if (sceneRoot) sceneRoot.updateWorldMatrix(true, true); const bases = {}; for (const name of Object.keys(config.components || {})) { const node = parts[name]; if (!node) continue; const entry = { position: node.position.clone(), quaternion: node.quaternion.clone(), rotation: node.rotation.clone(), positionY: node.position.y, rotationY: node.rotation.y, worldPosition: node.getWorldPosition(new THREE.Vector3()), }; Object.assign(entry, computeWheelPartMeta(node)); bases[name] = entry; } return bases; } const wrap = document.getElementById("canvas-wrap"); const statusEl = document.getElementById("status"); const toastContainer = document.getElementById("toast-container"); const compMetaEl = document.getElementById("compMeta"); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.sortObjects = true; renderer.shadowMap.enabled = true; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; wrap.appendChild(renderer.domElement); renderer.domElement.tabIndex = -1; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0d1117); scene.fog = new THREE.Fog(0x0d1117, 6, 18); scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture; const camera = new THREE.PerspectiveCamera(48, 1, 0.02, 80); camera.position.set(1.6, 1.1, 1.6); function resizeCanvas() { if (!wrap) return; const w = Math.max(1, wrap.clientWidth); const h = Math.max(1, wrap.clientHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } resizeCanvas(); if (typeof ResizeObserver !== "undefined") { new ResizeObserver(resizeCanvas).observe(wrap); } const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.18, 0); controls.enableDamping = true; const panelEl = document.getElementById("panel"); panelEl?.addEventListener("focusin", (e) => { if (e.target.closest("input, textarea, select")) controls.enabled = false; }); panelEl?.addEventListener("focusout", () => { requestAnimationFrame(() => { if (!panelEl?.contains(document.activeElement)) controls.enabled = true; }); }); panelEl?.addEventListener("keydown", (e) => e.stopPropagation()); panelEl?.addEventListener("keyup", (e) => e.stopPropagation()); scene.add(new THREE.AmbientLight(0xffffff, 0.4)); const key = new THREE.DirectionalLight(0xffffff, 1.3); key.position.set(3, 6, 2); key.castShadow = true; scene.add(key); const fill = new THREE.DirectionalLight(0xaaccff, 0.35); fill.position.set(-2, 3, -1); scene.add(fill); scene.add(new THREE.GridHelper(5, 20, 0x2a3142, 0x1a1f2b)); const AGV_BASE = "../agv/"; const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/libs/draco/gltf/"); const loader = new GLTFLoader(); loader.setDRACOLoader(dracoLoader); let manifest = { models: [] }; let currentModel = null; let sceneRoot = null; const parts = {}; let glbToConfigKey = {}; let agvRoot = null; let baseTransforms = null; let agvConfig = null; let agvRuntime = null; let partBases = {}; let selectedComponentName = ""; let selectionHelper = null; let selectionAxesOrigin = null; let selectionAxesCenter = null; const _focusBox = new THREE.Box3(); const _focusCenter = new THREE.Vector3(); const _focusSize = new THREE.Vector3(); const _focusDir = new THREE.Vector3(); let componentFilterText = ""; /** @type {Record} 动作草稿:组件名 -> 动作参数切片 */ let actionDraftComponents = {}; /** @type {object|null} 动作草稿级 parameters;null 表示使用默认 */ let actionDraftParameters = null; let actionEditName = ""; /** 正在编辑的已保存动作 ID;空字符串表示新建草稿 */ let actionDraftEditId = ""; const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const ui = { agvModel: document.getElementById("agvModel"), refreshList: document.getElementById("refreshList"), openConfig: document.getElementById("openConfig"), exportConfig: document.getElementById("exportConfig"), componentFilter: document.getElementById("componentFilter"), componentList: document.getElementById("componentList"), componentType: document.getElementById("componentType"), panelStatic: document.getElementById("panelStatic"), panelLight: document.getElementById("panelLight"), panelWheel: document.getElementById("panelWheel"), panelMotion: document.getElementById("panelMotion"), panelCamera: document.getElementById("panelCamera"), panelEmergency: document.getElementById("panelEmergency"), wheelAxis: document.getElementById("wheelAxis"), wheelSpeedFactor: document.getElementById("wheelSpeedFactor"), wheelSteerAngleDeg: document.getElementById("wheelSteerAngleDeg"), componentMotionRole: document.getElementById("componentMotionRole"), componentMotionKind: document.getElementById("componentMotionKind"), componentMotionAxis: document.getElementById("componentMotionAxis"), liftStartRow: document.getElementById("liftStartRow"), liftEndRow: document.getElementById("liftEndRow"), liftStart: document.getElementById("liftStart"), liftEnd: document.getElementById("liftEnd"), compLightColor: document.getElementById("compLightColor"), compLightEffect: document.getElementById("compLightEffect"), compLightIntensity: document.getElementById("compLightIntensity"), compLightHz: document.getElementById("compLightHz"), compLightHzRow: document.getElementById("compLightHzRow"), compLightPeriod: document.getElementById("compLightPeriod"), compLightPeriodRow: document.getElementById("compLightPeriodRow"), actionCamScan: document.getElementById("actionCamScan"), actionCamLedBreath: document.getElementById("actionCamLedBreath"), actionCamLedColor: document.getElementById("actionCamLedColor"), actionEmerColor: document.getElementById("actionEmerColor"), actionEmerEffect: document.getElementById("actionEmerEffect"), actionEmerHz: document.getElementById("actionEmerHz"), applyComponent: document.getElementById("applyComponent"), actionLabel: document.getElementById("actionLabel"), saveAsAction: document.getElementById("saveAsAction"), syncBaseline: document.getElementById("syncBaseline"), actionList: document.getElementById("actionList"), tabBaseline: document.getElementById("tabBaseline"), tabActions: document.getElementById("tabActions"), tabPanelBaseline: document.getElementById("tabPanelBaseline"), tabPanelActions: document.getElementById("tabPanelActions"), agvInfoName: document.getElementById("agvInfoName"), statComponents: document.getElementById("statComponents"), statActions: document.getElementById("statActions"), statLights: document.getElementById("statLights"), actionCompPickList: document.getElementById("actionCompPickList"), actionCfgPlaceholder: document.getElementById("actionCfgPlaceholder"), actionCompPick: document.getElementById("actionCompPick"), addActionComp: document.getElementById("addActionComp"), addAllActionComp: document.getElementById("addAllActionComp"), applyOpacityToAll: document.getElementById("applyOpacityToAll"), actionSelectedList: document.getElementById("actionSelectedList"), actionSelectedCount: document.getElementById("actionSelectedCount"), actionCfgBox: document.getElementById("actionCfgBox"), actionCfgTitle: document.getElementById("actionCfgTitle"), clearActionDraft: document.getElementById("clearActionDraft"), saveActionDraftComp: document.getElementById("saveActionDraftComp"), }; const actUi = { componentType: document.getElementById("actComponentType"), panelStatic: document.getElementById("actPanelStatic"), panelLight: document.getElementById("actPanelLight"), panelWheel: document.getElementById("actPanelWheel"), panelMotion: document.getElementById("actPanelMotion"), panelCamera: document.getElementById("actPanelCamera"), panelEmergency: document.getElementById("actPanelEmergency"), compLightColor: document.getElementById("actLightColor"), compLightEffect: document.getElementById("actLightEffect"), compLightIntensity: document.getElementById("actLightIntensity"), compLightHz: document.getElementById("actLightHz"), compLightHzRow: document.getElementById("actLightHzRow"), compLightPeriod: document.getElementById("actLightPeriod"), compLightPeriodRow: document.getElementById("actLightPeriodRow"), wheelAxisHint: document.getElementById("actWheelAxisHint"), wheelSpeedFactor: document.getElementById("actWheelSpeedFactor"), wheelSteerAngleDeg: document.getElementById("actWheelSteerAngleDeg"), componentMotionRole: document.getElementById("actMotionRole"), componentMotionKind: document.getElementById("actMotionKind"), componentMotionAxis: document.getElementById("actMotionAxis"), liftStartRow: document.getElementById("actLiftStartRow"), liftEndRow: document.getElementById("actLiftEndRow"), liftStart: document.getElementById("actLiftStart"), liftEnd: document.getElementById("actLiftEnd"), actionCamScan: document.getElementById("actCamScan"), actionCamLedBreath: document.getElementById("actCamLedBreath"), actionCamLedColor: document.getElementById("actCamLedColor"), actionEmerColor: document.getElementById("actEmerColor"), actionEmerEffect: document.getElementById("actEmerEffect"), actionEmerHz: document.getElementById("actEmerHz"), actOpacity: document.getElementById("actOpacity"), }; function cloneJson(obj) { return JSON.parse(JSON.stringify(obj)); } /** 基准组件表(全量 JSON) */ function getBaseline(cfg) { return cfg?.baseline || cfg?.components || {}; } function withComponents(cfg, components) { return { ...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 getDraftParameters() { return mergeParameters(actionDraftParameters); } function buildActionDraftConfig() { if (!agvConfig) return null; const components = cloneJson(getBaseline(agvConfig)); for (const [name, patch] of Object.entries(actionDraftComponents)) { if (components[name]) components[name] = mergeComp(components[name], patch); } return { ...withComponents(agvConfig, components), parameters: getDraftParameters() }; } function isActionDraftPreviewActive(t = null) { const now = t ?? (typeof clock !== "undefined" ? clock.getElapsedTime() : 0); return actionDraftPreviewUntil > 0 && now < actionDraftPreviewUntil; } function actionDraftPreviewDuration() { const params = getDraftParameters(); return Math.max(params.actionDraftPreviewSec, 1 / params.liftPreviewHz); } function startActionDraftPreview() { if (!Object.keys(actionDraftComponents).length) return; actionDraftPreviewUntil = clock.getElapsedTime() + actionDraftPreviewDuration(); } function ensureDefaultActionLabel() { if (ui.actionLabel && !ui.actionLabel.value.trim()) { ui.actionLabel.value = DEFAULT_ACTION_LABEL; renderAgvInfoBar(); } } 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 = cloneJson(root); } delete cfg.parameters; } function normalizeConfig(cfg) { if (!cfg) return cfg; if (!cfg.baseline) cfg.baseline = cloneJson(cfg.components || {}); if (cfg.actions) { for (const action of Object.values(cfg.actions)) { if (action.overrides && !action.components) { action.components = action.overrides; delete action.overrides; } } } if (cfg.states && !cfg.actions) migrateStatesToActions(cfg); migrateRootParametersToActions(cfg); delete cfg.components; return cfg; } function prepareConfigForSave(cfg) { const out = cloneJson(cfg); normalizeConfig(out); return out; } function getComponentForEditor(name) { if (!name || !agvConfig) return null; return getBaseline(agvConfig)[name] ?? null; } function parseNum(v, fallback) { const n = parseFloat(v); if (Number.isNaN(n)) return fallback; return n; } function guessActionUiClass(id, label = "") { const k = `${id} ${label}`.toLowerCase(); if (/fault|error|alarm|\u6025|\u6545\u969c/.test(k)) return "mode-fault"; if (/run|work|move|turn|zhuan|\u8f6c|\u8fd0\u884c|\u4f5c\u4e1a|\u5de6\u8f6c|\u53f3\u8f6c/.test(k)) return "mode-running"; if (/lift|ju|sheng|\u4e3e\u5347|\u9876\u5347/.test(k)) return "mode-lift"; if (/transparent|touming|\u900f\u660e/.test(k)) return "mode-transparent"; return "mode-idle"; } function jsonEqual(a, b) { return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); } function diffComponent(base, current) { const patch = {}; if (!base || !current) return patch; if (current.type !== base.type) patch.type = current.type; if (!jsonEqual(current.motion, base.motion)) patch.motion = current.motion ? cloneJson(current.motion) : null; if (!jsonEqual(current.light, base.light)) patch.light = current.light ? cloneJson(current.light) : undefined; if (!jsonEqual(current.action, base.action)) patch.action = current.action ? cloneJson(current.action) : undefined; return patch; } function migrateStatesToActions(config) { if (!config?.states || config.actions) return config; config.actions = {}; const baseline = getBaseline(config); for (const [id, st] of Object.entries(config.states)) { const components = {}; for (const [name, cur] of Object.entries(st.components || {})) { const base = baseline[name]; if (!base) continue; const patch = diffComponent(base, cur); if (Object.keys(patch).length) components[name] = patch; } config.actions[id] = { label: st.label || id, uiClass: st.uiClass || guessActionUiClass(id, st.label), components, }; } delete config.states; return config; } /** display name -> action id (pinyin; ASCII kept as-is) */ async function labelToActionId(label) { const text = (label || "").trim(); if (!text) return ""; if (/^[a-zA-Z][\w-]*$/.test(text)) return text; try { const { pinyin } = await import("pinyin-pro"); let id = pinyin(text, { toneType: "none", type: "array" }).join("").toLowerCase(); id = id.replace(/[^a-z0-9_-]/g, ""); if (!id) return ""; if (!/^[a-zA-Z]/.test(id)) id = `s_${id}`; return id; } catch { flashSaved("拼音库加载失败,请检查网络或刷新后重试", "warn"); return ""; } } function getWritableComponents() { if (!agvConfig) return null; agvConfig.baseline ||= {}; return agvConfig.baseline; } function switchInspectorTab(tab) { const isBaseline = tab === "baseline"; ui.tabBaseline?.classList.toggle("active", isBaseline); ui.tabActions?.classList.toggle("active", !isBaseline); ui.tabPanelBaseline?.classList.toggle("hidden", !isBaseline); ui.tabPanelActions?.classList.toggle("hidden", isBaseline); } function renderAgvInfoBar() { const name = currentModel?.name || agvConfig?.model?.name || agvConfig?.model?.id || "—"; if (ui.agvInfoName) ui.agvInfoName.textContent = name; const baseline = getBaseline(agvConfig || {}); const comps = Object.keys(baseline).length; const actions = Object.keys(agvConfig?.actions || {}).length; const lights = Object.values(baseline).filter((c) => c.type === "light").length; if (ui.statComponents) ui.statComponents.textContent = `组件 ${comps}`; if (ui.statActions) ui.statActions.textContent = `动作 ${actions}`; if (ui.statLights) ui.statLights.textContent = `灯光 ${lights}`; } function buildComponentCard(name, comp, { active = false, inDraft = false, compact = false } = {}) { const card = document.createElement("div"); card.className = `component-card${compact ? " compact" : ""}${active ? " active" : ""}${inDraft ? " in-draft" : ""}`; card.dataset.name = name; const icon = document.createElement("div"); icon.className = "component-card-icon"; icon.textContent = TYPE_ICONS[comp.type] || "▣"; const body = document.createElement("div"); body.className = "component-card-body"; const title = document.createElement("div"); title.className = "component-card-name"; title.textContent = name; const sub = document.createElement("div"); sub.className = "component-card-type"; const glb = comp.glbNode?.trim(); sub.textContent = glb ? `${typeTag(comp.type)} · ${glb}` : typeTag(comp.type); body.appendChild(title); body.appendChild(sub); card.appendChild(icon); card.appendChild(body); return card; } function draftCompForEdit(name) { const base = getBaseline(agvConfig)?.[name]; if (!base) return null; const slice = actionDraftComponents[name] || {}; return { ...base, type: slice.type ?? base.type, motion: slice.motion !== undefined ? slice.motion : base.motion, light: slice.light ?? base.light, action: slice.action ?? base.action, }; } function renderActionCompPick() { if (!agvConfig) return; const baseline = getBaseline(agvConfig); if (ui.actionCompPick) { const prev = ui.actionCompPick.value; ui.actionCompPick.innerHTML = ''; for (const [name, comp] of Object.entries(baseline)) { const opt = document.createElement("option"); opt.value = name; opt.textContent = `${name} · ${typeTag(comp.type)}`; ui.actionCompPick.appendChild(opt); } if (prev && baseline[prev]) ui.actionCompPick.value = prev; } if (!ui.actionCompPickList) return; ui.actionCompPickList.innerHTML = ""; const names = Object.keys(baseline); if (!names.length) { ui.actionCompPickList.innerHTML = '

无可用组件

'; return; } for (const name of names) { const comp = baseline[name]; const inDraft = !!actionDraftComponents[name]; const card = buildComponentCard(name, comp, { inDraft, compact: true }); card.addEventListener("click", () => { if (inDraft) selectActionEditComponent(name); else addActionDraftComponent(name); }); ui.actionCompPickList.appendChild(card); } } function renderActionSelectedList() { if (!ui.actionSelectedList) return; const names = Object.keys(actionDraftComponents); if (ui.actionSelectedCount) ui.actionSelectedCount.textContent = String(names.length); ui.actionSelectedList.innerHTML = ""; const hasEdit = !!actionEditName && !!actionDraftComponents[actionEditName]; if (ui.actionCfgBox) ui.actionCfgBox.classList.toggle("hidden", !hasEdit); if (ui.actionCfgPlaceholder) ui.actionCfgPlaceholder.classList.toggle("hidden", hasEdit); if (!names.length) { actionEditName = ""; renderActionCompPick(); return; } for (const name of names) { const base = getBaseline(agvConfig)?.[name]; const chip = document.createElement("div"); chip.className = `action-comp-chip${actionEditName === name ? " active" : ""}`; const label = document.createElement("span"); label.textContent = base ? `${name} · ${typeTag(base.type)}` : name; label.addEventListener("click", () => selectActionEditComponent(name)); const del = document.createElement("button"); del.type = "button"; del.className = "chip-del"; del.title = "移出动作"; del.textContent = "\u00d7"; del.addEventListener("click", (ev) => { ev.stopPropagation(); removeActionDraftComponent(name); }); chip.appendChild(label); chip.appendChild(del); ui.actionSelectedList.appendChild(chip); } renderActionCompPick(); } function selectActionEditComponent(name) { if (!actionDraftComponents[name]) return; actionEditName = name; const comp = draftCompForEdit(name); if (!comp) return; selectComponent(name, { skipReset: true }); if (ui.actionCfgTitle) { ui.actionCfgTitle.textContent = `${name} · ${typeTag(comp.type)}`; } syncActionEditorFields(comp); renderActionSelectedList(); } function draftEntryFromBaseline(name) { const base = getBaseline(agvConfig)?.[name]; if (!base) return null; const comp = draftCompForEdit(name) || base; const slice = pickActionFields(comp); const entry = Object.keys(slice).length ? cloneJson(slice) : { type: comp.type || base.type }; const t = entry.type || base.type; if ((t === "static" || t === "body") && actUi.actOpacity) { const opacity = parseNum(actUi.actOpacity.value, DEFAULT_TRANSPARENT_OPACITY); if (opacity < 1) entry.action = { ...(entry.action || {}), opacity }; } return entry; } function addActionDraftComponent(name, { focus = true } = {}) { if (!name || !getBaseline(agvConfig)?.[name]) return false; if (actionDraftComponents[name]) { if (focus) { flashSaved(`「${name}」已在动作中`, "warn"); selectActionEditComponent(name); } return false; } const entry = draftEntryFromBaseline(name); if (!entry) return false; actionDraftComponents[name] = entry; switchInspectorTab("actions"); if (focus) selectActionEditComponent(name); else renderActionSelectedList(); return true; } function addAllActionDraftComponents() { const baseline = getBaseline(agvConfig); const toAdd = Object.keys(baseline).filter((n) => !actionDraftComponents[n]); if (!toAdd.length) { flashSaved("全部基准组件已在动作中", "warn"); return; } for (const name of toAdd) addActionDraftComponent(name, { focus: false }); actionEditName = toAdd[0]; selectActionEditComponent(actionEditName); ensureDefaultActionLabel(); flashSaved(`已加入 ${toAdd.length} 个组件(当前共 ${Object.keys(actionDraftComponents).length} 个,无数量上限)`); } function applyActOpacityToAllDraftStatic() { const opacity = parseNum(actUi.actOpacity?.value, DEFAULT_TRANSPARENT_OPACITY); if (opacity >= 1) { flashSaved("请设置小于 1 的透明度", "warn"); return; } let n = 0; for (const name of Object.keys(actionDraftComponents)) { const base = getBaseline(agvConfig)?.[name]; const t = actionDraftComponents[name]?.type ?? base?.type; if (t !== "static" && t !== "body") continue; const patch = actionDraftComponents[name] || {}; actionDraftComponents[name] = { ...patch, type: t, action: { ...(patch.action || {}), opacity }, }; n++; } if (!n) { flashSaved("没有可应用的静态/车体组件", "warn"); return; } renderActionSelectedList(); ensureDefaultActionLabel(); startActionDraftPreview(); flashSaved(`已为 ${n} 个组件设置透明度 ${opacity}`); } function removeActionDraftComponent(name) { delete actionDraftComponents[name]; if (actionEditName === name) { const rest = Object.keys(actionDraftComponents); actionEditName = rest[0] || ""; if (actionEditName) selectActionEditComponent(actionEditName); else if (ui.actionCfgBox) ui.actionCfgBox.classList.add("hidden"); } renderActionSelectedList(); } function updateSaveActionButtonLabel() { if (ui.saveAsAction) { ui.saveAsAction.textContent = actionDraftEditId ? "更新动作" : "保存动作"; } } function isActionDraftDirty() { if (!Object.keys(actionDraftComponents).length && !actionDraftEditId) return false; if (!actionDraftEditId) return Object.keys(actionDraftComponents).length > 0; const saved = agvConfig?.actions?.[actionDraftEditId]; if (!saved) return true; const label = ui.actionLabel?.value?.trim() || ""; if (label !== (saved.label || actionDraftEditId)) return true; if (!jsonEqual(getDraftParameters(), mergeParameters(saved.parameters))) return true; return !jsonEqual(actionDraftComponents, saved.components || {}); } function openActionForEdit(actionId) { const action = agvConfig?.actions?.[actionId]; if (!action) return; if (actionDraftEditId !== actionId && isActionDraftDirty()) { const cur = actionDraftEditId ? (agvConfig.actions[actionDraftEditId]?.label || actionDraftEditId) : "当前草稿"; if (!window.confirm(`「${cur}」尚未保存,是否放弃并打开「${action.label || actionId}」?`)) return; } actionDraftEditId = actionId; actionDraftComponents = cloneJson(action.components || {}); actionDraftParameters = action.parameters ? cloneJson(action.parameters) : null; actionDraftPreviewUntil = 0; if (ui.actionLabel) ui.actionLabel.value = action.label || actionId; const names = Object.keys(actionDraftComponents); actionEditName = names[0] || ""; switchInspectorTab("actions"); renderActionList(); renderActionSelectedList(); if (actionEditName) selectActionEditComponent(actionEditName); else { if (ui.actionCfgBox) ui.actionCfgBox.classList.add("hidden"); if (ui.actionCfgPlaceholder) ui.actionCfgPlaceholder.classList.remove("hidden"); } updateSaveActionButtonLabel(); flashSaved(`已打开动作「${action.label || actionId}」`); } function clearActionDraft() { actionDraftComponents = {}; actionDraftParameters = null; actionEditName = ""; actionDraftEditId = ""; actionDraftPreviewUntil = 0; renderActionSelectedList(); renderActionList(); if (ui.actionLabel) ui.actionLabel.value = ""; updateSaveActionButtonLabel(); } function saveActionDraftComponentFromUi() { if (!actionEditName || !actionDraftComponents[actionEditName]) return false; const comp = draftCompForEdit(actionEditName); if (!comp) return false; applyUiToComponent(comp, actUi, { forAction: true }); const slice = pickActionFields(comp); if (!Object.keys(slice).length) { flashSaved("该组件无可保存的动作参数", "warn"); return false; } actionDraftComponents[actionEditName] = slice; renderActionSelectedList(); ensureDefaultActionLabel(); startActionDraftPreview(); const label = ui.actionLabel?.value?.trim() || DEFAULT_ACTION_LABEL; flashSaved(`已更新动作组件「${actionEditName}」,正在演示「${label}」`); return true; } function renderActionList() { if (!ui.actionList) return; ui.actionList.innerHTML = ""; if (!agvConfig?.actions || !Object.keys(agvConfig.actions).length) { ui.actionList.innerHTML = '

暂无已定义动作

'; renderAgvInfoBar(); return; } for (const [id, action] of Object.entries(agvConfig.actions)) { const n = Object.keys(action.components || {}).length; const card = document.createElement("div"); card.className = `action-card${actionDraftEditId === id ? " active" : ""}`; const body = document.createElement("div"); body.className = "action-card-body"; const title = document.createElement("div"); title.className = "action-card-title"; title.textContent = action.label || id; const meta = document.createElement("div"); meta.className = "action-card-meta"; meta.textContent = n ? `${n} 个组件` : "无组件"; body.appendChild(title); body.appendChild(meta); card.appendChild(body); const compHint = n ? Object.keys(action.components).join("、") : ""; card.title = compHint ? `组件:${compHint}` : "点击编辑"; card.addEventListener("click", () => openActionForEdit(id)); const del = document.createElement("button"); del.type = "button"; del.className = "action-card-del"; del.title = "删除动作"; del.textContent = "\u00d7"; del.addEventListener("click", (ev) => deleteAction(id, ev)); card.appendChild(del); ui.actionList.appendChild(card); } renderAgvInfoBar(); } async function saveCurrentAsAction() { if (!agvConfig) return; if (actionEditName) saveActionDraftComponentFromUi(); const names = Object.keys(actionDraftComponents); if (!names.length) { flashSaved("请先「加入动作」并配置至少一个组件", "warn"); return; } const label = ui.actionLabel?.value?.trim(); if (!label) { flashSaved("请填写显示名称", "warn"); return; } const id = await labelToActionId(label); if (!id) { flashSaved("无法从显示名称生成有效 ID", "warn"); return; } agvConfig.actions ||= {}; if (actionDraftEditId) { if (id !== actionDraftEditId && agvConfig.actions[id]) { flashSaved(`动作「${label}」已存在(ID: ${id}),不可覆盖。请先删除或改名。`, "warn"); return; } const payload = { label, uiClass: guessActionUiClass(id, label), parameters: cloneJson(getDraftParameters()), components: cloneJson(actionDraftComponents), }; if (id !== actionDraftEditId) delete agvConfig.actions[actionDraftEditId]; agvConfig.actions[id] = payload; } else { if (agvConfig.actions[id]) { flashSaved(`动作「${label}」已存在(ID: ${id}),不可覆盖。请先删除或点击该动作编辑。`, "warn"); return; } agvConfig.actions[id] = { label, uiClass: guessActionUiClass(id, label), parameters: cloneJson(getDraftParameters()), components: cloneJson(actionDraftComponents), }; } const disk = await persistConfigToDisk(); const verb = actionDraftEditId ? "更新" : "保存"; renderActionList(); clearActionDraft(); if (disk.ok) { flashSaved(`已${verb}动作「${label}」(${names.length} 个组件:${names.join("、")})并写入 agv/${disk.file}`); } else { flashSaved(`动作已${verb}到内存;未落盘:${disk.message}`, "warn"); } } async function deleteAction(actionId, ev) { ev?.stopPropagation(); ev?.preventDefault(); if (!agvConfig?.actions?.[actionId]) return; const action = agvConfig.actions[actionId]; const label = action.label || actionId; if (!window.confirm(`确定删除动作「${label}」?删除后不可恢复。`)) return; delete agvConfig.actions[actionId]; if (actionDraftEditId === actionId) clearActionDraft(); renderActionList(); const disk = await persistConfigToDisk(); if (disk.ok) flashSaved(`已删除动作「${label}」并写入 agv/${disk.file}`); else flashSaved(`已从内存删除动作「${label}」;未落盘:${disk.message}`, "warn"); } function flashSaved(msg, level = "ok") { if (!toastContainer) return; const toast = document.createElement("div"); toast.className = `toast toast-${level}`; const icon = level === "warn" ? "!" : level === "err" ? "✕" : "✓"; toast.innerHTML = `${icon}${msg}`; toastContainer.appendChild(toast); requestAnimationFrame(() => toast.classList.add("show")); clearTimeout(flashSaved._timer); flashSaved._timer = setTimeout(() => { toast.classList.remove("show"); setTimeout(() => toast.remove(), 280); }, 3200); } function normalizeColorInput(raw, fallback = "#00FF55") { let color = (raw || "").trim() || fallback; if (!color.startsWith("#")) color = `#${color}`; if (/^#[0-9a-fA-F]{3}$/.test(color)) { color = `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; } return color; } function toColorPickerValue(raw, fallback = "#00FF55") { const hex = normalizeColorInput(raw, fallback); return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex.toLowerCase() : fallback.toLowerCase(); } function readLightSpecFromUi(u = ui) { const effect = u.compLightEffect.value || "solid"; const spec = { color: normalizeColorInput(u.compLightColor.value), effect }; const v = parseNum(u.compLightIntensity.value, 100); if (effect.includes("flow") || effect === "blink_segment") spec.speed = v; else spec.intensity = v; if (effect === "blink" || effect === "strobe") { spec.hz = parseFloat(u.compLightHz.value || "4"); spec.intensityOn = v; spec.intensityOff = 0; } if (effect === "breath") { spec.period = parseFloat(u.compLightPeriod.value || "2"); spec.intensityMin = 0.2; spec.intensityMax = v; } return spec; } function fillLightUiFromSpec(spec, u = ui) { const s = { ...DEFAULT_LIGHT, ...spec }; u.compLightColor.value = toColorPickerValue(s.color, "#00FF55"); u.compLightEffect.value = s.effect || "solid"; const eff = u.compLightEffect.value; if (eff.includes("flow") || eff === "blink_segment") { u.compLightIntensity.value = s.speed ?? s.intensity ?? 100; } else if (eff === "breath") { u.compLightIntensity.value = s.intensityMax ?? s.intensity ?? 100; } else { u.compLightIntensity.value = s.intensity ?? s.intensityOn ?? 100; } u.compLightHz.value = s.hz ?? 4; u.compLightPeriod.value = s.period ?? 2; updateCompLightExtraRows(u); } function updateCompLightExtraRows(u = ui) { const eff = u.compLightEffect.value; u.compLightHzRow?.classList.toggle("hidden", eff !== "blink" && eff !== "strobe"); u.compLightPeriodRow?.classList.toggle("hidden", eff !== "breath"); } function readActionFromUi(type, u = ui) { const action = {}; if (type === "wheel") { action.wheelSpeedFactor = parseNum(u.wheelSpeedFactor.value, 1); action.wheelSteerAngleDeg = parseNum(u.wheelSteerAngleDeg?.value, 0); } else if (type === "static" || type === "body") { const opacity = parseNum(u.actOpacity?.value, DEFAULT_TRANSPARENT_OPACITY); if (opacity < 1) action.opacity = opacity; } else if (type === "actuator" || type === "sensor") { if (u.componentMotionRole.value === "lift") { action.liftStart = parseNum(u.liftStart.value, DEFAULT_LIFT_START); action.liftEnd = parseNum(u.liftEnd.value, DEFAULT_LIFT_END); } if (u.componentMotionRole.value === "camera") { action.camera = { scan: u.actionCamScan.checked, ledBreath: u.actionCamLedBreath.checked, ledColor: normalizeColorInput(u.actionCamLedColor.value, "#00AFFF"), }; } } else if (type === "safety") { action.emergency = { color: normalizeColorInput(u.actionEmerColor.value, "#FF0000"), effect: u.actionEmerEffect.value, hz: parseFloat(u.actionEmerHz.value || "4"), intensityOn: 1.2, }; } return Object.keys(action).length ? action : undefined; } function fillActionUi(comp, u = ui) { const a = comp.action || {}; u.wheelSpeedFactor.value = a.wheelSpeedFactor ?? a.speedFactor ?? 1; if (u.wheelSteerAngleDeg) u.wheelSteerAngleDeg.value = a.wheelSteerAngleDeg ?? 0; if (u.actOpacity) u.actOpacity.value = a.opacity ?? DEFAULT_TRANSPARENT_OPACITY; const { start, end } = getLiftRange(comp, agvConfig); u.liftStart.value = start; u.liftEnd.value = end; const cam = a.camera || {}; u.actionCamScan.checked = cam.scan !== false; u.actionCamLedBreath.checked = cam.ledBreath !== false; u.actionCamLedColor.value = toColorPickerValue(cam.ledColor, "#00AFFF"); const em = a.emergency || {}; u.actionEmerColor.value = toColorPickerValue(em.color, "#FF0000"); u.actionEmerEffect.value = em.effect || "blink"; u.actionEmerHz.value = em.hz ?? 4; } function updateActionPanels(u = ui) { const type = u.componentType.value; const role = u.componentMotionRole.value; const isCamera = type === "sensor" && role === "camera"; const isLift = (type === "actuator" || type === "sensor") && role === "lift"; const isStatic = type === "static" || type === "body"; u.panelStatic?.classList.toggle("hidden", !isStatic || u !== actUi); u.panelLight?.classList.toggle("hidden", type !== "light"); u.panelWheel?.classList.toggle("hidden", type !== "wheel"); u.panelMotion?.classList.toggle("hidden", type !== "actuator" && type !== "sensor"); u.panelCamera?.classList.toggle("hidden", !isCamera); u.panelEmergency?.classList.toggle("hidden", type !== "safety"); u.liftStartRow?.classList.toggle("hidden", !isLift); u.liftEndRow?.classList.toggle("hidden", !isLift); } function applyTypePreset(type, u = ui) { if (type === "light") fillLightUiFromSpec(DEFAULT_LIGHT, u); else if (type === "wheel") { u.wheelAxis.value = "x"; u.wheelSpeedFactor.value = "1"; if (u.wheelSteerAngleDeg) u.wheelSteerAngleDeg.value = "0"; } else if (type === "actuator") { u.componentMotionRole.value = "lift"; u.componentMotionKind.value = "translate"; u.componentMotionAxis.value = "y"; u.liftStart.value = String(DEFAULT_LIFT_START); u.liftEnd.value = String(DEFAULT_LIFT_END); } else if (type === "sensor") { u.componentMotionRole.value = "lidar"; u.componentMotionKind.value = "rotate"; u.componentMotionAxis.value = "y"; } else if (type === "safety") { u.actionEmerColor.value = toColorPickerValue("#FF0000", "#FF0000"); u.actionEmerEffect.value = "blink"; u.actionEmerHz.value = "4"; } updateActionPanels(u); } function syncActionEditorFields(comp) { if (!comp || !actUi.componentType) return; actUi.componentType.value = comp.type || "static"; if (comp.motion?.role && comp.type !== "light" && comp.type !== "wheel") { actUi.componentMotionRole.value = comp.motion.role; actUi.componentMotionKind.value = comp.motion.kind || "translate"; actUi.componentMotionAxis.value = comp.motion.axis || "y"; } if (comp.type === "wheel" || comp.motion?.role === "wheel") { const base = actionEditName ? getBaseline(agvConfig)?.[actionEditName] : null; const part = actionEditName ? bindPartConfigKey(actionEditName) : null; const meta = part ? (partBases[actionEditName] || computeWheelPartMeta(part)) : null; const spinAx = base?.motion?.axis || meta?.inferredSpinAxis || comp.motion?.axis || "z"; if (actUi.wheelAxisHint) { actUi.wheelAxisHint.textContent = `${spinAx.toUpperCase()}(基准配置,动作不可改)`; } } if (comp.light) fillLightUiFromSpec(comp.light, actUi); fillActionUi(comp, actUi); updateActionPanels(actUi); if (ui.actionCfgBox) ui.actionCfgBox.classList.remove("hidden"); } const COMPONENT_ALIASES = { 车壳: "主体", 外壳: "主体", 壳体: "主体" }; function componentMatchesFilter(name, comp) { if (!componentFilterText) return true; const q = componentFilterText.toLowerCase(); const glb = (comp.glbNode || "").trim().toLowerCase(); for (const [alias, target] of Object.entries(COMPONENT_ALIASES)) { if (q.includes(alias) && (glb.includes(target) || name.toLowerCase().includes(target))) return true; } return name.toLowerCase().includes(q) || glb.includes(q); } function isActionsTabActive() { return ui.tabPanelActions && !ui.tabPanelActions.classList.contains("hidden"); } function selectComponent(name, { skipReset = false } = {}) { if (!name || !getBaseline(agvConfig)?.[name]) return; if (!skipReset && name !== selectedComponentName) resetPartsToOrigin(); selectedComponentName = name; if (ui.actionCompPick) ui.actionCompPick.value = name; syncComponentEditorFields(); highlightSelectedComponent(); renderComponentList(); renderAgvInfoBar(); } /** 3D 点选:基准 Tab 更新基准选中;动作 Tab 加入/选中动作草稿并打开参数配置 */ function selectComponentForActiveTab(name) { if (!name || !getBaseline(agvConfig)?.[name]) return; actionDraftPreviewUntil = 0; if (isActionsTabActive()) { if (actionDraftComponents[name]) selectActionEditComponent(name); else addActionDraftComponent(name, { focus: true }); return; } selectComponent(name); } function renderComponentList() { if (!ui.componentList) return; const prev = selectedComponentName; ui.componentList.innerHTML = ""; const baseline = getBaseline(agvConfig); if (!Object.keys(baseline).length) return; const entries = Object.entries(baseline).filter(([n, c]) => componentMatchesFilter(n, c)); if (!entries.length) { ui.componentList.innerHTML = '

无匹配组件

'; selectedComponentName = ""; compMetaEl.textContent = "—"; renderAgvInfoBar(); return; } if (!prev || !entries.some(([n]) => n === prev)) { selectedComponentName = entries[0][0]; } else { selectedComponentName = prev; } for (const [name, comp] of entries) { const card = buildComponentCard(name, comp, { active: selectedComponentName === name }); card.addEventListener("click", () => selectComponent(name)); ui.componentList.appendChild(card); } syncComponentEditorFields(); highlightSelectedComponent(); renderAgvInfoBar(); } function syncComponentEditorFields() { if (!selectedComponentName) { compMetaEl.textContent = "-"; return; } const comp = getComponentForEditor(selectedComponentName); if (!comp) { compMetaEl.textContent = "-"; return; } const base = getBaseline(agvConfig)[selectedComponentName]; const glbLabel = base?.glbNode || comp.glbNode || "-"; const part = bindPartConfigKey(selectedComponentName); const suggest = !part ? suggestGlbNodeNames(glbLabel) : []; const bindHint = part ? `已绑定 · 场景节点 ${part.name || glbLabel}` : `未找到 GLB 节点「${glbLabel}」${suggest.length ? ` · 近似:${suggest.map((s) => `${s}`).join(" ")}` : ""} · 请运行 python analyze_agv.py 或核对 glbNode`; let axisHint = ""; if (part) { const meta = partBases[selectedComponentName] || computeWheelPartMeta(part); const p = meta.spinPivotLocal; const off = p && p.lengthSq() > 1e-8 ? `(${p.x.toFixed(3)}, ${p.y.toFixed(3)}, ${p.z.toFixed(3)})` : null; const spinAx = part.userData?._wheelSpinAxis || meta.inferredSpinAxis || "?"; const isWheel = base?.type === "wheel" || base?.motion?.role === "wheel"; if (isWheel && off) { axisHint = `
坐标轴在轮心(相对原点偏移 ${off})· 自转绕 ${spinAx.toUpperCase()}(红X 绿Y 蓝Z)`; } else if (off) { axisHint = `
坐标轴:原点 + 几何中心(偏移 ${off})`; } else { axisHint = isWheel ? `
自转绕 ${spinAx.toUpperCase()} 轴` : ""; } } compMetaEl.innerHTML = `${selectedComponentName} \u2192 GLB: ${glbLabel}
${bindHint}${axisHint}`; ui.componentType.value = comp.type || "static"; if (comp.motion?.role && comp.type !== "light" && comp.type !== "wheel") { ui.componentMotionRole.value = comp.motion.role; ui.componentMotionKind.value = comp.motion.kind || "translate"; ui.componentMotionAxis.value = comp.motion.axis || "y"; } if (comp.type === "wheel" || comp.motion?.role === "wheel") { const meta = part ? (partBases[selectedComponentName] || computeWheelPartMeta(part)) : null; ui.wheelAxis.value = comp.motion?.axis || meta?.inferredSpinAxis || "x"; } if (comp.light) fillLightUiFromSpec(comp.light); fillActionUi(comp); updateActionPanels(); } function resetComponentToOrigin(name) { if (!baseTransforms || !getBaseline(agvConfig)?.[name]) return; clearWheelSpinState(name); restoreNodeOpacity(parts[name]); const glbName = getBaseline(agvConfig)[name].glbNode || name; if (parts[name] && getBaseTransform(baseTransforms, glbName)) { resetPart(parts[name], baseTransforms, glbName); resetWheelSpinGroup(name); } } /** 将表单写入组件对象(不落盘) */ function applyUiToComponent(comp, u = ui, { forAction = false } = {}) { const type = u.componentType.value; comp.type = type; delete comp.subtype; if (type === "light") { comp.motion = { role: "light", kind: "emissive" }; comp.light = readLightSpecFromUi(u); delete comp.action; } else if (type === "wheel") { if (!forAction) { comp.motion = { role: "wheel", kind: "rotate", axis: u.wheelAxis.value || "x" }; } comp.action = { ...(comp.action || {}), wheelSpeedFactor: parseNum(u.wheelSpeedFactor.value, 1), wheelSteerAngleDeg: parseNum(u.wheelSteerAngleDeg?.value, 0), }; delete comp.light; } else if (type === "static" || type === "body") { comp.motion = null; delete comp.light; if (u.actOpacity) { comp.action = readActionFromUi(type, u); if (!comp.action) delete comp.action; } else { delete comp.action; } } else if (type === "safety") { comp.motion = { role: "emergency", kind: "emissive" }; comp.action = readActionFromUi(type, u); delete comp.light; } else if (type === "actuator" || type === "sensor") { comp.motion = { role: u.componentMotionRole.value, kind: u.componentMotionKind.value, axis: u.componentMotionAxis.value, }; comp.action = readActionFromUi(type, u); delete comp.light; } } /** 提取可参与动作覆盖的字段(轮子自转轴仅由基准决定,不写入动作) */ function pickActionFields(comp) { const patch = {}; if (!comp) return patch; if (comp.type && comp.type !== "static" && comp.type !== "body") patch.type = comp.type; if (comp.motion && !isWheelComp(comp)) patch.motion = cloneJson(comp.motion); if (comp.light) patch.light = cloneJson(comp.light); if (comp.action) patch.action = cloneJson(comp.action); return patch; } function saveComponentFromUi() { const baseline = getWritableComponents(); if (!selectedComponentName || !baseline?.[selectedComponentName]) return false; if (!baseline[selectedComponentName]) { baseline[selectedComponentName] = cloneJson(getBaseline(agvConfig)[selectedComponentName]); } resetComponentToOrigin(selectedComponentName); applyUiToComponent(baseline[selectedComponentName]); agvRuntime = buildRuntime(withComponents(agvConfig, baseline), parts); renderComponentList(); renderAgvInfoBar(); return true; } async function fetchConfig(entry) { const cfgFile = entry.config || `${entry.id}.json`; const res = await fetch(`${AGV_BASE}${cfgFile}?t=${Date.now()}`); if (!res.ok) throw new Error(`缺少配置 ${cfgFile},请运行: python analyze_agv.py ${entry.id}`); return res.json(); } /** persist config to agv/.json via serve.py (not file://) */ async function persistConfigToDisk() { if (!agvConfig || !currentModel) { return { ok: false, message: "未加载模型" }; } const cfgFile = currentModel.config || `${currentModel.id}.json`; try { const res = await fetch("/api/save-config", { method: "POST", headers: { "Content-Type": "application/json;charset=utf-8" }, body: JSON.stringify({ file: cfgFile, config: prepareConfigForSave(agvConfig) }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { throw new Error(data.error || `HTTP ${res.status}`); } return { ok: true, file: cfgFile }; } catch (e) { return { ok: false, message: `${e.message}。请用「python serve.py」打开 http://127.0.0.1:8765/viewer/`, }; } } function applyConfigToUi() { if (!agvConfig) return; renderComponentList(); renderActionCompPick(); renderActionSelectedList(); renderActionList(); renderAgvInfoBar(); updateSaveActionButtonLabel(); } function previewWheelSpeed() { return isActionDraftPreviewActive() ? getDraftParameters().defaultWheelSpeed : DEFAULT_PARAMETERS.defaultWheelSpeed; } function resetPartsToOrigin() { if (!baseTransforms || !agvConfig) return; for (const [configKey, comp] of Object.entries(getBaseline(agvConfig))) { restoreNodeOpacity(parts[configKey]); const glbName = comp.glbNode || configKey; if (parts[configKey] && getBaseTransform(baseTransforms, glbName)) { resetPart(parts[configKey], baseTransforms, glbName); if (comp.type === "wheel" || comp.motion?.role === "wheel") { const node = parts[configKey]; const spinAxis = resolveWheelSpinAxis(comp, partBases[configKey], node, comp.motion?.axis); syncWheelSteerState(configKey, node, comp?.action?.wheelSteerAxis || defaultSteerAxis(spinAxis)); } resetWheelSpinGroup(configKey); clearWheelSpinState(configKey); } } partBases = captureBases(parts, withComponents(agvConfig, getBaseline(agvConfig))); } function focusCameraOnPart(part) { if (!part) return; part.updateWorldMatrix(true, true); _focusBox.setFromObject(part); if (_focusBox.isEmpty()) return; _focusBox.getCenter(_focusCenter); _focusBox.getSize(_focusSize); const radius = Math.max(_focusSize.length() * 0.5, 0.04); const dist = Math.max(radius * 2.8, 0.35); _focusDir.copy(camera.position).sub(controls.target); if (_focusDir.lengthSq() < 1e-8) _focusDir.set(1, 0.55, 1); _focusDir.normalize(); controls.target.copy(_focusCenter); camera.position.copy(_focusCenter).addScaledVector(_focusDir, dist); controls.update(); } function disposeAxesHelper(axes) { if (!axes) return; axes.parent?.remove(axes); axes.geometry?.dispose(); axes.material?.dispose(); } function computeSelectionAxisSize(part) { part.updateWorldMatrix(true, true); _focusBox.setFromObject(part); if (_focusBox.isEmpty()) return 0.15; _focusBox.getSize(_focusSize); return THREE.MathUtils.clamp(_focusSize.length() * 0.4, 0.08, 0.6); } function attachAxesHelper(part, size, { opacity = 1 } = {}) { const axes = new THREE.AxesHelper(size); axes.renderOrder = 1001; if (axes.material) { axes.material.depthTest = false; axes.material.transparent = opacity < 1; axes.material.opacity = opacity; } part.add(axes); return axes; } function clearSelectionVisuals() { if (selectionHelper) { scene.remove(selectionHelper); selectionHelper = null; } disposeAxesHelper(selectionAxesOrigin); selectionAxesOrigin = null; disposeAxesHelper(selectionAxesCenter); selectionAxesCenter = null; } function highlightSelectedComponent() { clearSelectionVisuals(); if (!selectedComponentName) return; const part = bindPartConfigKey(selectedComponentName); if (!part) { const glbLabel = getBaseline(agvConfig)?.[selectedComponentName]?.glbNode || selectedComponentName; flashSaved(`组件「${selectedComponentName}」未绑定到 GLB 节点「${glbLabel}」,3D 无法高亮`, "warn"); return; } part.updateWorldMatrix(true, true); const axisSize = computeSelectionAxisSize(part); const spinGroup = part.userData?._wheelSpinGroup; const isWheelPivot = spinGroup && spinGroup !== part; if (isWheelPivot) { // 轮子:只显示几何中心自转轴(与 spinGroup 一致,会随轮子一起转) selectionAxesCenter = attachAxesHelper(spinGroup, axisSize); } else { selectionAxesOrigin = attachAxesHelper(part, axisSize); const meshMeta = partBases[selectedComponentName] || computeWheelPartMeta(part); const pivot = meshMeta.spinPivotLocal; if (pivot && pivot.lengthSq() > 1e-8) { selectionAxesCenter = attachAxesHelper(part, axisSize * 0.85, { opacity: 0.85 }); selectionAxesCenter.position.copy(pivot); } } selectionHelper = new THREE.BoxHelper(part, 0xffcc00); selectionHelper.renderOrder = 999; if (selectionHelper.material) { selectionHelper.material.depthTest = false; selectionHelper.material.transparent = true; selectionHelper.material.opacity = 0.95; } selectionHelper.update(); scene.add(selectionHelper); focusCameraOnPart(part); } ui.openConfig?.addEventListener("click", () => { const cfg = currentModel?.config || (currentModel?.id ? `${currentModel.id}.json` : null); if (cfg) window.open(`${AGV_BASE}${cfg}`, "_blank"); }); ui.exportConfig?.addEventListener("click", async () => { if (!agvConfig || !currentModel) return; const disk = await persistConfigToDisk(); const blob = new Blob([JSON.stringify(prepareConfigForSave(agvConfig), null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${currentModel.id || "agv"}.json`; a.click(); URL.revokeObjectURL(a.href); if (disk.ok) flashSaved(`已写入 agv/${disk.file},并已下载备份`); else flashSaved(`已下载备份;未写入磁盘:${disk.message}`, "warn"); }); ui.componentFilter.addEventListener("input", () => { componentFilterText = ui.componentFilter.value.trim(); renderComponentList(); }); ui.tabBaseline?.addEventListener("click", () => switchInspectorTab("baseline")); ui.tabActions?.addEventListener("click", () => switchInspectorTab("actions")); ui.componentType.addEventListener("change", () => { resetPartsToOrigin(); applyTypePreset(ui.componentType.value); }); ui.componentMotionRole.addEventListener("change", updateActionPanels); ui.compLightEffect.addEventListener("change", updateCompLightExtraRows); ui.wheelAxis.addEventListener("change", resetPartsToOrigin); ui.actionLabel?.addEventListener("input", renderAgvInfoBar); ui.addActionComp?.addEventListener("click", () => { const name = ui.actionCompPick?.value; if (!name) { flashSaved("请先选择要加入的组件", "warn"); return; } addActionDraftComponent(name); }); ui.addAllActionComp?.addEventListener("click", () => addAllActionDraftComponents()); ui.applyOpacityToAll?.addEventListener("click", () => applyActOpacityToAllDraftStatic()); ui.saveActionDraftComp?.addEventListener("click", () => saveActionDraftComponentFromUi()); ui.clearActionDraft?.addEventListener("click", () => { if (!Object.keys(actionDraftComponents).length) return; if (window.confirm("清空当前动作草稿?")) clearActionDraft(); }); if (ui.saveAsAction) { ui.saveAsAction.addEventListener("click", () => saveCurrentAsAction()); } actUi.componentType?.addEventListener("change", () => applyTypePreset(actUi.componentType.value, actUi)); actUi.componentMotionRole?.addEventListener("change", () => updateActionPanels(actUi)); actUi.compLightEffect?.addEventListener("change", () => updateCompLightExtraRows(actUi)); ui.syncBaseline?.addEventListener("click", async () => { if (!agvConfig) return; if (selectedComponentName) saveComponentFromUi(); const disk = await persistConfigToDisk(); const n = Object.keys(getBaseline(agvConfig)).length; if (disk.ok) flashSaved(`baseline + actions 已落盘 agv/${disk.file}(${n} 个基准组件)`); else flashSaved(`未落盘:${disk.message}`, "warn"); }); ui.applyComponent.addEventListener("click", async () => { if (!saveComponentFromUi()) return; const comp = getBaseline(agvConfig)[selectedComponentName]; const tag = typeTag(comp.type); const role = comp.motion?.role; const previewHint = role === "lift" ? ",顶升往复预览中" : role === "camera" ? ",相机动作预览中" : comp.motion?.kind === "rotate" ? ",旋转往复预览中" : comp.type === "wheel" ? ",轮子旋转预览中" : comp.type === "light" ? ",灯光效果预览中" : ""; const disk = await persistConfigToDisk(); statusEl.innerHTML = `${"已写入基准"} ${selectedComponentName} (${tag})`; if (disk.ok) { flashSaved(`基准已更新:${selectedComponentName}${previewHint},已写入 agv/${disk.file}`); } else { flashSaved(`已更新内存;刷新会丢失。${disk.message}`, "warn"); } }); function disposeObject3D(root) { root.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mats = Array.isArray(o.material) ? o.material : [o.material]; mats.forEach((m) => { if (!m) return; Object.keys(m).forEach((k) => { const tex = m[k]; if (tex?.isTexture) tex.dispose(); }); m.dispose(); }); }); } function clearModel() { clearSelectionVisuals(); if (sceneRoot) { scene.remove(sceneRoot); disposeObject3D(sceneRoot); sceneRoot = null; } Object.keys(parts).forEach((k) => delete parts[k]); _glbNodesIndex = new Map(); _glbPartsByName = {}; Object.keys(_wheelSpinAngles).forEach((k) => delete _wheelSpinAngles[k]); Object.keys(_wheelSteerAngles).forEach((k) => delete _wheelSteerAngles[k]); _materialBaseOpacity.clear(); _meshVisualState.clear(); glbToConfigKey = {}; agvRoot = null; baseTransforms = null; agvConfig = null; agvRuntime = null; partBases = {}; selectedComponentName = ""; clearActionDraft(); } function setupLoadedModel(loaded, meta, config) { const needMigrate = Boolean( config.states || config.components || Object.values(config.actions || {}).some((a) => a.overrides), ); normalizeConfig(config); agvConfig = config; const baselineCfg = withComponents(agvConfig, getBaseline(agvConfig)); sceneRoot = loaded.sceneRoot; scene.add(sceneRoot); agvRoot = loaded.modelRoot; _glbPartsByName = loaded.parts || {}; _glbNodesIndex = buildGlbNodesIndex(loaded.sceneRoot, _glbPartsByName); const mapped = buildPartsFromConfig(baselineCfg); Object.assign(parts, mapped.parts); glbToConfigKey = mapped.glbToConfigKey; if (mapped.missing?.length) { console.warn("未绑定的基准组件:", mapped.missing); } installAllWheelPivots(baselineCfg); baseTransforms = loaded.baseTransforms; agvRuntime = buildRuntime(baselineCfg, parts); partBases = captureBases(parts, baselineCfg); applyConfigToUi(); if (needMigrate) { persistConfigToDisk().then((disk) => { if (disk.ok) flashSaved(`已迁移为 baseline + actions 结构并写入 agv/${disk.file}`); }); } sceneRoot.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; ensureStandardMaterial(o); const mat = Array.isArray(o.material) ? o.material[0] : o.material; if (mat && mat.toneMapped !== false) { mat.envMapIntensity = 1.1; } } }); const total = Object.keys(getBaseline(config)).length; const bound = Object.keys(parts).length; const miss = bound < total ? ` · ${total - bound} 个未绑定` : ""; statusEl.innerHTML = `已加载 ${meta.name} · ${meta.file} · 绑定 ${bound}/${total}${miss}`; renderAgvInfoBar(); } async function loadAgvModel(entry) { if (!entry?.file) return; clearModel(); currentModel = entry; statusEl.innerHTML = `加载 ${entry.file}...`; let config; try { config = await fetchConfig(entry); } catch (e) { statusEl.innerHTML = `${e.message}`; return; } loader.load( `${AGV_BASE}${entry.file}`, (gltf) => { const loaded = loadBlenderGLB(gltf, entry.root || config.model?.root || null); setupLoadedModel(loaded, entry, config); }, undefined, (err) => { statusEl.innerHTML = `${"加载失败"}
${err.message}`; }, ); } function fillModelSelect(models, selectId) { ui.agvModel.innerHTML = ""; if (!models.length) { ui.agvModel.innerHTML = ``; return; } models.forEach((m) => { const opt = document.createElement("option"); opt.value = m.id; opt.textContent = m.name || m.id; ui.agvModel.appendChild(opt); }); const idx = selectId ? models.findIndex((m) => m.id === selectId) : 0; ui.agvModel.selectedIndex = Math.max(0, idx); loadAgvModel(models[ui.agvModel.selectedIndex]); } async function initModelList(keepSelection = false) { const prev = ui.agvModel.value; try { const res = await fetch(`${AGV_BASE}manifest.json?t=${Date.now()}`); if (!res.ok) throw new Error(`无法读取 manifest (${res.status})`); manifest = await res.json(); fillModelSelect(manifest.models || [], keepSelection ? prev : null); } catch (e) { statusEl.innerHTML = `${e.message}
${"请从 agv_glb 目录启动 HTTP 服务"}`; ui.agvModel.innerHTML = ``; } } ui.agvModel.addEventListener("change", () => { const entry = manifest.models.find((m) => m.id === ui.agvModel.value); if (entry) loadAgvModel(entry); }); ui.refreshList?.addEventListener("click", () => initModelList(true)); renderer.domElement.addEventListener("pointerdown", (ev) => { if (!sceneRoot || !agvConfig) return; const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const meshes = []; sceneRoot.traverse((o) => { if (o.isMesh) meshes.push(o); }); const hits = raycaster.intersectObjects(meshes, false); if (!hits.length) return; const name = findConfigKeyFromObject(hits[0].object, glbToConfigKey); if (!name) return; selectComponentForActiveTab(name); }); initModelList(); const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const dt = clock.getDelta(); const t = clock.getElapsedTime(); if (actionDraftPreviewUntil > 0 && t >= actionDraftPreviewUntil) { actionDraftPreviewUntil = 0; resetPartsToOrigin(); } if (agvRoot && agvConfig && agvRuntime) { const preview = isActionDraftPreviewActive(t) ? buildActionDraftConfig() : withComponents(agvConfig, getBaseline(agvConfig)); applyLift(preview, agvRuntime, parts, partBases, computeLiftDistancesBaseline(preview, agvRuntime, t)); applyRotateMotionPreview(preview, agvRuntime, parts, partBases, t); applyLights(preview, agvRuntime, t); applyWheels(preview, agvRuntime, dt, parts, previewWheelSpeed()); applyEmergency(preview, agvRuntime, t, parts); applyCamera(preview, agvRuntime, t, parts, partBases); applyComponentOpacity(preview); } if (selectionHelper) selectionHelper.update(); controls.update(); renderer.render(scene, camera); } animate(); window.addEventListener("resize", resizeCanvas);