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