按关节槽位驱动 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:
146
web/workspace/glb-axis-naming.mjs
Normal file
146
web/workspace/glb-axis-naming.mjs
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user