2485 lines
90 KiB
JavaScript
2485 lines
90 KiB
JavaScript
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} 动作草稿级 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 = '<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);
|