关节契约扩为槽位制:以 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>
159 lines
9.3 KiB
JavaScript
159 lines
9.3 KiB
JavaScript
// GLB 关节识别与 TRT 标定推导的单测(纯 Node,不需要浏览器)。
|
||
// 运行:node web/workspace/test_glb_axis_naming.mjs
|
||
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
import { dirname, resolve } from "node:path";
|
||
import { classifyAxisNodeName, deriveTrtCalibration, selectAxisNodes } from "./glb-axis-naming.mjs";
|
||
import managerSettings from "./machine-manager-settings.json" with { type: "json" };
|
||
|
||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||
const profileOf = (coordinates) => managerSettings.profiles.find((profile) => profile.coordinates === coordinates) ?? null;
|
||
|
||
// ---------------------------------------------------------------- 命名规则
|
||
const NAMING_CASES = [
|
||
// [节点名, 期望轴, 期望等级, 期望优先级, 说明]
|
||
["JT_X", "X", "high", 0, "JT_ 精确名"],
|
||
["JT_C", "C", "high", 0, "JT_ 精确名"],
|
||
["X_AXIS", "X", "medium", 1, "轴字母 + AXIS"],
|
||
["AXIS_Z", "Z", "medium", 1, "AXIS + 轴字母"],
|
||
["b_axis", "B", "medium", 1, "轴字母 + AXIS"],
|
||
["b_axis_bearing", "B", "low", 4, "同轴零件:优先级低于 b_axis"],
|
||
["b_axis_axis", "B", "low", 4, "同轴零件:优先级低于 b_axis"],
|
||
["a_axis_bearings", "A", "low", 4, "同轴零件:优先级低于 a_outer_table"],
|
||
["x_slide", "X", "medium", 2, "直线滑台"],
|
||
["y_saddle", "Y", "medium", 2.2, "Y 鞍座"],
|
||
["y_saddle_casting", "Y", "medium", 2.22, "铸件:优先级低于 y_saddle"],
|
||
["z_ram", "Z", "medium", 2.8, "Z 滑枕"],
|
||
["moving_x_table", "X", "medium", 2.5, "轴字母在部件词之前"],
|
||
["a_outer_table", "A", "medium", 2.5, "外转台 = A"],
|
||
["b_inner_table", "B", "medium", 2.5, "内转台 = B"],
|
||
["c_table", "C", "medium", 2.5, "回转工作台 = C"],
|
||
["b_trunnion", "B", "medium", 2.3, "B 轴摇篮"],
|
||
["a_tilt_frame", "A", "medium", 2.4, "A 轴倾转架"],
|
||
["w_tool_direction", "W", "medium", 2.9, "W 刀具方向轴"],
|
||
["x_linear_rail_guide", "X", "low", 4, "导轨属于零件级线索"],
|
||
["bolt_x_1", "X", "low", 4, "螺栓属于零件级线索"],
|
||
];
|
||
for (const [name, axis, confidence, rank, note] of NAMING_CASES) {
|
||
const result = classifyAxisNodeName(name);
|
||
assert.ok(result, `${name} 应当被识别为轴(${note})`);
|
||
assert.equal(result.axis, axis, `${name} 轴`);
|
||
assert.equal(result.confidence, confidence, `${name} 可信度`);
|
||
assert.ok(Math.abs(result.rank - rank) < 1e-9, `${name} 优先级应为 ${rank},实际 ${result.rank}`);
|
||
}
|
||
|
||
for (const name of ["tilt_axis", "rotary_axis", "tilt_table", "rotary_axis_bearing", "work_platter"]) {
|
||
const result = classifyAxisNodeName(name);
|
||
assert.ok(result?.ambiguous, `${name} 应当是需要消歧的通用部件名`);
|
||
}
|
||
|
||
for (const name of ["j1_base", "j2_shoulder", "portal_column", "column.001", "fixture_plate", "machine_root", "支腿", "", "tool"]) {
|
||
assert.equal(classifyAxisNodeName(name), null, `${name} 不应被识别为数控轴`);
|
||
}
|
||
// 通用词 + 零件词的优先级低于主轴节点,避免把 tilt_bearing 当成倾转轴。
|
||
assert.ok(classifyAxisNodeName("tilt_bearing_49").rank > classifyAxisNodeName("tilt_table").rank);
|
||
assert.ok(classifyAxisNodeName("trunnion_pedestal_48").rank > classifyAxisNodeName("tilt_table").rank);
|
||
|
||
// ---------------------------------------------------------------- 真实 GLB 名称
|
||
function readDescriptor(path) {
|
||
const buffer = readFileSync(path);
|
||
const jsonLength = buffer.readUInt32LE(12);
|
||
const json = JSON.parse(buffer.subarray(20, 20 + jsonLength).toString("utf8"));
|
||
const nodes = json.nodes ?? [];
|
||
const depth = new Array(nodes.length).fill(0);
|
||
const walk = (index, level) => { depth[index] = level; for (const child of nodes[index].children ?? []) walk(child, level + 1); };
|
||
const scene = json.scenes?.[json.scene ?? 0];
|
||
(scene?.nodes ?? []).forEach((index) => walk(index, 0));
|
||
const urdf = scene?.extras?.linuxcncMachine?.urdf;
|
||
const declared = scene?.extras?.linuxcncMachine?.coordinates ?? (typeof urdf === "string"
|
||
? [...new Set([...urdf.matchAll(/<cnc:axis\s+coordinate="([XYZABCUVW])"/g)].map((match) => match[1]))].join("")
|
||
: null);
|
||
return {
|
||
// Joint metadata written by scripts/generate_physical_case_glbs.mjs is the
|
||
// authoritative axis hint; single-letter slot names carry no other clue.
|
||
nodes: nodes.map((node, index) => ({ name: node.name ?? "", path: `scene/${index}`, depth: depth[index], explicitAxis: node.extras?.linuxcncJoint?.axis ?? node.extras?.["linuxcnc.axis"] ?? null })),
|
||
declared: typeof declared === "string" && declared.length === 5 ? declared : null,
|
||
};
|
||
}
|
||
|
||
const analysis = (relativePath) => {
|
||
const { nodes, declared } = readDescriptor(resolve(repoRoot, relativePath));
|
||
const profile = declared ? profileOf(declared) : null;
|
||
return selectAxisNodes(nodes, { declaredCoordinates: declared, tiltAxis: profile?.tiltAxis ?? null, rotaryAxis: profile?.rotaryAxis ?? null });
|
||
};
|
||
const boundAxes = (result) => result.axes.filter((item) => item.autoBind).map((item) => `${item.axis}:${item.name}`);
|
||
|
||
// 真实机床(带 JT_ 命名)与自带案例:不依赖任何提示即可绑定。
|
||
const AUTO_CASES = [
|
||
["glb/m1.glb", "XYZAC", ["X:JT_X", "Y:JT_Y", "Z:JT_Z", "A:JT_A", "C:JT_C"]],
|
||
["web/react/public/machine-cases/physical/xyzac-trt.glb", "XYZAC", ["X:x", "Y:y", "Z:z", "A:a", "C:c"]],
|
||
["web/react/public/machine-cases/physical/xyzbc-trt.glb", "XYZBC", ["X:x", "Y:y", "Z:z", "B:b", "C:c"]],
|
||
["web/react/public/machine-cases/physical/xyzab-tdr.glb", "XYZAB", ["X:x", "Y:y", "Z:z", "A:a", "B:b"]],
|
||
["web/react/public/machine-cases/physical/trivkins-xyzab.glb", "XYZAB", ["X:x", "Y:y", "Z:z", "A:a", "B:b"]],
|
||
["web/react/public/machine-cases/physical/trivkins-xyzcw.glb", "XYZCW", ["X:x", "Y:y", "Z:z", "C:c", "W:w"]],
|
||
];
|
||
for (const [file, coordinates, expected] of AUTO_CASES) {
|
||
const result = analysis(file);
|
||
assert.equal(result.suggestedCoordinates, coordinates, `${file} 机型`);
|
||
assert.deepEqual(boundAxes(result), expected, `${file} 绑定节点`);
|
||
assert.deepEqual(result.missingAxes, [], `${file} 不应缺轴`);
|
||
}
|
||
|
||
// 内置示例:场景内嵌 URDF 声明了轴序,按声明消歧后同样"导入即绑定"。
|
||
const DECLARED_CASES = [
|
||
["web/react/public/machines/demo-ac-xyz-head.glb", "XYZAC"],
|
||
["web/react/public/machines/demo-ac-gantry.glb", "XYZAC"],
|
||
["web/react/public/machines/demo-bc-gantry.glb", "XYZBC"],
|
||
];
|
||
for (const [file, coordinates] of DECLARED_CASES) {
|
||
const result = analysis(file);
|
||
assert.equal(result.suggestedCoordinates, coordinates, `${file} 机型`);
|
||
assert.deepEqual(boundAxes(result).map((item) => item.split(":")[0]), [...coordinates], `${file} 应绑定五个轴`);
|
||
}
|
||
|
||
// 重复轴与额外直线轴案例:分析器要求恰好五个唯一字母,因此这两类必须返回
|
||
// UNKNOWN,由 machine-visual-bindings.json 按槽位显式绑定。
|
||
// bridge-mill: XYZBCW + 重复 Y —— 六个不同字母
|
||
// xyzya: XYZ + A + 重复 Y —— 只有四个不同字母
|
||
for (const [id, reason] of [["bridge-mill-5axiskins", "六个不同轴字母"], ["trivkins-xyzya", "重复 Y 合并后只有四个字母"]]) {
|
||
const result = analysis(`web/react/public/machine-cases/physical/${id}.glb`);
|
||
assert.equal(result.suggestedCoordinates, "UNKNOWN", `${id} 不按五轴自动识别(${reason})`);
|
||
assert.deepEqual(boundAxes(result), [], `${id} 不应自动绑定`);
|
||
}
|
||
|
||
// 并联平台与串联机器人不是 XYZABC 语义,保持"需手动绑定",不误报。
|
||
for (const id of ["hexapod-genhexkins", "pentapod-pentakins", "puma-pumakins", "puma-genserkins", "scorbot-kins"]) {
|
||
const result = analysis(`web/react/public/machine-cases/physical/${id}.glb`);
|
||
assert.equal(result.suggestedCoordinates, "UNKNOWN", `${id} 不应被当作五轴 XYZABC 机床`);
|
||
assert.deepEqual(boundAxes(result), [], `${id} 不应自动绑定`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- TRT 标定推导
|
||
const xyzac = profileOf("XYZAC");
|
||
const calibration = deriveTrtCalibration({ coordinates: "XYZAC", profile: xyzac, cWorldMm: [10, 20, 30], tiltWorldMm: [110, 50, 70] });
|
||
assert.deepEqual(calibration.cOriginMm, [10, 20, 30], "C 轴原点取 C 轴节点世界坐标");
|
||
assert.deepEqual(calibration.tiltOffsetMm, [0, 30, 40], "AC 机型把沿 A 轴的 X 分量归零");
|
||
assert.equal(calibration.zeroedOffsetValueMm, 100, "记录被归零的分量");
|
||
assert.equal(calibration.zeroedOffsetDirection, "x");
|
||
assert.deepEqual(calibration.physicalZero, { x: 0, y: 0, z: 0, a: 0, c: 0 });
|
||
|
||
const xyzbc = profileOf("XYZBC");
|
||
const bcCalibration = deriveTrtCalibration({ coordinates: "XYZBC", profile: xyzbc, cWorldMm: [1, 2, 3], tiltWorldMm: [11, 22, 33] });
|
||
assert.deepEqual(bcCalibration.tiltOffsetMm, [10, 0, 30], "BC 机型把沿 B 轴的 Y 分量归零");
|
||
assert.equal(bcCalibration.zeroedOffsetValueMm, 20);
|
||
|
||
assert.equal(deriveTrtCalibration({ coordinates: "XYZAB", profile: null, cWorldMm: [0, 0, 0], tiltWorldMm: [1, 1, 1] }), null, "非 TRT 机型不产出原生标定");
|
||
assert.equal(deriveTrtCalibration({ coordinates: "XYZAC", profile: xyzac, cWorldMm: null, tiltWorldMm: [1, 1, 1] }), null, "缺坐标时不产出标定");
|
||
|
||
console.log(JSON.stringify({
|
||
report_id: "glb-axis-naming-v1",
|
||
status: "verified",
|
||
naming_cases: NAMING_CASES.length + 5 + 9,
|
||
auto_binding_cases: AUTO_CASES.length,
|
||
declared_binding_cases: DECLARED_CASES.length,
|
||
manual_only_cases: 5,
|
||
trt_calibration: calibration,
|
||
}, null, 1));
|