按关节槽位驱动 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

@@ -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 };
}