Files
linuxcncWebWASM/web/react/src/glb-five-axis-analyzer.ts
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

199 lines
13 KiB
TypeScript
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.
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<string, number>;
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<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)];
/** 纯函数:把 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 字段,
* 但关节上带有 <cnc:axis coordinate="X"/> 标注,按文档顺序即可还原轴序。
*/
function coordinatesFromUrdf(urdf: unknown): string | null {
if (typeof urdf !== "string") return null;
const axes = [...urdf.matchAll(/<cnc:axis\s+coordinate="([XYZABCUVW])"/g)].map((match) => 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}`;
}