// Case id -> GLB node binding. Pure functions shared by the manager dialog and // the runtime view. Motion values always come from LinuxCNC WASM joints; this // module only says which scene node receives which joint slot. import bindings from "./machine-visual-bindings.json" with { type: "json" }; import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs"; export const VISUAL_BINDINGS_SCHEMA = "linuxcnc.machine-visual-bindings/1"; const SLOT_KINDS = new Set(["prismatic", "revolute", "strut"]); const SLOT_SOURCES = new Set(["kinematics", "extra", "duplicate", "passive"]); const AXIS_LETTER = /^[XYZABCUVW]$/; const fail = (message) => { throw new Error(`视觉绑定配置无效:${message}`); }; if (bindings.schema !== VISUAL_BINDINGS_SCHEMA) fail("schema"); if (typeof bindings.glbRoot !== "string" || !bindings.glbRoot) fail("glbRoot"); if (!Array.isArray(bindings.cases) || !bindings.cases.length) fail("cases"); const catalog = new Map(MACHINE_DEFINITIONS.map((machine) => [machine.id, machine])); const byId = new Map(); const byEntrypoint = new Map(); for (const item of bindings.cases) { if (!item.machineId || byId.has(item.machineId)) fail(`machineId 缺失或重复:${item.machineId}`); const machine = catalog.get(item.machineId); if (!machine) fail(`案例目录中没有 ${item.machineId}`); if (item.entrypoint !== machine.entrypoint) fail(`${item.machineId} entrypoint 与运行目录不一致`); if (item.coordinates !== machine.coordinates) fail(`${item.machineId} coordinates 与运行目录不一致`); if (!Array.isArray(item.jointOrder) || item.jointOrder.length !== machine.joint_count) fail(`${item.machineId} 槽位数 ${item.jointOrder?.length} 与 joint_count ${machine.joint_count} 不符`); if (!Array.isArray(item.bindings) || item.bindings.length !== item.jointOrder.length) fail(`${item.machineId} 绑定数量与槽位数不符`); const keys = new Set(), bound = new Set(); for (const [position, slot] of item.jointOrder.entries()) { if (slot.index !== position) fail(`${item.machineId} 槽位索引不连续`); if (typeof slot.key !== "string" || !slot.key || keys.has(slot.key)) fail(`${item.machineId} 槽位 key 缺失或重复`); keys.add(slot.key); if (!SLOT_KINDS.has(slot.kind)) fail(`${item.machineId}/${slot.key} 类型`); if (slot.unit !== (slot.kind === "revolute" ? "deg" : "mm")) fail(`${item.machineId}/${slot.key} 单位`); if (slot.axis !== null && !AXIS_LETTER.test(slot.axis)) fail(`${item.machineId}/${slot.key} 轴字母`); if (!SLOT_SOURCES.has(slot.source)) fail(`${item.machineId}/${slot.key} 来源`); } for (const binding of item.bindings) { if (!Number.isInteger(binding.slot) || binding.slot < 0 || binding.slot >= item.jointOrder.length) fail(`${item.machineId} 绑定槽位越界`); if (bound.has(binding.slot)) fail(`${item.machineId} 槽位 ${binding.slot} 重复绑定`); bound.add(binding.slot); if (typeof binding.node !== "string" || !binding.node) fail(`${item.machineId} 槽位 ${binding.slot} 缺少节点名`); if (!["x", "y", "z"].includes(binding.direction)) fail(`${item.machineId} 槽位 ${binding.slot} 方向`); if (![1, -1].includes(binding.sign)) fail(`${item.machineId} 槽位 ${binding.slot} 正负向`); } for (const slot of item.jointOrder) if (slot.source !== "passive" && !bound.has(slot.index)) fail(`${item.machineId} 槽位 ${slot.key} 未绑定节点`); const zeros = item.calibration?.physicalZero; if (!Array.isArray(zeros) || zeros.length !== item.jointOrder.length || !zeros.every(Number.isFinite)) fail(`${item.machineId} 物理零点`); byId.set(item.machineId, Object.freeze(item)); byEntrypoint.set(item.entrypoint, byId.get(item.machineId)); } export const VISUAL_BINDINGS = Object.freeze({ schema: VISUAL_BINDINGS_SCHEMA, glbRoot: bindings.glbRoot, cases: Object.freeze([...byId.values()]) }); export const glbPath = (binding) => binding ? `${bindings.glbRoot}${binding.glb.default}` : null; export const visualBinding = (value) => byId.get(String(value ?? "")) ?? byEntrypoint.get(String(value ?? "")) ?? null; export const hasVisualBinding = (value) => Boolean(visualBinding(value)); export const bindingGlbPath = (value) => glbPath(visualBinding(value)); // Build a v2 joint model from a binding plus the GLB node ids that carry // userData.linuxcncNodeId. Fails per slot instead of guessing a node. export function bindingJointModel(binding, nodeIds) { if (!binding) fail("缺少绑定"); const ids = new Set(nodeIds); const slotMap = binding.jointOrder.map((slot) => { const row = binding.bindings.find((item) => item.slot === slot.index); // Node identity is userData.linuxcncNodeId, not the display name: two // machines may both call their node "x". const id = row?.nodeId ?? row?.node ?? null; return id && ids.has(id) ? id : null; }); const missing = binding.jointOrder.filter((slot, index) => slotMap[index] === null && slot.source !== "passive").map((slot) => slot.key); return { schema: "linuxcnc.joint-model/2", machineId: binding.machineId, coordinates: binding.coordinates, units: { length: "mm", angle: "deg", gltfLength: "m" }, controller: { source: "linuxcnc-wasm.visual.joints", jointOrder: binding.jointOrder.map((slot) => ({ ...slot })) }, calibration: { cOriginMm: [...binding.calibration.cOriginMm], tiltOffsetMm: [...binding.calibration.tiltOffsetMm], physicalZero: [...binding.calibration.physicalZero] }, slotMap, missing, }; }