import * as THREE from "three"; import { CNC_AXES, defaultDof, isFiveAxisCoordinates, isLinear, validJointDof, type CncAxis, type JointDof } from "./machine-joints"; import { managerProfile } from "./machine-manager-config"; import type { Vec3 } from "./machine-urdf"; import { classifyAxisNodeName as classifyAxisName, deriveTrtCalibration, selectAxisNodes } from "../../workspace/glb-axis-naming.mjs"; /** A single axis candidate found in a GLB node hierarchy. */ export type GlbAxisEvidence = { axis: CncAxis; nodeName: string; path: string; parentName: string; parentPath: string; depth: number; originMm: [number, number, number]; worldOriginMm: [number, number, number]; expectedKind: "linear" | "rotary"; confidence: "high" | "medium" | "low"; reason: string; joint: JointDof; autoBind: boolean; rank: number; }; /** TRT 标定候选:由旋转轴节点的世界坐标推导,写入管理器标定页作为初值。 */ export type GlbTrtCalibration = { schema: "linuxcnc.trt-calibration-candidate/1"; source: "axis-world-origin"; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: Record; zeroedOffsetDirection?: "x" | "y" | "z"; zeroedOffsetValueMm?: number; }; export type GlbFiveAxisAnalysis = { schema: "linuxcnc.glb-five-axis-analysis/1"; filename?: string; nodeCount: number; meshCount: number; boundsMm: { min: [number, number, number]; max: [number, number, number]; size: [number, number, number] }; axes: GlbAxisEvidence[]; missingAxes: string[]; linearChain: string[]; rotaryChain: string[]; chains: string[][]; coordinateCandidates: string[]; suggestedCoordinates: string | "UNKNOWN"; suggestedKinematics: "xyzac-trt-kins" | "xyzbc-trt-kins" | "generic-five-axis" | "unknown"; rtcp: { mode: "native-trt" | "unverified" | "insufficient"; reason: string; requiredParameters: string[] }; calibration: GlbTrtCalibration | null; warnings: string[]; }; export type AxisNameHint = { axis: CncAxis; confidence: "high" | "medium" | "low"; rank: number; reason: string; ambiguous?: "tilt" | "rotary" }; const AXES = CNC_AXES; const axisSet = new Set(CNC_AXES); const round = (value: number) => Number(value.toFixed(6)); const mm = (v: THREE.Vector3): [number, number, number] => [round(v.x * 1000), round(v.y * 1000), round(v.z * 1000)]; /** 纯函数:把 GLB 节点名归类为某个数控轴及其可信度(规则见 web/workspace/glb-axis-naming.mjs)。 */ export function classifyAxisNodeName(name: string): AxisNameHint | null { return classifyAxisName(name) as AxisNameHint | null; } function objectPath(node: THREE.Object3D): string { const parts: string[] = []; let current: THREE.Object3D | null = node; while (current?.parent) { const index = current.parent.children.indexOf(current); parts.unshift(String(index)); current = current.parent; } return `scene${parts.length ? `/${parts.join("/")}` : ""}`; } /** * 内嵌 URDF 的机床(内置 AC/BC 示例)用场景元数据没有 coordinates 字段, * 但关节上带有 标注,按文档顺序即可还原轴序。 */ function coordinatesFromUrdf(urdf: unknown): string | null { if (typeof urdf !== "string") return null; const axes = [...urdf.matchAll(/ match[1]); const unique = [...new Set(axes)]; return unique.length === 5 ? unique.join("") : null; } /** * Inspect an already parsed Three.js GLB scene. The heuristic intentionally * prioritizes explicit joint metadata and uses names only as hints. It never mutates * geometry and can therefore be reused for previews, import validation and * offline model review. */ export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string, options: { expectedCoordinates?: string } = {}): GlbFiveAxisAnalysis { scene.updateMatrixWorld(true); const warnings: string[] = []; const bounds = new THREE.Box3(); let nodeCount = 0, meshCount = 0; type Candidate = { evidence: GlbAxisEvidence; hint: AxisNameHint | null; explicitAxis: CncAxis | null }; const candidates: Candidate[] = []; scene.traverse((node) => { nodeCount += 1; const mesh = node as THREE.Mesh; if (mesh.isMesh) { meshCount += 1; bounds.expandByObject(mesh); } const data = node.userData; const explicit = Object.hasOwn(data, "linuxcncJoint") || Object.hasOwn(data, "linuxcnc.axis"); if (Object.hasOwn(data, "linuxcncJoint") && data.linuxcncJoint === null) return; const metadata = data.linuxcncJoint ?? (Object.hasOwn(data, "linuxcnc.axis") ? { axis: data["linuxcnc.axis"], direction: data["linuxcnc.axis_direction"], sign: data["linuxcnc.axis_sign"], frame: data["linuxcnc.axis_frame"] } : undefined); const hint = classifyAxisNodeName(node.name || ""); const axis = explicit ? metadata?.axis : hint?.axis; if (!axisSet.has(axis)) { if (explicit) warnings.push(`${node.name}:轴元数据无效,请手动绑定`); return; } const match = { axis: axis as CncAxis, explicit }; const direction = metadata?.direction ?? defaultDof(match.axis).direction; const joint: JointDof = { axis: match.axis, direction: Array.isArray(direction) ? "custom" : direction, sign: metadata?.sign ?? 1, ...(metadata?.frame !== undefined ? { frame: metadata.frame } : {}), ...(Array.isArray(direction) ? { vector: direction } : metadata?.vector ? { vector: metadata.vector } : {}) }; if (!validJointDof(joint) || (metadata?.type && metadata.type !== (isLinear(match.axis) ? "prismatic" : "revolute"))) { warnings.push(`${node.name}:关节方向、参考系或类型无效,请手动配置`); return; } const parent = node.parent ?? scene; const evidence: GlbAxisEvidence = { axis: match.axis, nodeName: node.name || `(unnamed ${match.axis})`, path: objectPath(node), parentName: parent.name || "scene", parentPath: objectPath(parent), depth: objectPath(node).split("/").length - 1, originMm: mm(node.position), worldOriginMm: mm(node.getWorldPosition(new THREE.Vector3())), expectedKind: isLinear(match.axis) ? "linear" : "rotary", confidence: explicit ? "high" : hint?.confidence ?? "low", reason: explicit ? "GLB 关节元数据" : hint?.reason ?? "节点名称提示;默认轴向需运动验证", joint, autoBind: false, rank: explicit ? -1 : hint?.rank ?? 5, }; candidates.push({ evidence, hint, explicitAxis: explicit ? (match.axis as CncAxis) : null }); }); // 候选排序、通用倾转/回转命名消歧和机型判定都放在纯模块里,便于单测。 const expected = isFiveAxisCoordinates(options.expectedCoordinates ?? "") ? options.expectedCoordinates as string : null; const declaredRaw = scene.userData.linuxcncJointModel?.coordinates ?? scene.userData.linuxcncMachine?.coordinates ?? coordinatesFromUrdf(scene.userData.linuxcncMachine?.urdf); const declaredHint = typeof declaredRaw === "string" && isFiveAxisCoordinates(declaredRaw) ? declaredRaw : null; const coordinateHint = expected ?? declaredHint; const profile = coordinateHint ? managerProfile(coordinateHint) : undefined; const selection = selectAxisNodes( candidates.map((item) => ({ name: item.evidence.nodeName, path: item.evidence.path, depth: item.evidence.depth, explicitAxis: item.explicitAxis })), { declaredCoordinates: declaredHint, expectedCoordinates: expected, tiltAxis: profile?.tiltAxis ?? null, rotaryAxis: profile?.rotaryAxis ?? null }, ) as { axes: Array<{ axis: string; path: string; rank: number; confidence: GlbAxisEvidence["confidence"]; reason: string; autoBind: boolean }>; suggestedCoordinates: string; missingAxes: string[]; warnings: string[] }; warnings.push(...selection.warnings); const axisList = selection.axes.flatMap((item) => { const candidate = candidates.find((entry) => entry.evidence.path === item.path); if (!candidate) return []; const axis = item.axis as CncAxis; candidate.evidence.axis = axis; candidate.evidence.rank = item.rank; candidate.evidence.confidence = item.confidence; candidate.evidence.reason = item.reason; candidate.evidence.autoBind = item.autoBind; candidate.evidence.expectedKind = isLinear(axis) ? "linear" : "rotary"; candidate.evidence.joint = { ...candidate.evidence.joint, axis }; return [candidate.evidence]; }); const suggestedCoordinates: string | "UNKNOWN" = selection.suggestedCoordinates as string | "UNKNOWN"; const coordinateCandidates = suggestedCoordinates === "UNKNOWN" ? [] : [suggestedCoordinates]; const missingAxes = selection.missingAxes; const available = new Set(axisList.filter((item) => item.autoBind).map((item) => item.axis)); const sorted = [...axisList].sort((a, b) => a.depth - b.depth); const nearestAxis = (item: GlbAxisEvidence) => sorted.filter((other) => item.path.startsWith(`${other.path}/`)).at(-1); const chains = sorted.filter((item) => !sorted.some((other) => nearestAxis(other) === item)).map((leaf) => { const chain = [leaf.nodeName]; let node = nearestAxis(leaf); while (node) { chain.unshift(node.nodeName); node = nearestAxis(node); } return chain; }); const linearChain = sorted.filter((item) => isLinear(item.axis)).map((item) => item.nodeName); const rotaryChain = sorted.filter((item) => !isLinear(item.axis)).map((item) => item.nodeName); const suggestedKinematics: GlbFiveAxisAnalysis["suggestedKinematics"] = suggestedCoordinates === "XYZAC" ? "xyzac-trt-kins" : suggestedCoordinates === "XYZBC" ? "xyzbc-trt-kins" : coordinateCandidates.length ? "generic-five-axis" : "unknown"; if (available.size !== 5) warnings.push(`检测到 ${available.size} 个唯一轴;请配置五个轴及其绑定,不会丢弃多余候选`); if (axisList.some((item) => item.confidence !== "high")) warnings.push("名称仅用于提示轴绑定;请确认轴向、旋转中心和父子关系"); if (bounds.isEmpty()) warnings.push("模型没有可计算包围盒的网格"); // 原生 TRT 标定候选:C 轴回转中心取 C 轴节点世界坐标,倾转偏置取倾转轴节点与之的差, // 并沿倾转轴方向(该机型运动学约束为 0 的分量)归零。 const trtProfile = suggestedCoordinates === "XYZAC" || suggestedCoordinates === "XYZBC" ? managerProfile(suggestedCoordinates) : undefined; const calibration = (trtProfile ? deriveTrtCalibration({ coordinates: suggestedCoordinates, profile: trtProfile, cWorldMm: axisList.find((item) => item.axis === trtProfile.rotaryAxis)?.worldOriginMm, tiltWorldMm: axisList.find((item) => item.axis === trtProfile.tiltAxis)?.worldOriginMm, }) : null) as GlbTrtCalibration | null; if (calibration?.zeroedOffsetDirection && calibration.zeroedOffsetValueMm !== undefined) { warnings.push(`按 ${suggestedCoordinates} 运动学约束,${trtProfile?.tiltAxis}→${trtProfile?.rotaryAxis} 偏置的 ${calibration.zeroedOffsetDirection.toUpperCase()} 分量 ${calibration.zeroedOffsetValueMm.toFixed(3)} mm 已归零`); } const rtcp = calibration ? { mode: "native-trt" as const, reason: `已按旋转轴节点的世界坐标推导 ${suggestedCoordinates} 的原生 TRT 标定候选(C 轴原点与${trtProfile?.tiltAxis}→C 偏置);请在三维机床管理中复核后回零验证`, requiredParameters: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] } : { mode: coordinateCandidates.length ? "unverified" as const : "insufficient" as const, reason: coordinateCandidates.length ? "已识别五轴结构但无法推导原生 TRT 标定;请确认刀具/工件链和轴心" : "轴名和层级不能证明 RTCP 运动学;请确认刀具/工件链、轴心及匹配的运行适配器", requiredParameters: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] }; const boxMin = bounds.isEmpty() ? new THREE.Vector3() : bounds.min; const boxMax = bounds.isEmpty() ? new THREE.Vector3() : bounds.max; return { schema: "linuxcnc.glb-five-axis-analysis/1", filename, nodeCount, meshCount, boundsMm: { min: mm(boxMin), max: mm(boxMax), size: mm(boxMax.clone().sub(boxMin)) }, axes: axisList, missingAxes, linearChain, rotaryChain, chains, coordinateCandidates, suggestedCoordinates, suggestedKinematics, rtcp, calibration, warnings }; } export function analysisSummary(analysis: GlbFiveAxisAnalysis): string { const axes = analysis.axes.map((item) => item.nodeName).join("、") || "未识别关节,可手动绑定"; const rtcp = analysis.rtcp.mode === "native-trt" ? "原生 TRT RTCP" : "RTCP 待确认"; return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · ${rtcp}`; }