按关节槽位驱动 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:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -37,7 +37,13 @@
"linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/xyzac-trt-kins.c",
"designParameters": ["rotaryPoint", "tiltToRotaryOffset", "toolOffset", "physicalZero"]
"designParameters": [
"rotaryPoint",
"tiltToRotaryOffset",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#xyzac-trt"
},
{
"id": "xyzbc-trt",
@@ -57,7 +63,13 @@
"linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/xyzbc-trt-kins.c",
"designParameters": ["rotaryPoint", "tiltToRotaryOffset", "toolOffset", "physicalZero"]
"designParameters": [
"rotaryPoint",
"tiltToRotaryOffset",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#xyzbc-trt"
},
{
"id": "xyzab-tdr",
@@ -77,7 +89,16 @@
"linuxcnc/configs/sim/qtdragon/qtdragon_xyz45/qtdragon_xyzab.ini"
],
"kinematicsSource": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
"designParameters": ["xRotPoint", "yRotPoint", "zRotPoint", "xOffset", "zOffset", "toolOffset", "physicalZero"]
"designParameters": [
"xRotPoint",
"yRotPoint",
"zRotPoint",
"xOffset",
"zOffset",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#xyzab-tdr"
},
{
"id": "bridge-mill-5axiskins",
@@ -96,7 +117,14 @@
"linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/5axiskins.c",
"designParameters": ["jointCoordinateMap", "duplicateAxisMap", "toolDirectionAxis", "toolOffset", "physicalZero"]
"designParameters": [
"jointCoordinateMap",
"duplicateAxisMap",
"toolDirectionAxis",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#bridge-mill-5axiskins"
},
{
"id": "max5-maxkins",
@@ -116,7 +144,14 @@
"linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZAB.ini"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/maxkins.c",
"designParameters": ["rotaryAxisOrder", "rotaryCenters", "uvwTransform", "toolOffset", "physicalZero"]
"designParameters": [
"rotaryAxisOrder",
"rotaryCenters",
"uvwTransform",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#max5-maxkins"
},
{
"id": "hexapod-genhexkins",
@@ -136,7 +171,15 @@
"linuxcnc/src/emc/kinematics/genhexkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/genhexkins.c",
"designParameters": ["baseJointPoints", "platformJointPoints", "spindleOffset", "screwLead", "iterationLimits", "physicalZero"]
"designParameters": [
"baseJointPoints",
"platformJointPoints",
"spindleOffset",
"screwLead",
"iterationLimits",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#hexapod-genhexkins"
},
{
"id": "pentapod-pentakins",
@@ -156,7 +199,14 @@
"linuxcnc/src/emc/kinematics/pentakins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/pentakins.c",
"designParameters": ["baseJointPoints", "effectorGeometry", "toolOffset", "convergenceCriterion", "physicalZero"]
"designParameters": [
"baseJointPoints",
"effectorGeometry",
"toolOffset",
"convergenceCriterion",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#pentapod-pentakins"
},
{
"id": "puma-pumakins",
@@ -176,7 +226,15 @@
"linuxcnc/src/emc/kinematics/pumakins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/pumakins.c",
"designParameters": ["A2", "A3", "D3", "D4", "toolOffset", "physicalZero"]
"designParameters": [
"A2",
"A3",
"D3",
"D4",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#puma-pumakins"
},
{
"id": "puma-genserkins",
@@ -196,7 +254,13 @@
"linuxcnc/src/emc/kinematics/genserkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/genserkins.c",
"designParameters": ["DHParameters", "jointLimits", "toolOffset", "physicalZero"]
"designParameters": [
"DHParameters",
"jointLimits",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#puma-genserkins"
},
{
"id": "trivkins-xyzab",
@@ -216,7 +280,12 @@
"linuxcnc/src/emc/kinematics/trivkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
"designParameters": ["coordinateOrder", "duplicateAxisMap", "physicalZero"]
"designParameters": [
"coordinateOrder",
"duplicateAxisMap",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzab"
},
{
"id": "trivkins-xyzcw",
@@ -234,7 +303,12 @@
"linuxcnc/src/emc/kinematics/trivkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
"designParameters": ["coordinateOrder", "toolDirectionAxis", "physicalZero"]
"designParameters": [
"coordinateOrder",
"toolDirectionAxis",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzcw"
},
{
"id": "trivkins-xyzya",
@@ -253,7 +327,12 @@
"linuxcnc/src/emc/kinematics/trivkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
"designParameters": ["coordinateOrder", "duplicateAxisMap", "physicalZero"]
"designParameters": [
"coordinateOrder",
"duplicateAxisMap",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzya"
},
{
"id": "scorbot-kins",
@@ -271,7 +350,13 @@
"linuxcnc/src/emc/kinematics/scorbot-kins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
"designParameters": ["robotLinkParameters", "jointLimits", "toolOffset", "physicalZero"]
"designParameters": [
"robotLinkParameters",
"jointLimits",
"toolOffset",
"physicalZero"
],
"physicalStructureRef": "five-axis-physical-structures.json#scorbot-kins"
}
]
}

View File

@@ -0,0 +1,526 @@
{
"schema": "linuxcnc.five-axis-physical-structures/1",
"units": {
"length": "mm",
"angle": "deg",
"gltf_length": "m",
"gltf_angle": "rad"
},
"sourcePolicy": "real-reference values are not calibration values; design-reference models require explicit design_reference status",
"cases": [
{
"id": "xyzac-trt",
"family": "table-rotary-tilting",
"reference": "HERMLE C 42 U 类摆动转台",
"status": "design-plus-real-reference",
"parts": [
"base",
"x",
"y",
"z",
"tool",
"a-tilt-frame",
"c-table",
"workholding",
"guard"
],
"jointTree": "base→x→y→z→tool; base→a→c→work",
"parameters": [
"rotaryPoint",
"tiltToRotaryOffset",
"toolOffset",
"physicalZero"
],
"source": "https://www.hermle.de/en/machining-centres-automation/models/machining-centre-c-42-gen2/",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "xyzbc-trt",
"family": "table-rotary-tilting",
"reference": "Haas UMC-500/750 B/C 摆台",
"status": "real-reference",
"parts": [
"base",
"x",
"y",
"z",
"tool",
"b-trunnion",
"c-table",
"workholding",
"guard"
],
"jointTree": "base→x→y→z→tool; base→b→c→work",
"parameters": [
"MRZP",
"bCIntersection",
"toolOffset",
"physicalZero"
],
"source": "https://www.haascnc.com/service/online-manuals/umc-series/umc---geometry.html",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "xyzab-tdr",
"family": "table-dual-rotary",
"reference": "LinuxCNC xyzab_tdr 双转台;双转台五轴通用设计",
"status": "linuxcnc-reference-plus-design",
"parts": [
"base",
"x",
"y",
"z",
"tool",
"a-outer-table",
"b-inner-table",
"workholding"
],
"jointTree": "base→x→y→z→tool; base→a→b→work",
"parameters": [
"xRotPoint",
"yRotPoint",
"zRotPoint",
"xOffset",
"zOffset",
"toolOffset",
"physicalZero"
],
"source": "https://linuxcnc.org/docs/html/man/man9/xyzab_tdr_kins.9.html",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "bridge-mill-5axiskins",
"family": "bridge-mill",
"reference": "FIDIA BRIDGE / AWEA Mega 5P 固定龙门双摆头",
"status": "real-reference-plus-design",
"parts": [
"foundation",
"left-column",
"right-column",
"bridge",
"x-table",
"y-carriage",
"z-ram",
"b-head",
"c-head",
"y-left-drive",
"y-right-drive"
],
"jointTree": "base→x→y→z→b→c→tool; y-left/y-right duplicate gantry joints; w is a tool-direction coordinate",
"parameters": [
"jointCoordinateMap",
"duplicateAxisMap",
"toolDirectionAxis",
"toolOffset",
"physicalZero"
],
"source": "https://www.fidia.it/en/products/bridge/",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "max5-maxkins",
"family": "table-and-head-rotary",
"reference": "LinuxCNC MAX5 桌面五轴:B 倾转头+C 水平转台",
"status": "linuxcnc-reference-plus-design",
"parts": [
"base",
"x",
"y",
"z",
"b-head",
"tool",
"c-horizontal-table",
"workholding"
],
"jointTree": "base→x→y→z→b→tool; base→c→work",
"parameters": [
"pivotLength",
"rotaryCenters",
"uvwTransform",
"toolOffset",
"physicalZero"
],
"source": "https://linuxcnc.org/docs/master/html/man/man9/kins.9.html",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "hexapod-genhexkins",
"family": "parallel-platform",
"reference": "Giddings & Lewis Variax 六支链平台",
"status": "real-reference-plus-design",
"parts": [
"base-platform",
"strut-1",
"strut-2",
"strut-3",
"strut-4",
"strut-5",
"strut-6",
"moving-platform",
"spindle",
"tool"
],
"jointTree": "base→six independent struts→moving-platform→tool",
"parameters": [
"baseJointPoints",
"platformJointPoints",
"spindleOffset",
"screwLead",
"iterationLimits",
"physicalZero"
],
"source": "https://collection.sciencemuseumgroup.org.uk/objects/co8003352/pair-of-hexapod-legs-used-with-the-variax-machine-tool-made-by-giddings-lewis-of-wisconsin-in-1993",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "pentapod-pentakins",
"family": "parallel-platform",
"reference": "METROM Pentapod P-Series",
"status": "real-reference-plus-design",
"parts": [
"base-frame",
"strut-1",
"strut-2",
"strut-3",
"strut-4",
"strut-5",
"moving-platform",
"spindle",
"tool"
],
"jointTree": "base→five independent struts→platform→tool",
"parameters": [
"baseJointPoints",
"effectorGeometry",
"toolOffset",
"convergenceCriterion",
"physicalZero"
],
"source": "https://metrom.com/5-axis-machining-center/",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "puma-pumakins",
"family": "serial-robot",
"reference": "Unimation PUMA 560 类六轴机器人",
"status": "real-reference",
"parts": [
"base",
"j1-turntable",
"j2-shoulder",
"j3-elbow",
"j4-wrist-roll",
"j5-wrist-pitch",
"j6-wrist-yaw",
"flange",
"tool"
],
"jointTree": "base→j1→j2→j3→j4→j5→j6→tool",
"parameters": [
"A2",
"A3",
"D3",
"D4",
"D6",
"jointLimits",
"toolOffset",
"physicalZero"
],
"source": "https://www.linuxcnc.org/docs/html/motion/switchkins.html",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "puma-genserkins",
"family": "serial-robot",
"reference": "PUMA 560 Modified-DH 六轴机器人",
"status": "real-reference-plus-design",
"parts": [
"base",
"j1",
"j2",
"j3",
"j4",
"j5",
"j6",
"flange",
"tool"
],
"jointTree": "base→j1→j2→j3→j4→j5→j6→tool",
"parameters": [
"DHParameters",
"jointLimits",
"toolOffset",
"physicalZero"
],
"source": "https://www.linuxcnc.org/docs/master/html/fr/man/man9/kins.9.html",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "trivkins-xyzab",
"family": "identity-baseline",
"reference": "通用立式五轴双转台验证机",
"status": "design-reference",
"parts": [
"base",
"x",
"y",
"z",
"tool",
"a-frame",
"b-table",
"workholding"
],
"jointTree": "base→x→y→z→tool; base→a→b→work",
"parameters": [
"coordinateOrder",
"duplicateAxisMap",
"physicalZero"
],
"source": null,
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "trivkins-xyzcw",
"family": "identity-baseline",
"reference": "通用桥式 C/W 刀向补偿验证机",
"status": "design-reference",
"parts": [
"base",
"x",
"y",
"z",
"tool",
"c-table",
"w-tool-sleeve",
"workholding"
],
"jointTree": "base→x→y→z→tool; base→c→work; tool→w→tip",
"parameters": [
"coordinateOrder",
"toolDirectionAxis",
"physicalZero"
],
"source": null,
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "trivkins-xyzya",
"family": "identity-gantry-rotary",
"reference": "双 Y 龙门+A 转台验证机",
"status": "design-reference",
"parts": [
"base",
"x",
"y-left",
"y-right",
"z",
"tool",
"a-table",
"workholding"
],
"jointTree": "five joints: x,y-left,z,y-right,a; y-left/y-right drive one gantry axis",
"parameters": [
"coordinateOrder",
"duplicateAxisMap",
"gantrySynchronization",
"physicalZero"
],
"source": null,
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
},
{
"id": "scorbot-kins",
"family": "serial-robot",
"reference": "SCORBOT-ER 4U/ER 4PC 五关节机器人",
"status": "real-reference-plus-design",
"parts": [
"base",
"j1-base-turn",
"j2-shoulder",
"j3-elbow",
"j4-pitch",
"j5-wrist-roll",
"flange",
"tool"
],
"jointTree": "base→j1→j2→j3→j4→j5→tool",
"parameters": [
"robotLinkParameters",
"jointLimits",
"toolOffset",
"physicalZero"
],
"source": "https://www.theoldrobots.com/book45/USBC-document.pdf",
"assetPlan": {
"zeroPoseRequired": true,
"separateJointNodes": true,
"includeOriginHelpers": true,
"metadata": [
"source",
"license",
"kinematics",
"coordinates",
"zero_pose",
"joint_limits",
"calibration_status"
]
}
}
]
}

View File

@@ -0,0 +1,146 @@
// GLB 节点名 → 数控轴 的识别规则,以及由旋转轴世界坐标推导的 TRT 标定候选。
// 抽成纯函数模块,便于被分析器(TypeScript)和 Node 单测(test_glb_axis_naming.mjs)共用。
export const CNC_AXIS_LETTERS = Object.freeze(["X", "Y", "Z", "A", "B", "C", "U", "V", "W"]);
// 主要运动部件词:轴字母与它相邻时可信度最高(直线部件在前、回转部件在后)。
export const PRIMARY_WORDS = Object.freeze(["SLIDE", "CARRIAGE", "SADDLE", "TRUNNION", "TILT", "TABLE", "FRAME", "PLATTER", "RAM", "TOOL"]);
// 轴字母后出现这些词只说明“和某个轴相关”,属于零件级线索,优先级最低。
export const PART_WORDS = Object.freeze(["LINEAR", "ROTARY", "BEARING", "BEARINGS", "DRIVE", "GUIDE", "RAIL", "BOLT", "CASTING", "MOTOR", "SHAFT", "HOLDER", "HEAD", "SPINDLE", "COLUMN", "SCREW", "WAY", "WAYS", "PEDESTAL", "CRADLE", "PLINTH", "FOOT"]);
// 不含轴字母的通用词:需要结合机型或同模型其它命名消歧。
export const AMBIGUOUS_WORDS = Object.freeze([{ word: "TILT", kind: "tilt" }, { word: "TRUNNION", kind: "tilt" }, { word: "ROTARY", kind: "rotary" }, { word: "PLATTER", kind: "rotary" }]);
const axisSet = new Set(CNC_AXIS_LETTERS);
const rankOfPrimary = (word) => 2 + PRIMARY_WORDS.indexOf(word) * 0.1;
/**
* 把 GLB 节点名归类为某个数控轴。
* 规则优先级:JT_X 精确名 → 轴字母 + AXIS/JOINT → 轴字母 + 主要运动部件词 → 零件级线索 → 待消歧的通用词。
* @returns {{axis: string, confidence: "high"|"medium"|"low", rank: number, reason: string, ambiguous?: "tilt"|"rotary"} | null}
*/
export function classifyAxisNodeName(name) {
const normalized = String(name ?? "").trim().toUpperCase();
if (!normalized) return null;
const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
if (exact && axisSet.has(exact[1])) return { axis: exact[1], confidence: "high", rank: 0, reason: "JT_ 轴名(精确)" };
const tokens = normalized.split(/[^A-Z0-9]+/).filter(Boolean);
const letters = tokens.filter((token) => token.length === 1 && axisSet.has(token));
const words = tokens.filter((token) => token.length > 1);
const partWord = words.find((word) => PART_WORDS.includes(word));
if (letters.length === 1) {
const axis = letters[0];
// AXIS/JOINT 只算一次:b_axis 是关节本身,b_axis_bearing / b_axis_axis 是它的零件。
const jointWords = words.filter((word) => word === "AXIS" || word === "JOINT").length;
const hasJointWord = jointWords > 0;
if (jointWords === 1 && !partWord) return { axis, confidence: "medium", rank: 1, reason: "轴字母 + AXIS/JOINT 命名" };
const letterIndex = tokens.indexOf(letters[0]);
const primaryIndex = tokens.findIndex((token) => PRIMARY_WORDS.includes(token));
// 允许 moving_x_table / a_outer_table 这类“轴字母在部件词之前”的命名。
if (!hasJointWord && primaryIndex > letterIndex) {
const primary = tokens[primaryIndex];
// 只有真正的零件词才算"附加层级"(y_saddle_casting 低于 y_saddle);
// direction / 序号之类的修饰词不降级(w_tool_direction 仍是 W 主节点)。
const extras = tokens.slice(primaryIndex + 1).filter((token) => PART_WORDS.includes(token)).length;
return { axis, confidence: "medium", rank: rankOfPrimary(primary) + Math.min(extras, 3) * 0.02, reason: `轴字母 + ${primary} 部件命名` };
}
if (partWord || hasJointWord) return { axis, confidence: "low", rank: 4, reason: "零件级轴名线索,需人工确认" };
}
const ambiguous = AMBIGUOUS_WORDS.find((item) => words.includes(item.word));
if (ambiguous) {
// 通用词 + 零件词(tilt_bearing、trunnion_pedestal)只是同轴零件,排序要低于 tilt_table 这类主轴节点。
const rank = partWord ? 4 : 3 + AMBIGUOUS_WORDS.indexOf(ambiguous) * 0.1;
return { axis: "C", confidence: "low", rank, reason: `通用${ambiguous.kind === "tilt" ? "倾转" : "回转"}部件命名,需结合机型判断`, ambiguous: ambiguous.kind };
}
return null;
}
/**
* 由旋转轴节点的世界坐标推导原生 TRT 标定候选:
* C 轴原点取 C 轴节点世界坐标,倾转偏置取倾转轴节点与之的差,
* 并按运动学约束把沿倾转轴方向(该机型要求为 0)的分量归零。
*/
export function deriveTrtCalibration({ coordinates, profile, cWorldMm, tiltWorldMm }) {
if (!profile || !["XYZAC", "XYZBC"].includes(coordinates)) return null;
if (![cWorldMm, tiltWorldMm].every((value) => Array.isArray(value) && value.length === 3 && value.every((item) => Number.isFinite(item)))) return null;
const offsetIndex = ["x", "y", "z"].indexOf(profile.forbiddenOffsetDirection);
if (offsetIndex < 0) return null;
const cOriginMm = cWorldMm.map((value) => Number(value.toFixed(6)));
const tiltOffsetMm = tiltWorldMm.map((value, index) => Number((value - cWorldMm[index]).toFixed(6)));
const zeroed = tiltOffsetMm[offsetIndex];
tiltOffsetMm[offsetIndex] = 0;
return {
schema: "linuxcnc.trt-calibration-candidate/1",
source: "axis-world-origin",
cOriginMm,
tiltOffsetMm,
physicalZero: Object.fromEntries([...coordinates].map((axis) => [axis.toLowerCase(), 0])),
...(Math.abs(zeroed) > 1e-6 ? { zeroedOffsetDirection: profile.forbiddenOffsetDirection, zeroedOffsetValueMm: zeroed } : {}),
};
}
/**
* 从节点描述里挑选每个数控轴的最佳绑定候选,并判定机型坐标。
* 纯函数:不依赖 three.js,可被分析器和 Node 单测共用。
*
* @param {{name: string, path: string, depth: number, explicitAxis?: string | null}[]} descriptors
* @param {{declaredCoordinates?: string | null, expectedCoordinates?: string | null, tiltAxis?: string | null, rotaryAxis?: string | null}} options
*/
export function selectAxisNodes(descriptors, options = {}) {
const warnings = [];
const items = [];
for (const descriptor of descriptors) {
const hint = descriptor.explicitAxis
? { axis: descriptor.explicitAxis, confidence: "high", rank: -1, reason: "GLB 关节元数据" }
: classifyAxisNodeName(descriptor.name);
if (!hint || !axisSet.has(hint.axis)) continue;
items.push({ path: descriptor.path, name: descriptor.name, depth: Number(descriptor.depth) || 0, hint, autoBind: false });
}
// 通用倾转/回转命名消歧:优先用调用方给的机型信息,其次用同模型里明确的轴字母。
const explicitAxes = new Set(items.filter((item) => !item.hint.ambiguous).map((item) => item.hint.axis));
const tiltAxis = options.tiltAxis ?? (explicitAxes.has("A") && !explicitAxes.has("B") ? "A" : explicitAxes.has("B") && !explicitAxes.has("A") ? "B" : null);
const rotaryAxis = options.rotaryAxis ?? (explicitAxes.has("C") ? "C" : null);
let unresolvedAmbiguous = 0;
for (const item of items) {
if (!item.hint.ambiguous) continue;
const resolved = item.hint.ambiguous === "tilt" ? tiltAxis : rotaryAxis;
// 无法消歧的候选不参与机型判定,也不自动绑定。
if (!resolved) { unresolvedAmbiguous += 1; item.hint = { ...item.hint, rank: 9, reason: `${item.hint.reason}(缺少机型线索)` }; continue; }
item.hint = { ...item.hint, axis: resolved, reason: `${item.hint.reason} → ${resolved}` };
}
if (unresolvedAmbiguous) warnings.push(`${unresolvedAmbiguous} 个通用倾转/回转节点缺少机型线索(可先选机型再导入)`);
// 每个轴取最优候选;同级并列视为歧义,不自动绑定。
const chosen = new Map();
const byAxis = new Map();
for (const item of items) { const list = byAxis.get(item.hint.axis) ?? []; list.push(item); byAxis.set(item.hint.axis, list); }
for (const [axis, list] of byAxis) {
list.sort((a, b) => a.hint.rank - b.hint.rank || a.depth - b.depth);
const best = list[0];
// 同名重复节点(CAD 导出常见)按深度取最上层;只有不同命名同分才算歧义。
const tied = list.filter((item) => item.hint.rank === best.hint.rank && item.name !== best.name);
if (tied.length > 1) { warnings.push(`轴 ${axis} 有 ${tied.length} 个同级候选(${tied.map((item) => item.name).join("、")}),未自动绑定;请手动选择唯一节点`); continue; }
chosen.set(axis, best);
for (const item of list) item.autoBind = item === best && best.hint.rank <= 3;
}
const declared = typeof options.declaredCoordinates === "string" && options.declaredCoordinates.length === 5 ? options.declaredCoordinates : null;
const expected = typeof options.expectedCoordinates === "string" && options.expectedCoordinates.length === 5 ? options.expectedCoordinates : null;
const primaryAxes = CNC_AXIS_LETTERS.filter((axis) => (chosen.get(axis)?.hint.rank ?? 9) <= 3);
const declaredValid = declared ? [...declared].every((axis) => chosen.has(axis)) : false;
const expectedValid = expected ? [...expected].every((axis) => chosen.has(axis)) : false;
const suggestedCoordinates = declaredValid ? declared : expectedValid ? expected : primaryAxes.length === 5 ? primaryAxes.join("") : "UNKNOWN";
if (declared && !declaredValid) warnings.push("GLB 声明的轴顺序与可用关节不一致,未采用该顺序");
if (suggestedCoordinates === "UNKNOWN" && primaryAxes.length !== 5) warnings.push(`检测到 ${primaryAxes.length} 个主轴候选(${primaryAxes.join("") || "无"});需要五个轴才能自动绑定`);
const bound = new Set(suggestedCoordinates === "UNKNOWN" ? [] : [...suggestedCoordinates]);
const axes = CNC_AXIS_LETTERS.flatMap((axis) => {
const best = chosen.get(axis);
if (!best) return [];
const autoBind = best.autoBind && bound.has(axis);
return [{ axis, name: best.name, path: best.path, depth: best.depth, rank: best.hint.rank, confidence: best.hint.confidence, reason: best.reason ?? best.hint.reason, expectedKind: "XYZUVW".includes(axis) ? "linear" : "rotary", autoBind }];
});
const available = new Set(axes.filter((item) => item.autoBind).map((item) => item.axis));
const missingAxes = suggestedCoordinates === "UNKNOWN" ? [] : [...suggestedCoordinates].filter((axis) => !available.has(axis));
return { axes, suggestedCoordinates, missingAxes, warnings, unresolvedAmbiguous };
}

View File

@@ -30,6 +30,250 @@
"helix_bc"
],
"runtime_fixture": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"
},
{
"id": "xyzab-tdr",
"coordinates": "XYZAB",
"kinematics_type": 1,
"runtime": "generic-wasm",
"kinematics": "xyzab-tdr",
"kinematics_source": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
"kinematics_parameters": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"z_offset": -10,
"tool_offset": 0
},
"joint_count": 5,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
"program": "machine-cases/xyzab-tdr.ngc",
"source_path": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
"model": "dual-rotary-table",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"
},
{
"id": "bridge-mill-5axiskins",
"coordinates": "XYZBCWY",
"kinematics_type": 9,
"runtime": "generic-wasm",
"kinematics": "5axiskins",
"kinematics_source": "linuxcnc/src/emc/kinematics/5axiskins.c",
"kinematics_parameters": {
"pivot_length": 100
},
"joint_count": 7,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
"program": "machine-cases/bridge-mill-5axiskins.ngc",
"source_path": "linuxcnc/src/emc/kinematics/5axiskins.c",
"model": "bridge-mill-xyzbcwy",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini"
},
{
"id": "max5-maxkins",
"coordinates": "XYZBC",
"kinematics_type": 7,
"runtime": "generic-wasm",
"kinematics": "maxkins",
"kinematics_source": "linuxcnc/src/emc/kinematics/maxkins.c",
"kinematics_parameters": {
"pivot_length": 0
},
"joint_count": 6,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
"program": "machine-cases/max5-maxkins.ngc",
"source_path": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
"model": "max5-maxkins",
"units": {
"linear": "machine-config",
"angular": "machine-config"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
"kinematics_joint_count": 5,
"extra_joint_count": 1
},
{
"id": "hexapod-genhexkins",
"coordinates": "XYZABC",
"kinematics_type": 17,
"runtime": "generic-wasm",
"kinematics": "genhexkins",
"kinematics_source": "linuxcnc/src/emc/kinematics/genhexkins.c",
"joint_count": 6,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
"program": "machine-cases/hexapod-genhexkins.ngc",
"source_path": "linuxcnc/src/emc/kinematics/genhexkins.c",
"model": "hexapod-six-strut",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"
},
{
"id": "pentapod-pentakins",
"coordinates": "XYZAB",
"kinematics_type": 18,
"runtime": "generic-wasm",
"kinematics": "pentakins",
"kinematics_source": "linuxcnc/src/emc/kinematics/pentakins.c",
"joint_count": 5,
"entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
"program": "machine-cases/pentapod-pentakins.ngc",
"source_path": "linuxcnc/src/emc/kinematics/pentakins.c",
"model": "pentapod-five-strut",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini"
},
{
"id": "puma-pumakins",
"coordinates": "XYZABC",
"kinematics_type": 11,
"runtime": "generic-wasm",
"kinematics": "pumakins",
"kinematics_source": "linuxcnc/src/emc/kinematics/pumakins.c",
"kinematics_parameters": {
"puma_a2": 400,
"puma_a3": 50,
"puma_d3": 100,
"puma_d4": 400,
"puma_d6": 95
},
"joint_count": 6,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
"program": "machine-cases/puma-pumakins.ngc",
"source_path": "linuxcnc/src/emc/kinematics/pumakins.c",
"model": "puma-six-axis-serial",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini"
},
{
"id": "puma-genserkins",
"coordinates": "XYZABC",
"kinematics_type": 13,
"runtime": "generic-wasm",
"kinematics": "genserkins",
"kinematics_source": "linuxcnc/src/emc/kinematics/genserkins.c",
"kinematics_parameters": {
"dh_joint_count": 6,
"dh_a": [
0,
17,
0,
0,
0,
0
],
"dh_alpha": [
0,
1.570796326,
0,
1.570796326,
-1.570796326,
1.570796326
],
"dh_d": [
26.45,
-5.5,
0,
17.05,
0,
2.2
]
},
"joint_count": 6,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
"program": "machine-cases/puma-genserkins.ngc",
"source_path": "linuxcnc/src/emc/kinematics/genserkins.c",
"model": "puma560-genser-dh",
"units": {
"linear": "inch-config",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini"
},
{
"id": "trivkins-xyzab",
"coordinates": "XYZAB",
"kinematics_type": 1,
"runtime": "generic-wasm",
"kinematics": "trivkins",
"joint_count": 5,
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
"program": "machine-cases/trivkins-xyzab.ngc",
"source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
"model": "xyzab-identity",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini"
},
{
"id": "trivkins-xyzcw",
"coordinates": "XYZCW",
"kinematics_type": 1,
"runtime": "generic-wasm",
"kinematics": "trivkins",
"joint_count": 5,
"entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
"program": "machine-cases/trivkins-xyzcw.ngc",
"source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
"model": "xyzcw-identity",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini"
},
{
"id": "trivkins-xyzya",
"coordinates": "XYZYA",
"kinematics_type": 1,
"runtime": "generic-wasm",
"kinematics": "trivkins",
"joint_count": 5,
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
"program": "machine-cases/trivkins-xyzya.ngc",
"source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
"model": "xyzya-identity-gantry",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"
},
{
"id": "scorbot-kins",
"coordinates": "XYZAB",
"kinematics_type": 12,
"runtime": "generic-wasm",
"kinematics": "scorbot-kins",
"joint_count": 5,
"entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
"program": "machine-cases/scorbot-kins.ngc",
"source_path": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
"model": "scorbot-five-axis-serial",
"units": {
"linear": "mm",
"angular": "deg"
},
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"
}
]
}

View File

@@ -1,11 +1,12 @@
import catalog from "./machine-catalog.json" with { type: "json" };
export const MACHINE_DEFINITIONS = Object.freeze(catalog.machines.map((entry) => {
if (!entry.id || !entry.entrypoint || !entry.program || !entry.source_path || !["XYZAC", "XYZBC"].includes(entry.coordinates)) throw new Error("machine_catalog_invalid");
return Object.freeze({ ...entry, label: `${entry.coordinates} TRT`, axes: [...entry.coordinates.toLowerCase()], joint_count: entry.coordinates.length, runtime: "trt" });
if (!entry.id || !entry.entrypoint || !entry.program || !entry.source_path || typeof entry.coordinates !== "string" || !entry.coordinates.length || entry.coordinates.length > 9) throw new Error("machine_catalog_invalid");
return Object.freeze({ ...entry, label: entry.runtime === "generic-wasm" ? entry.id : `${entry.coordinates} TRT`, axes: [...entry.coordinates.toLowerCase()], joint_count: entry.joint_count ?? entry.coordinates.length, runtime: entry.runtime ?? "trt" });
}));
if (new Set(MACHINE_DEFINITIONS.map((entry) => entry.id)).size !== MACHINE_DEFINITIONS.length) throw new Error("machine_catalog_duplicate");
export const DEFAULT_MACHINE = MACHINE_DEFINITIONS.find((entry) => entry.id === catalog.default_machine);
export const GENERIC_WASM_MACHINES = Object.freeze(MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "generic-wasm"));
if (!DEFAULT_MACHINE) throw new Error("machine_catalog_default_missing");
export function machineDefinition(value) {
return MACHINE_DEFINITIONS.find((entry) => [entry.id, entry.entrypoint, entry.coordinates].includes(value)) ?? null;

View File

@@ -0,0 +1,489 @@
{
"schema": "linuxcnc.machine-kinematics-specs/1",
"description": "Per-case joint slots and limits, derived from the LinuxCNC INIs. limits[j] = [min,max] for joint index j; null means the source config declares none.",
"cases": [
{
"machineId": "xyzab-tdr",
"coordinates": "XYZAB",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
"kinematics": "xyzab-tdr",
"jointCount": 5,
"jointOrder": [
"X",
"Y",
"Z",
"A",
"B"
],
"limitsByJoint": [
[
-200.0,
200.0
],
[
-200.0,
200.0
],
[
-300.0,
300.0
],
[
-3600.0,
3600.0
],
[
-180.0,
180.0
]
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 0,
"parameters": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"z_offset": -10,
"tool_offset": 0
},
"limitsSource": "linuxcnc-ini"
},
{
"machineId": "bridge-mill-5axiskins",
"coordinates": "XYZBCWY",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
"kinematics": "5axiskins",
"jointCount": 7,
"jointOrder": [
"X",
"Y",
"Z",
"B",
"C",
"W",
"Y#2"
],
"limitsByJoint": [
[
-500.0,
500.0
],
[
-500.0,
500.0
],
[
-500.0,
100.0
],
[
-120.0,
120.0
],
[
-36000.0,
36000.0
],
[
-100.0,
500.0
],
[
-500.0,
500.0
]
],
"declaredKinematicsJointCount": 7,
"extraJointCount": 0,
"parameters": {
"pivot_length": 100
},
"limitsSource": "linuxcnc-ini"
},
{
"machineId": "max5-maxkins",
"coordinates": "XYZBC",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
"kinematics": "maxkins",
"jointCount": 6,
"jointOrder": [
"X",
"Y",
"Z",
"B",
"C",
"J5"
],
"limitsByJoint": [
[
-100.0,
200.0
],
[
-60.0,
60.0
],
[
-149.0,
1.0
],
[
-10.0,
10.0
],
[
-90.0,
90.0
],
[
-3600.0,
3600.0
]
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 1,
"parameters": {
"pivot_length": 0
},
"limitsSource": "linuxcnc-ini"
},
{
"machineId": "hexapod-genhexkins",
"coordinates": "XYZABC",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
"kinematics": "genhexkins",
"jointCount": 6,
"jointOrder": [
"J0",
"J1",
"J2",
"J3",
"J4",
"J5"
],
"limitsByJoint": [
[
-1000.0,
1000.0
],
[
-1000.0,
1000.0
],
[
-1000.0,
1000.0
],
[
-1000.0,
1000.0
],
[
-1000.0,
1000.0
],
[
-1000.0,
1000.0
]
],
"declaredKinematicsJointCount": 6,
"extraJointCount": 0,
"parameters": {},
"limitsSource": "linuxcnc-ini"
},
{
"machineId": "pentapod-pentakins",
"coordinates": "XYZAB",
"entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
"kinematics": "pentakins",
"jointCount": 5,
"jointOrder": [
"J0",
"J1",
"J2",
"J3",
"J4"
],
"limitsByJoint": [
[
400.0,
1500.0
],
[
400.0,
1500.0
],
[
400.0,
1500.0
],
[
400.0,
1500.0
],
[
400.0,
1500.0
]
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 0,
"parameters": {},
"limitsSource": "linuxcnc-ini"
},
{
"machineId": "puma-pumakins",
"coordinates": "XYZABC",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
"kinematics": "pumakins",
"jointCount": 6,
"jointOrder": [
"J0",
"J1",
"J2",
"J3",
"J4",
"J5"
],
"limitsByJoint": [
[
-170.0,
170.0
],
[
-85.0,
50.0
],
[
-70.0,
75.0
],
[
-250.0,
250.0
],
[
-120.0,
120.0
],
null
],
"declaredKinematicsJointCount": 6,
"extraJointCount": 0,
"parameters": {
"puma_a2": 400,
"puma_a3": 50,
"puma_d3": 100,
"puma_d4": 400,
"puma_d6": 95
},
"limitsSource": "linuxcnc-ini (missing: J5)"
},
{
"machineId": "puma-genserkins",
"coordinates": "XYZABC",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
"kinematics": "genserkins",
"jointCount": 6,
"jointOrder": [
"J0",
"J1",
"J2",
"J3",
"J4",
"J5"
],
"limitsByJoint": [
[
-180.0,
180.0
],
[
-360.0,
360.0
],
[
-215.0,
35.0
],
[
-360.0,
360.0
],
[
-360.0,
360.0
],
[
-360.0,
360.0
]
],
"declaredKinematicsJointCount": 6,
"extraJointCount": 0,
"parameters": {
"dh_joint_count": 6,
"dh_a": [
0,
17,
0,
0,
0,
0
],
"dh_alpha": [
0,
1.570796326,
0,
1.570796326,
-1.570796326,
1.570796326
],
"dh_d": [
26.45,
-5.5,
0,
17.05,
0,
2.2
]
},
"limitsSource": "linuxcnc-ini"
},
{
"machineId": "trivkins-xyzab",
"coordinates": "XYZAB",
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
"kinematics": "trivkins",
"jointCount": 5,
"jointOrder": [
"X",
"Y",
"Z",
"A",
"B"
],
"limitsByJoint": [
[
-0.001,
520.0
],
[
-0.001,
630.0
],
[
-115.0,
10.0
],
null,
null
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 0,
"parameters": {},
"limitsSource": "linuxcnc-ini (missing: A,B)"
},
{
"machineId": "trivkins-xyzcw",
"coordinates": "XYZCW",
"entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
"kinematics": "trivkins",
"jointCount": 5,
"jointOrder": [
"X",
"Y",
"Z",
"C",
"W"
],
"limitsByJoint": [
[
-0.0,
600.0
],
[
-0.0,
400.0
],
[
-400.0,
4.0
],
null,
[
-2.0,
4.0
]
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 0,
"parameters": {},
"limitsSource": "linuxcnc-ini (missing: C)"
},
{
"machineId": "trivkins-xyzya",
"coordinates": "XYZYA",
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
"kinematics": "trivkins",
"jointCount": 5,
"jointOrder": [
"X",
"Y",
"Z",
"Y#2",
"A"
],
"limitsByJoint": [
[
-0.001,
520.0
],
[
-0.001,
630.0
],
[
-115.0,
10.0
],
[
-0.001,
630.0
],
null
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 0,
"parameters": {},
"limitsSource": "linuxcnc-ini (missing: A)"
},
{
"machineId": "scorbot-kins",
"coordinates": "XYZAB",
"entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
"kinematics": "scorbot-kins",
"jointCount": 5,
"jointOrder": [
"J0",
"J1",
"J2",
"J3",
"J4"
],
"limitsByJoint": [
null,
null,
null,
null,
null
],
"declaredKinematicsJointCount": 5,
"extraJointCount": 0,
"parameters": {},
"limitsSource": "linuxcnc-ini (missing: J0,J1,J2,J3,J4)"
}
]
}

File diff suppressed because it is too large Load Diff

View 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,
};
}

View File

@@ -147,6 +147,28 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
if (typeof fetchImpl !== "function") throw new Error("simulation_case_fetch_required");
const catalog = findSimulationCase(path);
if (!catalog) throw new Error("simulation_case_unknown");
const genericMachine = machineDefinition(path);
if (genericMachine?.runtime === "generic-wasm") {
const axes = [...genericMachine.coordinates];
const linear = genericMachine.units?.linear ?? "mm", angular = genericMachine.units?.angular ?? "deg";
const lines = ["[EMC]", `MACHINE = ${genericMachine.label}`, "[DISPLAY]", "DISPLAY = axis", "MAX_FEED_OVERRIDE = 1", "[TASK]", "TASK = milltask", "CYCLE_TIME = 0.010", "[EMCMOT]", "EMCMOT = motmod", "SERVO_PERIOD = 1000000", "[TRAJ]", `COORDINATES = ${axes.join(" ")}`, `LINEAR_UNITS = ${linear}`, `ANGULAR_UNITS = ${angular}`, "DEFAULT_LINEAR_VELOCITY = 10", "MAX_LINEAR_VELOCITY = 20", "MAX_LINEAR_ACCELERATION = 100", "[KINS]", `JOINTS = ${genericMachine.joint_count}`, `KINEMATICS = ${genericMachine.kinematics}`];
for (let index = 0; index < genericMachine.joint_count; index += 1) {
const axis = axes[index] ?? `J${index}`;
const range = axis.toUpperCase() === axis && "XYZ".includes(axis.toUpperCase()) ? [-1000, 1000] : [-360, 360];
lines.push(`[JOINT_${index}]`, `TYPE = ${"XYZ".includes(axis.toUpperCase()) && axis !== "W" ? "LINEAR" : "ANGULAR"}`, `MIN_LIMIT = ${range[0]}`, `MAX_LIMIT = ${range[1]}`, "MAX_VELOCITY = 10", "MAX_ACCELERATION = 100", "HOME = 0");
}
const files = { [genericMachine.entrypoint]: `${lines.join("\n")}\n` };
const programPath = genericMachine.program;
const program = await fetchText(fetchImpl, baseUrl, programPath);
if (program === null) throw new Error(`generic_machine_program_missing:${programPath}`);
files[programPath] = program;
if (genericMachine.source_path && genericMachine.source_path.startsWith("linuxcnc/") && genericMachine.source_path !== genericMachine.entrypoint) {
const source = await fetchText(fetchImpl, baseUrl, genericMachine.source_path);
if (source !== null) files[genericMachine.source_path] = source;
}
const unsigned = { schema: SIMULATION_CASE_SCHEMA, entrypoint: genericMachine.entrypoint, files, fetched: [genericMachine.entrypoint, programPath, genericMachine.source_path].filter((item) => item && item in files).sort(), missing: [], optional_missing: [], runtime_fixtures: [{ kind: "web-generic-kinematics", kinematics: genericMachine.kinematics, source_entrypoint: genericMachine.source_entrypoint }] };
return { ...clone(unsigned), source_ini: files[genericMachine.entrypoint], catalog: { path: genericMachine.entrypoint, coordinates: genericMachine.coordinates, label: genericMachine.label, status: "web_generic_wasm" }, halManifest: [], status: "ready", state_hash: fnv1a64(unsigned) };
}
const files = {};
const fetched = [];
const missing = [];
@@ -239,6 +261,23 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
export function toWorkerConfiguration(bundle) {
if (!bundle || bundle.schema !== SIMULATION_CASE_SCHEMA) throw new Error("simulation_case_bundle_invalid");
const genericMachine = machineDefinition(bundle.entrypoint);
if (genericMachine?.runtime === "generic-wasm") {
const parsed = parseIni({ workspace: new WorkspaceRuntime({ files: bundle.files }), entrypoint: bundle.entrypoint });
const axes = String(parsed.sections.TRAJ.COORDINATES ?? "").replace(/\s+/g, "").toUpperCase();
const joints = Number(parsed.sections.KINS.JOINTS);
if (axes !== genericMachine.coordinates || joints !== genericMachine.joint_count) throw new Error("generic_machine_configuration_mismatch");
const limits = {};
for (let index = 0; index < joints; index += 1) {
if (index >= axes.length) continue;
const section = parsed.sections[`JOINT_${index}`];
const range = [Number(section?.MIN_LIMIT), Number(section?.MAX_LIMIT)];
if (!range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`generic_machine_joint_limit:${index}`);
limits[axes[index].toLowerCase()] = range;
}
const kinematicsJointCount = genericMachine.kinematics_joint_count ?? joints;
return { entrypoint: bundle.entrypoint, files: clone(bundle.files), halManifest: [], state_hash: bundle.state_hash, kinematics: { type: genericMachine.kinematics, runtime: "generic-wasm", coordinates: axes, joint_count: joints, kinematics_joint_count: kinematicsJointCount, extra_joint_count: genericMachine.extra_joint_count ?? joints - kinematicsJointCount, linear_units: genericMachine.units?.linear ?? "mm", angular_units: genericMachine.units?.angular ?? "deg", limits, parameters: clone(genericMachine.kinematics_parameters ?? {}) }, home_limits: { joint_count: joints, kinematics_type: genericMachine.kinematics_type, coordinates: axes, ...limits } };
}
const kinematics = CASE_KINEMATICS[bundle.entrypoint] ? clone(CASE_KINEMATICS[bundle.entrypoint]) : null;
const trt = machineDefinition(bundle.entrypoint) ? readTrtConfiguration(bundle) : null;
let homeLimits = null;

View File

@@ -0,0 +1,158 @@
// 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));

View File

@@ -0,0 +1,53 @@
// Drift check: machine-kinematics-specs.json must match the limits declared in
// the LinuxCNC INIs. A null limit means the source declares none; inventing a
// number here would put a fabricated value in front of the operator.
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
// This file lives in web/workspace/, so the repo root is two levels up.
const root = resolve(new URL("../..", import.meta.url).pathname);
const specs = JSON.parse(readFileSync(join(root, "web/workspace/machine-kinematics-specs.json"), "utf8"));
assert.equal(specs.schema, "linuxcnc.machine-kinematics-specs/1");
function readCase(entrypoint) {
const path = join(root, entrypoint);
if (!existsSync(path)) return null;
let text = readFileSync(path, "utf8");
for (const match of text.matchAll(/^\s*(?:#)?INCLUDE\s+(\S+)/gim)) {
const include = join(dirname(path), match[1]);
if (existsSync(include)) text += `\n${readFileSync(include, "utf8")}`;
}
return text;
}
// `m` alone makes `$` match at the first line end, which truncated every body.
// The lookahead uses a "no more input" test instead of `$` so the section runs
// to the next header or to the end of the file.
const section = (text, name) => {
const match = text.match(new RegExp(`^\\[${name}\\]\\s*(?:\\r?\\n)([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "im"));
return match ? match[1] : null;
};
const value = (body, key) => body?.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\S+)`, "im"))?.[1] ?? null;
const generic = MACHINE_DEFINITIONS.filter((machine) => machine.runtime === "generic-wasm");
assert.equal(specs.cases.length, generic.length, "每个通用案例都应有规格");
for (const machine of generic) {
const spec = specs.cases.find((item) => item.machineId === machine.id);
assert.ok(spec, `${machine.id} 缺少规格`);
assert.equal(spec.jointCount, machine.joint_count, `${machine.id} 槽位数`);
assert.equal(spec.coordinates, machine.coordinates, `${machine.id} 坐标`);
assert.equal(spec.jointOrder.length, machine.joint_count, `${machine.id} 槽位键数量`);
assert.equal(spec.limitsByJoint.length, machine.joint_count, `${machine.id} 限位数量`);
const text = readCase(machine.entrypoint);
assert.ok(text, `${machine.id} 的 INI 不存在:${machine.entrypoint}`);
for (let index = 0; index < machine.joint_count; index += 1) {
const body = section(text, `JOINT_${index}`);
const lower = value(body, "MIN_LIMIT"), upper = value(body, "MAX_LIMIT");
const expected = lower !== null && upper !== null && Number.isFinite(Number(lower)) && Number.isFinite(Number(upper))
? [Number(lower), Number(upper)] : null;
assert.deepEqual(spec.limitsByJoint[index], expected,
`${machine.id} J${index} 限位与 INI 不一致(spec=${JSON.stringify(spec.limitsByJoint[index])} ini=${JSON.stringify(expected)})`);
}
}
console.log(JSON.stringify({ schema: "machine-kinematics-specs.v1", status: "verified", cases: specs.cases.length, checks: ["槽位数与运行目录一致", "逐关节限位与 INI 完全一致", "INI 未声明的限位保持 null"] }));

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { existsSync, statSync } from "node:fs";
import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
import { VISUAL_BINDINGS, bindingGlbPath, bindingJointModel, hasVisualBinding, visualBinding } from "./machine-visual-bindings.mjs";
const MAX_BYTES = 64 * 1024 * 1024;
assert.equal(VISUAL_BINDINGS.cases.length, MACHINE_DEFINITIONS.length, "每个案例都必须有绑定");
for (const machine of MACHINE_DEFINITIONS) {
const binding = visualBinding(machine.id);
assert.ok(binding, `${machine.id} 缺少绑定`);
assert.equal(binding.jointCount, machine.joint_count, `${machine.id} 槽位数`);
assert.equal(binding.coordinates, machine.coordinates, `${machine.id} 坐标`);
const file = `web/react/public/${bindingGlbPath(machine.id)}`;
assert.ok(existsSync(file), `${machine.id} GLB 缺失`);
assert.ok(statSync(file).size <= MAX_BYTES, `${machine.id} GLB 超限`);
}
assert.ok(hasVisualBinding("xyzac-trt"));
assert.ok(visualBinding("linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"), "entrypoint 查找");
assert.equal(visualBinding("no-such-case"), null);
assert.equal(visualBinding("XYZAB"), null, "坐标字母不得作为键(三个案例共用)");
// bindingJointModel reports per-slot gaps instead of guessing nodes.
const bridge = visualBinding("bridge-mill-5axiskins");
const nodes = bridge.bindings.map((item) => item.nodeId);
const full = bindingJointModel(bridge, nodes);
assert.deepEqual(full.missing, []);
assert.equal(full.slotMap.length, 7);
assert.equal(full.controller.jointOrder[6].key, "Y#2");
const partial = bindingJointModel(bridge, nodes.filter((id) => id !== bridge.bindings.find((item) => item.node === "w").nodeId));
assert.deepEqual(partial.missing, ["W"]);
assert.equal(partial.slotMap[5], null);
// Passive slots are allowed to stay unbound (max5 joint 5 has no visual motion).
const max5 = visualBinding("max5-maxkins");
const max5Model = bindingJointModel(max5, max5.bindings.map((item) => item.nodeId));
assert.deepEqual(max5Model.missing, []);
console.log(JSON.stringify({ schema: "machine-visual-bindings.v1", status: "verified", cases: VISUAL_BINDINGS.cases.length, checks: ["13 案例全覆盖", "槽位数与 joint_count 一致", "GLB 存在且 ≤64 MiB", "坐标字母不作键", "缺节点逐槽报告", "被动槽允许不绑定"] }));