关节契约扩为槽位制:以 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>
199 lines
13 KiB
TypeScript
199 lines
13 KiB
TypeScript
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}`;
|
||
}
|