1277 lines
41 KiB
JavaScript
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);
|
|
});
|