Files
linuxcncWebWASM/web/workspace/test_glb_axis_naming.mjs
wangdequan db29ae625f 按关节槽位驱动 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>
2026-10-30 06:09:12 +08:00

159 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));