Make 3D machine manager axis controls configuration driven

This commit is contained in:
2026-09-30 20:30:07 +08:00
parent 469881794c
commit af65b69b2c
14 changed files with 705 additions and 116 deletions

View File

@@ -0,0 +1,134 @@
import * as THREE from "three";
import { CNC_AXES, defaultDof, isFiveAxisCoordinates, isLinear, validJointDof, type CncAxis, type JointDof } from "./machine-joints";
/** 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;
};
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: "generic-five-axis" | "unknown";
rtcp: { mode: "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
warnings: string[];
};
const AXES = CNC_AXES;
const axisSet = new Set<string>(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)];
function axisFromName(name: string): { axis: CncAxis; exact: boolean } | null {
const normalized = name.trim().toUpperCase();
const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
if (exact && axisSet.has(exact[1])) return { axis: exact[1] as CncAxis, exact: true };
const token = normalized.match(/(?:^|[_\-\s])(?:JT|JOINT|AXIS|LINEAR|ROTARY)[_-]?([XYZABCUVW])(?:$|[_\-\s])/)
?? normalized.match(/(?:^|[_\-\s])([XYZABCUVW])[_-]?(?:AXIS|JOINT|LINEAR|ROTARY)(?:$|[_\-\s])/);
return token && axisSet.has(token[1]) ? { axis: token[1] as CncAxis, exact: false } : 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("/")}` : ""}`;
}
/**
* 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): GlbFiveAxisAnalysis {
scene.updateMatrixWorld(true);
const axes: GlbAxisEvidence[] = [];
const warnings: string[] = [];
const duplicateAxes = new Set<string>();
const bounds = new THREE.Box3();
let nodeCount = 0, meshCount = 0;
scene.traverse((node) => {
nodeCount += 1;
const mesh = node as THREE.Mesh;
if (mesh.isMesh) { meshCount += 1; bounds.expandByObject(mesh); }
const named = axisFromName(node.name || "");
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 axis = explicit ? metadata?.axis : named?.axis;
if (!axisSet.has(axis)) { if (explicit) warnings.push(`${node.name}:轴元数据无效,请手动绑定`); return; }
const match = { axis: axis as CncAxis, exact: 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; }
if (axes.some((item) => item.axis === match.axis)) duplicateAxes.add(match.axis);
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: match.exact ? "high" : "medium", reason: explicit ? "GLB 关节元数据" : "节点名称提示;默认轴向需运动验证", joint, autoBind: validJointDof(joint),
};
axes.push(evidence);
});
const axisList = axes.sort((a, b) => AXES.indexOf(a.axis) - AXES.indexOf(b.axis));
for (const item of axisList) if (duplicateAxes.has(item.axis)) item.autoBind = false;
const available = new Set(axisList.filter((item) => item.autoBind).map((item) => item.axis));
const declared = scene.userData.linuxcncJointModel?.coordinates ?? scene.userData.linuxcncMachine?.coordinates;
const declaredValid = typeof declared === "string" && isFiveAxisCoordinates(declared) && [...declared].every((axis) => available.has(axis as CncAxis));
const detected = AXES.filter((axis) => available.has(axis)).join("");
const suggestedCoordinates = declaredValid ? declared : isFiveAxisCoordinates(detected) ? detected : "UNKNOWN";
const coordinateCandidates = suggestedCoordinates === "UNKNOWN" ? [] : [suggestedCoordinates];
const missingAxes = typeof declared === "string" && isFiveAxisCoordinates(declared) ? [...declared].filter((axis) => !available.has(axis as CncAxis)) : [];
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 = coordinateCandidates.length ? "generic-five-axis" : "unknown";
if (declared !== undefined && !declaredValid) warnings.push("声明的轴顺序与可用关节不一致,未采用该顺序");
if (available.size !== 5) warnings.push(`检测到 ${available.size} 个唯一轴;请配置五个轴及其绑定,不会丢弃多余候选`);
if (axisList.some((item) => item.confidence !== "high")) warnings.push("名称仅用于提示轴绑定;请确认轴向、旋转中心和父子关系");
if (duplicateAxes.size) warnings.push(`重复轴 ${[...duplicateAxes].join("、")}:保留所有候选,未自动绑定;请手动选择唯一节点`);
if (bounds.isEmpty()) warnings.push("模型没有可计算包围盒的网格");
const rtcp = { mode: coordinateCandidates.length ? "unverified" as const : "insufficient" as const,
reason: "轴名和层级不能证明 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, warnings };
}
export function analysisSummary(analysis: GlbFiveAxisAnalysis): string {
const axes = analysis.axes.map((item) => item.nodeName).join("、") || "未识别关节,可手动绑定";
return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · RTCP 待确认`;
}