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

1277 lines
41 KiB
JavaScript

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