Files
Migu2.0/SimpleLite/model/agv_glb/viewer/app.js
T
ArtoriasWu 15405ba114 新增 WMS 搬运规则与任务管理
支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
2026-06-25 11:02:22 +08:00

2485 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, object>} 动作草稿:组件名 -> 动作参数切片 */
let actionDraftComponents = {};
/** @type {object|null} 动作草稿级 parametersnull 表示使用默认 */
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 = '<option value=""></option>';
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 = '<p class="hint">无可用组件</p>';
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 = '<p class="hint">暂无已定义动作</p>';
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 = `<span class="toast-icon">${icon}</span><span>${msg}</span>`;
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 = '<p class="hint">无匹配组件</p>';
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
? `<span class="ok">已绑定</span> · 场景节点 <code>${part.name || glbLabel}</code>`
: `<span class="err">未找到 GLB 节点「${glbLabel}」</span>${suggest.length ? ` · 近似:${suggest.map((s) => `<code>${s}</code>`).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 = `<br>坐标轴在轮心(相对原点偏移 ${off})· 自转绕 <code>${spinAx.toUpperCase()}</code>(红X 绿Y 蓝Z`;
} else if (off) {
axisHint = `<br>坐标轴:原点 + 几何中心(偏移 ${off}`;
} else {
axisHint = isWheel ? `<br>自转绕 <code>${spinAx.toUpperCase()}</code> 轴` : "";
}
}
compMetaEl.innerHTML = `<strong>${selectedComponentName}</strong> \u2192 GLB: <code>${glbLabel}</code><br>${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/<id>.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 = `<span class="ok">${"已写入基准"}</span> ${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 = `<span class="ok">已加载 ${meta.name}</span> · ${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 = `<span class="err">${e.message}</span>`;
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 = `<span class="err">${"加载失败"}</span><br>${err.message}`; },
);
}
function fillModelSelect(models, selectId) {
ui.agvModel.innerHTML = "";
if (!models.length) {
ui.agvModel.innerHTML = `<option value="">${"(无 GLB"}</option>`;
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 = `<span class="err">${e.message}</span><br>${"请从 agv_glb 目录启动 HTTP 服务"}`;
ui.agvModel.innerHTML = `<option value="">${"加载失败"}</option>`;
}
}
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);