Files
linuxcncWebWASM/web/workspace/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

147 lines
10 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 标定候选。
// 抽成纯函数模块,便于被分析器(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 };
}