按关节槽位驱动 13 个五轴案例的三维模型
关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性, 重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位 (max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存 GLB 与既有测试不受影响。 13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建, 可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个 关节,槽位数与运行目录的 joint_count 逐条一致。 新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键: 坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换 案例时自动装载对应模型,失败时保留内置模型并显式提示。 消除前端五关节截断:visual 适配器的 joints 改为数组并新增 jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的 machine id 查找机型。 管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部 可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径 不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的 限位保持 null 而不编造。 修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录( DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下 第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。 验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0; typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、 双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。 证据在 coverage/glb-machine-manager/physical-drive/。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
84
web/workspace/machine-visual-bindings.mjs
Normal file
84
web/workspace/machine-visual-bindings.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user