// 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(/ 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));