按关节槽位驱动 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>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -0,0 +1,13 @@
(CASE_ID: bridge-mill-5axiskins)
(CONTROL_PROFILE: XYZBCWY)
(KINEMATICS: 5axiskins)
(STATUS: adapter-required)
(NOTE: W 沿刀具方向;配置含重复 Y 关节。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 B3 C8 W1 F120
G1 X15 Y-5 Z-4 B-3 C-8 W-1 F120
G1 X5 Y5 Z-2 B3 C8 W1 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,9 @@
(CASE_ID: hexapod-genhexkins)
(CONTROL_PROFILE: XYZABC)
(KINEMATICS: genhexkins)
(STATUS: adapter-required)
(NOTE: 六支链并联平台;XYZABC 六自由度。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: max5-maxkins)
(CONTROL_PROFILE: XYZBC)
(KINEMATICS: maxkins)
(STATUS: adapter-required)
(NOTE: B/C 非恒等旋转映射;不是 TRT 替代。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 B3 C8 F120
G1 X15 Y-5 Z-4 B-3 C-8 F120
G1 X5 Y5 Z-2 B3 C8 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,9 @@
(CASE_ID: pentapod-pentakins)
(CONTROL_PROFILE: XYZAB)
(KINEMATICS: pentakins)
(STATUS: adapter-required)
(NOTE: 五支链并联平台;XYZAB 五轴。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
M30

View File

@@ -0,0 +1,2 @@
Procedural design-reference assets for linuxcncWebWASM. CC0-1.0.
No manufacturer affiliation or dimensional calibration is implied.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,13 @@
(CASE_ID: puma-genserkins)
(CONTROL_PROFILE: XYZABC)
(KINEMATICS: genserkins)
(STATUS: adapter-required)
(NOTE: DH 参数驱动的六轴串联机器人。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X0 Y0 Z0 A0 B0 C0 F120
G1 X0 Y0 Z0 A0 B0 C0 F120
G1 X0 Y0 Z0 A0 B0 C0 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,9 @@
(CASE_ID: puma-pumakins)
(CONTROL_PROFILE: XYZABC)
(KINEMATICS: pumakins)
(STATUS: adapter-required)
(NOTE: PUMA 六关节串联机器人。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
M30

View File

@@ -0,0 +1,9 @@
(CASE_ID: scorbot-kins)
(CONTROL_PROFILE: XYZAB)
(KINEMATICS: scorbot-kins)
(STATUS: adapter-required)
(NOTE: 五关节串联机器人;不是五轴铣床 TRT。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: trivkins-xyzab)
(CONTROL_PROFILE: XYZAB)
(KINEMATICS: trivkins)
(STATUS: identity-only)
(NOTE: 五轴一一映射基线;不提供 RTCP。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 A3 B3 F120
G1 X15 Y-5 Z-4 A-3 B-3 F120
G1 X5 Y5 Z-2 A3 B3 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: trivkins-xyzcw)
(CONTROL_PROFILE: XYZCW)
(KINEMATICS: trivkins)
(STATUS: identity-only)
(NOTE: C/W 轴字母映射基线;不提供 RTCP。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 C8 W1 F120
G1 X15 Y-5 Z-4 C-8 W-1 F120
G1 X5 Y5 Z-2 C8 W1 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: trivkins-xyzya)
(CONTROL_PROFILE: XYZYA)
(KINEMATICS: trivkins)
(STATUS: identity-only)
(NOTE: Y 轴重复映射与旋转 A 基线;不提供 RTCP。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 A3 F120
G1 X15 Y-5 Z-4 A-3 F120
G1 X5 Y5 Z-2 A3 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: xyzab-tdr)
(CONTROL_PROFILE: XYZAB)
(KINEMATICS: xyzab_tdr_kins)
(STATUS: adapter-required)
(NOTE: LinuxCNC xyzab_tdr_kins;当前 Web 尚未接入该求解器。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 A3 B3 F120
G1 X15 Y-5 Z-4 A-3 B-3 F120
G1 X5 Y5 Z-2 A3 B3 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: xyzac-trt)
(CONTROL_PROFILE: XYZAC)
(KINEMATICS: xyzac-trt-kins)
(STATUS: supported)
(NOTE: A 绕 X、C 绕 Z;Web LinuxCNC WASM 原生可运行。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 A3 C8 F120
G1 X15 Y-5 Z-4 A-3 C-8 F120
G1 X5 Y5 Z-2 A3 C8 F120
G1 X0 Y0 Z0 F120
M30

View File

@@ -0,0 +1,13 @@
(CASE_ID: xyzbc-trt)
(CONTROL_PROFILE: XYZBC)
(KINEMATICS: xyzbc-trt-kins)
(STATUS: supported)
(NOTE: B 绕 Y、C 绕 Z;Web LinuxCNC WASM 原生可运行。)
(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
G21 G90 G94 G17 G40 G49 G80
G54
G1 X5 Y5 Z-2 B3 C8 F120
G1 X15 Y-5 Z-4 B-3 C-8 F120
G1 X5 Y5 Z-2 B3 C8 F120
G1 X0 Y0 Z0 F120
M30

File diff suppressed because one or more lines are too long

View File

@@ -25,9 +25,10 @@ export function MachineImportPanel({ model, disabled, onImport }: { model: Impor
<button type="button" data-axis-control="machine-glb-import" disabled={disabled} onClick={openFilePicker}>导入 GLB</button>
{examples.map(({ id, label, filename }) => <span key={id}><button type="button" data-axis-control={`machine-glb-${id}`} disabled={disabled} onClick={() => void run(`${import.meta.env.BASE_URL}machines/${filename}`)}>{label}</button><a href={`${import.meta.env.BASE_URL}machines/${filename}`} download>下载 {label.replace(" 示例", "")}</a></span>)}
</div>
{s && <details data-machine-structure><summary>{`底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}</summary><p>旋转中心 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_rot_point`].toFixed(3)).join(" / ")}</p><p>轴间偏置 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_offset`].toFixed(3)).join(" / ")}</p><p>运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。</p></details>}
{s && s.urdf !== model.scene.userData.linuxcncMachine?.urdf && <p>模型已按当前几何参数调整,原 GLB 文件未改写。</p>}
<output role="status">{message || (disabled ? "关机且程序空闲后可导入" : "单个 GLB,包含层级模型及 URDF,最大 32 MiB")}</output>
{s && <details data-machine-structure><summary>{s.profile === "generic-wasm/1" ? `槽位关节链:${[...s.coordinates].join(" / ")}(${s.joints.filter((j) => j.type !== "fixed").length} 个运动关节)` : `底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}</summary>{s.profile === "generic-wasm/1" ? <p>该案例使用通用 WASM 运动学,旋转中心由运动学参数和 INI 决定,不使用 TRT 偏置。</p> : <><p>旋转中心 (mm):{["x", "y", "z"].map((c) => Number(s.offsets[`${c}_rot_point`] ?? 0).toFixed(3)).join(" / ")}</p><p>轴间偏置 (mm):{["x", "y", "z"].map((c) => Number(s.offsets[`${c}_offset`] ?? 0).toFixed(3)).join(" / ")}</p></>}<p>运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。</p></details>}
{model?.glbAnalysis && <details open data-glb-runtime-analysis><summary>GLB 自动分析 · {model.glbAnalysis.suggestedCoordinates} · {model.rawAxisBindings?.length ?? 0}/5 轴已绑定</summary><p>{model.glbAnalysis.axes.map((axis) => `JT_${axis.axis} ← ${axis.parentName}`).join(";")}</p><p>实时关节值将直接驱动 GLB 节点;轴原点、方向和旋转中心可在三维机床管理中复核。</p>{model.glbAnalysis.warnings.length > 0 && <small>{model.glbAnalysis.warnings.join(";")}</small>}</details>}
{s && model.scene.userData.linuxcncMachine && s.urdf !== model.scene.userData.linuxcncMachine.urdf && <p>模型已按当前几何参数调整,原 GLB 文件未改写。</p>}
<output role="status">{message || (disabled ? "关机且程序空闲后可导入" : "单个 GLB,包含层级模型及 URDF,最大 64 MiB")}</output>
<input ref={file} hidden type="file" accept=".glb,model/gltf-binary" data-axis-control="machine-glb-file" onChange={(event) => { const selected = event.target.files?.[0]; event.target.value = ""; if (selected) void run(selected); }} />
</DraggableDialog>
</section>;

View File

@@ -14,6 +14,7 @@ import { machineDocumentFilename } from "./machine-document";
import { MachineNumberInput } from "./MachineManagerInputs";
import { inspectMachineGlb, MAX_MACHINE_BYTES, type Vec3 } from "./machine-urdf";
import { FIVE_AXIS_CASES, FIVE_AXIS_STATUS_LABELS } from "../../workspace/five-axis-machine-catalog.mjs";
import { hasVisualBinding } from "../../workspace/machine-visual-bindings.mjs";
import { analyzeGlbFiveAxisScene, analysisSummary, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
import { MANAGER_SETTINGS, managerAxes, managerProfile, validateManagerCalibration, zeroAxisValues } from "./machine-manager-config";
@@ -27,7 +28,11 @@ const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) :
const zeroPose = (coordinates: string): TestPose => zeroAxisValues(coordinates);
const axisUnit = (axis: CncAxis) => isLinear(axis) ? "mm" : "deg";
const catalogCoordinateOptions = [...new Set(FIVE_AXIS_CASES.map((item) => item.coordinates))];
const supportedCoordinateOptions = new Set(FIVE_AXIS_CASES.filter((item) => item.webWasm === "supported").map((item) => item.coordinates));
// A coordinate is selectable when the case has a complete visual binding, not
// when the catalog marks it "supported": all 13 cases are driven by WASM
// kinematics now, and the binding file is the authority for the visual side.
const supportedCoordinateOptions = new Set(FIVE_AXIS_CASES.filter((item) => hasVisualBinding(item.id)).map((item) => item.coordinates));
const caseSelectable = (item: { id: string }) => hasVisualBinding(item.id);
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, onSelectModel, command }: { command: PreviewCommand; coordinates: string; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record<string, MotionTransform>) => void; onSelectModel: (partId: string) => void }) {
type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: string) => void };
@@ -292,9 +297,18 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
disposeSources(loaded);
throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入");
}
const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeGlbFiveAxisScene(source.scene, source.filename)]));
// 先按模型自身命名/声明识别;无法消歧时再用"设计机型"作为提示,
// 这样先选机型再导入即可自动绑定(tilt_table / rotary_axis 这类通用命名依赖机型)。
const analyzeSource = (source: { scene: THREE.Group; filename: string }) => {
const automatic = analyzeGlbFiveAxisScene(source.scene, source.filename);
if (automatic.suggestedCoordinates !== "UNKNOWN") return automatic;
const hinted = analyzeGlbFiveAxisScene(source.scene, source.filename, { expectedCoordinates: draft.coordinates });
return hinted.suggestedCoordinates !== "UNKNOWN" ? hinted : automatic;
};
const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeSource(source)]));
const detectedCoordinates = loaded.length === 1 ? autoAnalysis.get(loaded[0].id)?.suggestedCoordinates : undefined;
const autoCoordinates = detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? detectedCoordinates : draft.coordinates;
const autoCalibration = loaded.length === 1 && !restored ? autoAnalysis.get(loaded[0].id)?.calibration ?? null : null;
const nextDraft = (() => {
const next = restored ? [] : [...draft.parts];
for (const source of loaded) {
@@ -312,11 +326,13 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const nodeRoles = source.restoredRoles ?? detectedRoles;
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints, nodeRoles });
}
return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : { coordinates: autoCoordinates }) };
return { ...draft, parts: next, ...(restored
? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero }
: { coordinates: autoCoordinates, ...(autoCalibration ? { cOriginMm: autoCalibration.cOriginMm as Vec3, tiltOffsetMm: autoCalibration.tiltOffsetMm as Vec3, physicalZero: autoCalibration.physicalZero as PhysicalZero } : {}) }) };
})();
edit(() => nextDraft);
if (restored) setSavedSignature(nextDraft.coordinates + draftFingerprint(nextDraft));
setTestPose(zeroPose(restored?.coordinates ?? autoCoordinates)); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件${detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? `;自动识别 ${detectedCoordinates},已绑定 ${autoAnalysis.get(loaded[0].id)?.axes.filter((item) => autoCoordinates.includes(item.axis)).length ?? 0}/${[...autoCoordinates].length} 轴` : ""}`);
setTestPose(zeroPose(restored?.coordinates ?? autoCoordinates)); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件${detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? `;自动识别 ${detectedCoordinates},已绑定 ${autoAnalysis.get(loaded[0].id)?.axes.filter((item) => autoCoordinates.includes(item.axis)).length ?? 0}/${[...autoCoordinates].length} 轴` : ""}${autoCalibration ? ";已按旋转轴几何填入标定初值" : ""}`);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
finally { setPending(false); }
};
@@ -473,11 +489,11 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const next = event.target.value;
edit((draft) => changeMachineType(draft, next)); setTab("structure");
setMessage(`设计机型已选择 ${next};保留模型层级,请核对 ${next} 轴绑定和标定。可撤销恢复。`);
}}>{catalogCoordinateOptions.map((axisSet) => { const caseItem = FIVE_AXIS_CASES.find((item) => item.coordinates === axisSet); return <option key={axisSet} value={axisSet} disabled={!supportedCoordinateOptions.has(axisSet)}>{caseItem?.label ?? axisSet}{supportedCoordinateOptions.has(axisSet) ? "" : " · 待 WASM 适配"}</option>; })}</select></label><label><strong>LinuxCNC 案例/运动学</strong><select data-axis-control="machine-manager-case-type" aria-label="LinuxCNC 案例与运动学" value={FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates && item.webWasm === "supported")?.id ?? FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates)?.id ?? FIVE_AXIS_CASES[0].id} disabled={pending || followLive} onChange={(event) => {
}}>{catalogCoordinateOptions.map((axisSet) => { const caseItem = FIVE_AXIS_CASES.find((item) => item.coordinates === axisSet); return <option key={axisSet} value={axisSet} disabled={!supportedCoordinateOptions.has(axisSet)}>{caseItem?.label ?? axisSet}{supportedCoordinateOptions.has(axisSet) ? "" : " · 缺少视觉绑定"}</option>; })}</select></label><label><strong>LinuxCNC 案例/运动学</strong><select data-axis-control="machine-manager-case-type" aria-label="LinuxCNC 案例与运动学" value={FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates && item.webWasm === "supported")?.id ?? FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates)?.id ?? FIVE_AXIS_CASES[0].id} disabled={pending || followLive} onChange={(event) => {
const selectedCase = FIVE_AXIS_CASES.find((item) => item.id === event.target.value);
if (!selectedCase || !supportedCoordinateOptions.has(selectedCase.coordinates)) { setMessage(`${selectedCase?.label ?? event.target.value} 已登记源码案例,但当前 Web/WASM 运动学适配尚未完成`); return; }
edit((draft) => changeMachineType(draft, selectedCase.coordinates)); setTab("structure"); setMessage(`已选择案例 ${selectedCase.label},设计机型同步为 ${selectedCase.coordinates}`);
}}>{FIVE_AXIS_CASES.map((item) => <option key={item.id} value={item.id} disabled={item.webWasm !== "supported"}>{item.label}{item.webWasm === "supported" ? "" : " · 待 WASM 适配"}</option>)}</select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库</small></div></div>
}}>{FIVE_AXIS_CASES.map((item) => <option key={item.id} value={item.id} disabled={!caseSelectable(item)}>{item.label}{caseSelectable(item) ? "" : " · 缺少视觉绑定"}</option>)}</select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库</small></div></div>
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={() => beginFilePicker(openFile.current)}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => beginFilePicker(file.current)}>导入 GLB 部件</button><button type="button" data-axis-control="machine-model-delete-selected" disabled={!selected || pending || followLive} title={selected ? `删除 ${selected.filename},可撤销恢复` : "请先选择导入模型"} onClick={() => { if (selected) remove(selected); }}>删除选中模型</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/{activeAxes.length} 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
<div className="machine-axis-overview" aria-label="数控轴绑定总览">{activeAxes.map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div>
<div className="machine-manager-layout">
@@ -489,7 +505,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
{parts.length > 0 && <div className="machine-model-list" data-axis-control="machine-model-list" aria-label="已导入 GLB 模型">{parts.map((part) => <div key={part.id} className={`machine-model-row ${selectedId === part.id ? "selected" : ""}`} data-machine-model-id={part.id}><button type="button" data-axis-control="machine-model-select" aria-pressed={selectedId === part.id} disabled={pending} onClick={() => selectModel(part.id)} title={part.filename}><strong>{part.filename}</strong><span>{part.role ? roleLabel(part.role, coordinates) : "未分配角色"} · {part.nodeJoints ? Object.keys(part.nodeJoints).length : 0} 个关节绑定</span></button><button type="button" data-axis-control="machine-model-delete" aria-label={`删除 ${part.filename}`} disabled={pending || followLive} onClick={() => remove(part)}>删除</button></div>)}</div>}
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration", "files"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 4] : event.key === "ArrowLeft" ? ids[(index + 3) % 4] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[3] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div>
<div id="machine-panel-structure" className="machine-structure-panel" role="tabpanel" aria-labelledby="machine-tab-structure" hidden={tab !== "structure"}>
<section className="machine-part-list"><header><h3>部件与层级</h3><span>{validation.model?.nodes.length ?? 0} 节点</span></header>{selectedAnalysis && <section className="glb-five-axis-analysis" data-axis-control="glb-five-axis-analysis"><header><h3>GLB 五轴结构分析</h3><button type="button" data-axis-control="glb-five-axis-export" onClick={() => { const body = JSON.stringify(selectedAnalysis, null, 2) + "\n"; const url = URL.createObjectURL(new Blob([body], { type: "application/json" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${selectedAnalysis.filename?.replace(/\.glb$/i, "") || "machine"}-five-axis-analysis.json`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }}>导出 JSON</button></header><p className="glb-analysis-summary" data-axis-control="glb-five-axis-summary">{analysisSummary(selectedAnalysis)}</p><div className="glb-analysis-grid"><span>节点 / 网格<strong>{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}</strong></span><span>建议运动学<strong>{selectedAnalysis.suggestedKinematics}</strong></span><span>RTCP<strong>{"待确认"}</strong></span></div><div className="glb-analysis-axes" aria-label="识别的机床轴">{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return <span key={axis} data-glb-analysis-axis={axis} className={item ? "detected" : "missing"}><b>{axis}</b>{item ? <small>{item.nodeName}<br />{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm</small> : <small>未识别</small>}</span>; })}</div><details><summary>结构链与 RTCP 参数</summary><p>直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}</p><p>旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}</p><p>节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}</p><p>{selectedAnalysis.rtcp.reason}</p><small>建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}</small>{selectedAnalysis.warnings.length > 0 && <ul>{selectedAnalysis.warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul>}</details></section>}<div className="machine-tree-tools"><input data-axis-control="machine-tree-search" type="search" aria-label="搜索节点名称或轴" placeholder="搜索节点名称 / 数控轴" value={query} onChange={(event) => setQuery(event.target.value)} /><button type="button" data-axis-control="machine-tree-expand" onClick={() => setExpansion((current) => ({ all: true, serial: current.serial + 1 }))}>展开</button><button type="button" data-axis-control="machine-tree-collapse" onClick={() => { setQuery(""); setExpansion((current) => ({ all: false, serial: current.serial + 1 })); }}>折叠</button></div>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
<section className="machine-part-list"><header><h3>部件与层级</h3><span>{validation.model?.nodes.length ?? 0} 节点</span></header>{selectedAnalysis && <section className="glb-five-axis-analysis" data-axis-control="glb-five-axis-analysis"><header><h3>GLB 五轴结构分析</h3><button type="button" data-axis-control="glb-five-axis-export" onClick={() => { const body = JSON.stringify(selectedAnalysis, null, 2) + "\n"; const url = URL.createObjectURL(new Blob([body], { type: "application/json" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${selectedAnalysis.filename?.replace(/\.glb$/i, "") || "machine"}-five-axis-analysis.json`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }}>导出 JSON</button></header><p className="glb-analysis-summary" data-axis-control="glb-five-axis-summary">{analysisSummary(selectedAnalysis)}</p><div className="glb-analysis-grid"><span>节点 / 网格<strong>{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}</strong></span><span>建议运动学<strong>{selectedAnalysis.suggestedKinematics}</strong></span><span>RTCP<strong>{selectedAnalysis.rtcp.mode === "native-trt" ? "原生 TRT" : "待确认"}</strong></span></div><div className="glb-analysis-axes" aria-label="识别的机床轴">{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return <span key={axis} data-glb-analysis-axis={axis} className={item ? "detected" : "missing"}><b>{axis}</b>{item ? <small>{item.nodeName}<br />{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm</small> : <small>未识别</small>}</span>; })}</div><details><summary>结构链与 RTCP 参数</summary><p>直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}</p><p>旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}</p><p>节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}</p><p>{selectedAnalysis.rtcp.reason}</p>{selectedAnalysis.calibration && <p>标定候选:C 轴原点 {selectedAnalysis.calibration.cOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm;{selectedAnalysis.suggestedCoordinates[3]} 到 C 轴偏置 {selectedAnalysis.calibration.tiltOffsetMm.map((value) => value.toFixed(1)).join(" / ")} mm</p>}<small>建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}</small>{selectedAnalysis.warnings.length > 0 && <ul>{selectedAnalysis.warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul>}</details></section>}<div className="machine-tree-tools"><input data-axis-control="machine-tree-search" type="search" aria-label="搜索节点名称或轴" placeholder="搜索节点名称 / 数控轴" value={query} onChange={(event) => setQuery(event.target.value)} /><button type="button" data-axis-control="machine-tree-expand" onClick={() => setExpansion((current) => ({ all: true, serial: current.serial + 1 }))}>展开</button><button type="button" data-axis-control="machine-tree-collapse" onClick={() => { setQuery(""); setExpansion((current) => ({ all: false, serial: current.serial + 1 })); }}>折叠</button></div>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
{selected && <section className="machine-part-editor"><header><h3 title={selected.filename}>{selectedTreeNode?.name ?? selected.filename}<small>{selected.filename}</small></h3><button type="button" data-axis-control="machine-manager-delete" disabled={pending || followLive} title="删除整个导入文件,可通过撤销恢复" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <details className="machine-origin-details"><summary>零姿态与当前坐标</summary><div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div></details>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" disabled={pending || followLive} value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{CNC_AXES.map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {axisUnit(axis)}</option>)}</select></label>
{selectedDof && <><label>父级运动方向<select data-axis-control="machine-joint-direction" disabled={pending || followLive} value={selectedDof.direction} onChange={(event) => setDof({ ...selectedDof, direction: event.target.value as "x" | "y" | "z" })}>{["x", "y", "z"].map((axis) => <option key={axis} value={axis}>{axis.toUpperCase()}</option>)}</select></label><label>正负方向<select data-axis-control="machine-joint-sign" disabled={pending || followLive} value={selectedDof.sign} onChange={(event) => setDof({ ...selectedDof, sign: Number(event.target.value) as 1 | -1 })}><option value="1">正向 +1</option><option value="-1">反向 −1</option></select></label></>}
<label>节点结构角色<select data-axis-control="machine-manager-role" disabled={pending || followLive} value={selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? ""} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label>{selectedNodePath === "scene" ? <label>模型装配父级<select data-axis-control="machine-manager-parent" disabled={pending || followLive} value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => parentAllowed(selected.id, part.id)).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label> : <p className="machine-parent-note">内部节点父级由层级树拖放管理</p>}<div className="machine-part-flags"><label><input type="checkbox" disabled={pending || followLive} checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示导入模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" disabled={pending || followLive} checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div><p className="machine-transform-reference">当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}</p>

View File

@@ -11,6 +11,7 @@ export type RtcpProgram = {
role: "main" | "support" | "reference";
features: string[];
note: string;
machineId?: string;
};
type Catalog = {
@@ -26,8 +27,10 @@ const roleLabel = (role: RtcpProgram["role"]) => role === "main" ? "主程序" :
const assetUrl = (path: string) => `${import.meta.env.BASE_URL}${path}`;
const machineLabel = (coordinates: string) => coordinates === "XYZAC" ? "AC(XYZAC)" : coordinates === "XYZBC" ? "BC(XYZBC)" : coordinates;
export function RtcpProgramLibrary({ coordinates, disabled, disabledReason, onLoad }: {
export function RtcpProgramLibrary({ coordinates, machineId, machineRuntime, disabled, disabledReason, onLoad }: {
coordinates: string;
machineId: string;
machineRuntime: string;
disabled: boolean;
disabledReason: string;
onLoad: (program: RtcpProgram, source: string) => Promise<void>;
@@ -41,13 +44,13 @@ export function RtcpProgramLibrary({ coordinates, disabled, disabledReason, onLo
const [message, setMessage] = useState("");
const [pending, setPending] = useState(false);
const selected = RTCP_PROGRAM_CATALOG.programs.find((program) => program.id === selectedId) ?? defaultProgram;
const compatible = selected.role === "main" && selected.coordinates.includes(coordinates);
const compatible = selected.role === "main" && (selected.machineId ? selected.machineId.length > 0 : selected.coordinates.includes(coordinates) && machineRuntime === "trt");
const machinePrograms = useMemo(() => RTCP_PROGRAM_CATALOG.programs.filter((program) => program.coordinates.includes(coordinates)), [coordinates]);
const visiblePrograms = scope === "machine" ? machinePrograms : RTCP_PROGRAM_CATALOG.programs;
const grouped = RTCP_PROGRAM_CATALOG.groups.map((group) => ({ ...group, programs: visiblePrograms.filter((program) => program.group === group.id) })).filter((group) => group.programs.length > 0);
useEffect(() => {
setScope("machine"); setSelectedId(defaultProgram.id); setSource(""); setSourcePath(""); setMessage("");
}, [coordinates, defaultProgram.id]);
}, [coordinates, machineId, machineRuntime, defaultProgram.id]);
const readSource = async () => {
if (sourcePath === selected.path && source) return source;
@@ -69,16 +72,16 @@ export function RtcpProgramLibrary({ coordinates, disabled, disabledReason, onLo
finally { setPending(false); }
};
const compatibility = compatible
? `${coordinates} 当前机型可加载;运动由 LinuxCNC WASM 解释器与运动学执行。`
? `${selected.machineId ? `${selected.machineId} · ` : ""}${coordinates} 当前机型可加载;运动由 LinuxCNC WASM 解释器与配置的运动学执行。`
: selected.role !== "main"
? `${roleLabel(selected.role)}用于 remap、O-call 或算法查阅,不作为独立主程序加载。`
: `需要 ${selected.coordinates.join("/")} 运动学;当前机型为 ${coordinates}。`;
: selected.machineId ? `需要机型 ${selected.machineId};当前机型为 ${machineId}。` : `该源程序要求原生 TRT;当前运行时为 ${machineRuntime}。`;
return <section className="rtcp-program-library" aria-label={`${machineLabel(coordinates)} RTCP 程序库`} data-rtcp-machine-family={coordinates === "XYZAC" ? "AC" : coordinates === "XYZBC" ? "BC" : coordinates}>
<button type="button" data-axis-control="workspace-rtcp-programs" aria-haspopup="dialog" onClick={() => setOpen(true)}>{machineLabel(coordinates)} RTCP 程序 ({machinePrograms.length})</button>
<DraggableDialog open={open} title={`${machineLabel(coordinates)} LinuxCNC RTCP 程序库`} className="rtcp-program-dialog" onClose={() => setOpen(false)}>
<header className="rtcp-program-summary"><strong>{machineLabel(coordinates)} 程序</strong><span>当前分类:{machinePrograms.length} 个</span><span>可直接加载:{machinePrograms.filter((program) => program.role === "main").length}</span><span>完整源程序:{RTCP_PROGRAM_CATALOG.total} 个</span></header>
<label className="rtcp-program-scope">显示范围<select data-axis-control="rtcp-program-scope" value={scope} onChange={(event) => { const next = event.target.value as "machine" | "all"; setScope(next); if (next === "machine" && !selected.coordinates.includes(coordinates)) setSelectedId(defaultProgram.id); setSource(""); setSourcePath(""); setMessage(""); }}><option value="machine">当前 {machineLabel(coordinates)} 机床</option><option value="all">全部 32 个源程序</option></select></label>
<header className="rtcp-program-summary"><strong>{machineLabel(coordinates)} 程序</strong><span>当前分类:{machinePrograms.length} 个</span><span>可直接加载:{machinePrograms.filter((program) => program.role === "main").length}</span><span>LinuxCNC 源程序:32 个 · 含案例干运行程序共 {RTCP_PROGRAM_CATALOG.total} 个</span></header>
<label className="rtcp-program-scope">显示范围<select data-axis-control="rtcp-program-scope" value={scope} onChange={(event) => { const next = event.target.value as "machine" | "all"; setScope(next); if (next === "machine" && !selected.coordinates.includes(coordinates)) setSelectedId(defaultProgram.id); setSource(""); setSourcePath(""); setMessage(""); }}><option value="machine">当前 {machineLabel(coordinates)} 机床</option><option value="all">全部 {RTCP_PROGRAM_CATALOG.total} 个程序</option></select></label>
<label className="rtcp-program-select">程序<select data-axis-control="rtcp-program-select" value={selected.id} onChange={(event) => { setSelectedId(event.target.value); setSource(""); setSourcePath(""); setMessage(""); }}>
{grouped.map((group) => <optgroup key={group.id} label={`${group.label} (${group.programs.length})`}>{group.programs.map((program) => <option key={program.id} value={program.id}>{program.label} · {roleLabel(program.role)}</option>)}</optgroup>)}
</select></label>

View File

@@ -32,7 +32,7 @@ export function XyzacManualPanel({ view }: { view: XyzacPanelView }) {
return <section className="xyzac-manual-summary" aria-label={`${machine.coordinates} authoritative position`}>
<div className="xyzac-section-title"><strong>{machine.coordinates} authority</strong><span>{view.kinematics?.linear_units ?? "mm"} / {view.kinematics?.angular_units ?? "deg"}</span></div>
<div className="xyzac-position-grid">
{axes.map((axis, index) => <div key={axis}><b>{axis.toUpperCase()}</b><output>{pose ? Number(pose[axis as keyof typeof pose] ?? Number.NaN).toFixed(["a", "b", "c"].includes(axis) ? 3 : 4) : "-"}</output><small>J{index} {view.joints.length === machine.joint_count ? Number(view.joints[index]).toFixed(4) : "-"}</small></div>)}
{axes.map((axis, index) => <div key={`${axis}-${index}`}><b>{axis.toUpperCase()}</b><output>{pose ? Number(pose[axis as keyof typeof pose] ?? Number.NaN).toFixed(["a", "b", "c"].includes(axis) ? 3 : 4) : "-"}</output><small>J{index} {view.joints.length === machine.joint_count ? Number(view.joints[index]).toFixed(4) : "-"}</small></div>)}
</div>
<div className="xyzac-offset-row">{offsetRows.map((row) => <span key={row}>{row}</span>)}</div>
</section>;

View File

@@ -1,5 +1,8 @@
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 = {
@@ -16,6 +19,18 @@ export type GlbAxisEvidence = {
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 = {
@@ -31,23 +46,22 @@ export type GlbFiveAxisAnalysis = {
chains: string[][];
coordinateCandidates: string[];
suggestedCoordinates: string | "UNKNOWN";
suggestedKinematics: "generic-five-axis" | "unknown";
rtcp: { mode: "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
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)];
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;
/** 纯函数:把 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 {
@@ -57,54 +71,85 @@ function objectPath(node: THREE.Object3D): string {
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): GlbFiveAxisAnalysis {
export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string, options: { expectedCoordinates?: 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;
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 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;
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, exact: explicit };
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; }
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),
confidence: explicit ? "high" : hint?.confidence ?? "low", reason: explicit ? "GLB 关节元数据" : hint?.reason ?? "节点名称提示;默认轴向需运动验证",
joint, autoBind: false, rank: explicit ? -1 : hint?.rank ?? 5,
};
axes.push(evidence);
candidates.push({ evidence, hint, explicitAxis: explicit ? (match.axis as CncAxis) : null });
});
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 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 = typeof declared === "string" && isFiveAxisCoordinates(declared) ? [...declared].filter((axis) => !available.has(axis as CncAxis)) : [];
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) => {
@@ -114,21 +159,40 @@ export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string
});
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("声明的轴顺序与可用关节不一致,未采用该顺序");
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 (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: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] };
// 原生 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, warnings };
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("、") || "未识别关节,可手动绑定";
return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · RTCP 待确认`;
const rtcp = analysis.rtcp.mode === "native-trt" ? "原生 TRT RTCP" : "RTCP 待确认";
return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · ${rtcp}`;
}

View File

@@ -1,11 +1,12 @@
import * as THREE from "three";
import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type AxisValues, type CncAxis, type JointZero } from "./machine-joints";
import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type JointRecordV2, type JointModelV2, type JointSlot, type JointDofV2, type AxisValues, type CncAxis, type JointZero } from "./machine-joints";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
import { readTrtConfiguration } from "../../workspace/trt-configuration.mjs";
import { readTrtGeometry, writeTrtGeometry } from "../../workspace/trt-geometry.mjs";
import type { XyzacVisualState } from "./xyzac-visual-adapter";
import { bindingJointModel, type VisualBinding } from "../../workspace/machine-visual-bindings.mjs";
import { analyzeGlbFiveAxisScene, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
export type RawAxisBinding = { axis: CncAxis; path: string; zero: JointZero };
export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial<Record<Lowercase<CncAxis>, number>>; glbAnalysis?: GlbFiveAxisAnalysis; rawAxisBindings?: RawAxisBinding[] };
@@ -14,7 +15,7 @@ export function disposeMachineScene(scene: THREE.Object3D) {
scene.traverse((node) => { const mesh = node as THREE.Mesh; if (mesh.geometry) geometries.add(mesh.geometry); if (mesh.material) for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) { materials.add(m); for (const value of Object.values(m)) if (value instanceof THREE.Texture) textures.add(value); } });
geometries.forEach((g) => g.dispose()); materials.forEach((m) => m.dispose()); textures.forEach((t) => t.dispose());
}
export async function loadImportedMachine(bytes: ArrayBuffer, filename: string): Promise<ImportedMachine> {
export async function loadImportedMachine(bytes: ArrayBuffer, filename: string, options: { binding?: VisualBinding | null } = {}): Promise<ImportedMachine> {
let inspected: ReturnType<typeof inspectMachineGlb> | null = null;
try { inspected = inspectMachineGlb(bytes); } catch { /* Raw five-axis GLBs are analyzed below. */ }
const manager = new THREE.LoadingManager();
@@ -23,6 +24,13 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
// A curated case binding wins over name heuristics when the GLB ships no
// joint model of its own: the binding names each slot's node explicitly.
if (!model && options.binding) {
const bound = bindMachineScene(gltf.scene, options.binding);
const structure = boundStructure(options.binding);
return { scene: gltf.scene, structure, hash, filename, physicalZero: Object.fromEntries(bound.controller.jointOrder.map((slot) => [slot.key.toLowerCase(), 0])) };
}
if (!inspected) {
const analysis = analyzeGlbFiveAxisScene(gltf.scene, filename);
if (analysis.suggestedCoordinates === "UNKNOWN" || analysis.suggestedCoordinates.length !== 5) throw new Error("GLB 未形成可用的五轴结构;请提供三个直线轴和两个旋转轴节点(支持 JT_X、AXIS_X、X_AXIS 等命名)");
@@ -106,7 +114,7 @@ export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: {
export function buildImportedMachine(asset: ImportedMachine) {
const scene = asset.scene.clone(true);
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
const jointModel = scene.userData.linuxcncJointModel as JointModel | undefined;
const jointModel = scene.userData.linuxcncJointModel as (JointModel | JointModelV2) | undefined;
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
const managed = scene.userData.linuxcncMachineGeometry;
@@ -128,12 +136,40 @@ export function buildImportedMachine(asset: ImportedMachine) {
};
return { machine, toolTip, workOrigin, joints: { rotate: machine }, update, dispose: () => undefined };
}
// Self-contained v2 assembly: a bound GLB carries its own joint model, so
// there is no URDF link tree to look up. Drive slot nodes directly and hang
// the tool tip / work origin on the nodes the binding named.
if (jointModel?.schema === "linuxcnc.joint-model/2" && !managed) {
const machine = new THREE.Group(); machine.name = asset.structure.name;
const scale = 1000 * WEB_SETTINGS.viewer.mm_scale;
machine.scale.set(scale, scale, -scale); machine.add(scene);
const slotNodes = new Map<number, THREE.Object3D>();
scene.traverse((node) => { const slot = node.userData.linuxcncJoint?.index; if (Number.isInteger(slot) && !slotNodes.has(slot)) slotNodes.set(slot, node); });
const toolTip = new THREE.Group(); toolTip.name = "bound-tool-tip";
const workOrigin = new THREE.Group(); workOrigin.name = "bound-work-origin";
const toolNode = scene.getObjectByName("tool") ?? [...slotNodes.values()].at(-1) ?? scene;
toolNode.add(toolTip); scene.add(workOrigin);
const driver = bindJointModel(scene, jointModel) as { update(values: number[]): void; state(): Record<string, unknown> };
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
const values = [...state.joints];
if (values.length !== jointModel.controller.jointOrder.length) return;
driver.update(values);
const length = state.offsets.tool_offset / 1000;
toolTip.position.set(0, 0, -length);
machine.updateMatrixWorld(true);
machine.userData.linuxcncJointState = driver.state();
};
return { machine, toolTip, workOrigin, joints: { rotate: machine }, update, dispose: () => undefined };
}
if (managed?.schema === "linuxcnc.machine-manager/1") {
const clean = (value: number) => Object.is(value, -0) ? 0 : value;
const effective = { ...managed, cOriginMm: [asset.structure.offsets.x_rot_point, asset.structure.offsets.y_rot_point, asset.structure.offsets.z_rot_point].map(clean), tiltOffsetMm: [asset.structure.offsets.x_offset, asset.structure.offsets.y_offset, asset.structure.offsets.z_offset].map(clean), physicalZero: { ...(asset.physicalZero ?? managed.physicalZero) } };
scene.userData.linuxcncMachineGeometry = effective;
if (jointModel) {
const effectiveJoints: JointModel = { ...jointModel, calibration: { cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3, physicalZero: { ...physicalZero } } };
// v1 keys physical zero by axis letter, v2 by slot index; keep each shape.
const effectiveJoints: JointModel | JointModelV2 = jointModel.schema === "linuxcnc.joint-model/2"
? { ...jointModel, calibration: { ...jointModel.calibration, cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3 } }
: { ...jointModel, calibration: { cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3, physicalZero: { ...physicalZero } } };
scene.userData.linuxcncJointModel = effectiveJoints;
scene.traverse((node) => {
if (node.userData.linuxcncJointModel) node.userData.linuxcncJointModel = effectiveJoints;
@@ -170,7 +206,14 @@ export function buildImportedMachine(asset: ImportedMachine) {
if (joint.type === "prismatic") link.position.addScaledVector(axis, value / 1000);
else link.quaternion.setFromAxisAngle(axis, value * Math.PI / 180);
}
if (jointDriver) jointDriver.update(Object.fromEntries([...asset.structure.coordinates].map((axis, i) => [axis, state.joints[i]])) as AxisValues, physicalZero);
if (jointDriver) {
// v2 models are slot-indexed: pass the array straight through so 6- and
// 7-joint machines (robots, parallel platforms, duplicate gantries) are
// driven by their own joint values rather than a coordinate-letter map.
const driver = jointDriver as { update(values: number[] | AxisValues, physicalZero?: Record<string, number>): void };
if (jointModel?.schema === "linuxcnc.joint-model/2") driver.update([...state.joints]);
else driver.update(Object.fromEntries([...asset.structure.coordinates].map((axis, i) => [axis, state.joints[i]])) as AxisValues, physicalZero);
}
const length = state.offsets.tool_offset / 1000;
toolTip.position.set(0, 0, -length);
cutter.visible = Math.abs(length) > 1e-9; cutter.scale.y = Math.abs(length); cutter.position.z = -length / 2;
@@ -182,3 +225,44 @@ export function buildImportedMachine(asset: ImportedMachine) {
};
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
}
// Appended helpers: bind a scene to a case's visual binding and drive it from
// slot-indexed WASM joints. No kinematics is computed here.
export function boundStructure(binding: VisualBinding): MachineStructure {
const joints = binding.jointOrder.map((slot, index) => ({
name: `slot_${index}`, type: slot.kind === "revolute" ? "revolute" : "prismatic",
parent: "bound_machine", child: `bound_${index}`, origin: [0, 0, 0] as Vec3,
axis: ([1, 0, 0] as Vec3), coordinate: slot.axis ?? "", limit: { lower: slot.kind === "revolute" ? -360 : -1000, upper: slot.kind === "revolute" ? 360 : 1000, velocity: 100, effort: 1 },
...( { slot: String(index) } as Record<string, string> ),
}));
return { name: binding.machineId, profile: "generic-wasm/1", coordinates: binding.coordinates, base: "bound_machine", tool: "bound_tool", work: "bound_work", links: ["bound_machine", "bound_tool", "bound_work", ...joints.map((j) => j.child)], joints, offsets: {}, urdf: `bound-glb://${binding.machineId}` };
}
export function bindMachineScene(scene: THREE.Object3D, binding: Parameters<typeof bindingJointModel>[0]) {
const ids: string[] = [];
scene.traverse((node) => { if (node.userData.linuxcncNodeId) ids.push(node.userData.linuxcncNodeId); });
const draft = bindingJointModel(binding, ids);
if (draft.missing.length) throw new Error(`模型缺少关节节点:${draft.missing.join("、")}`);
const records: JointRecordV2[] = [];
scene.traverse((node) => {
const id = node.userData.linuxcncNodeId;
if (!id) return;
const index = draft.slotMap.indexOf(id);
const slot = index >= 0 ? draft.controller.jointOrder[index] : null;
records.push({
id, parentId: node.parent?.userData.linuxcncNodeId ?? null, name: node.name || id, role: "",
source: { file: binding.machineId, hash: binding.machineId, path: id }, zero: jointZero(node),
joint: slot ? { axis: (slot.axis as CncAxis | null), slot: index, direction: (node.userData.linuxcncJoint?.direction ?? "z") as JointDofV2["direction"], sign: (node.userData.linuxcncJoint?.sign ?? 1) as 1 | -1, dof: 1 as const, type: slot.kind as JointRecordV2["joint"] extends null ? never : NonNullable<JointRecordV2["joint"]>["type"], unit: slot.unit as "mm" | "deg", frame: "parent" as const } : null,
});
});
const model: JointModelV2 = {
schema: "linuxcnc.joint-model/2", machineId: String(draft.machineId), coordinates: String(draft.coordinates),
units: { length: "mm", angle: "deg", gltfLength: "m" },
controller: { source: "linuxcnc-wasm.visual.joints", jointOrder: draft.controller.jointOrder.map((slot) => ({ ...slot, axis: (slot.axis as CncAxis | null), kind: slot.kind as JointSlot["kind"], unit: slot.unit as "mm" | "deg", source: slot.source as JointSlot["source"] })) },
calibration: { cOriginMm: [...draft.calibration.cOriginMm] as Vec3, tiltOffsetMm: [...draft.calibration.tiltOffsetMm] as Vec3, physicalZero: [...draft.calibration.physicalZero] },
slotMap: [...draft.slotMap], nodes: records,
};
validateJointModel(model);
scene.userData.linuxcncJointModel = model;
return model;
}

View File

@@ -16,6 +16,16 @@ declare module "*.mjs" {
export const SIM_CASE_CATALOG: Array<{ path: string; label?: string; category?: string; status?: string }>;
export function loadSimulationCase(options: { path: string; baseUrl?: string }): Promise<{ status: string; entrypoint: string; files: Record<string, string>; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
export function classifyAxisNodeName(name: string): { axis: string; confidence: "high" | "medium" | "low"; rank: number; reason: string; ambiguous?: "tilt" | "rotary" } | null;
export function selectAxisNodes(descriptors: Array<{ name: string; path: string; depth: number; explicitAxis?: string | null }>, options?: { declaredCoordinates?: string | null; expectedCoordinates?: string | null; tiltAxis?: string | null; rotaryAxis?: string | null }): { axes: Array<{ axis: string; name: string; path: string; depth: number; rank: number; confidence: "high" | "medium" | "low"; reason: string; expectedKind: "linear" | "rotary"; autoBind: boolean }>; suggestedCoordinates: string; missingAxes: string[]; warnings: string[]; unresolvedAmbiguous: number };
export function deriveTrtCalibration(input: { coordinates: string; profile?: { tiltAxis: string; rotaryAxis: string; forbiddenOffsetDirection: string }; cWorldMm?: number[]; tiltWorldMm?: number[] }): { schema: string; source: string; cOriginMm: number[]; tiltOffsetMm: number[]; physicalZero: Record<string, number>; zeroedOffsetDirection?: string; zeroedOffsetValueMm?: number } | null;
export type VisualBindingSlot = { index: number; key: string; axis: string | null; kind: "prismatic" | "revolute" | "strut"; unit: "mm" | "deg"; source: "kinematics" | "extra" | "duplicate" | "passive" };
export type VisualBinding = { machineId: string; entrypoint: string; coordinates: string; kinematics: string; jointCount: number; kinematicsJointCount: number; extraJointCount: number; jointOrder: readonly VisualBindingSlot[]; glb: { default: string }; bindings: ReadonlyArray<{ slot: number; node: string; direction: "x" | "y" | "z"; sign: 1 | -1 }>; anchors: unknown; calibration: { cOriginMm: readonly number[]; tiltOffsetMm: readonly number[]; physicalZero: readonly number[] }; referenceStatus: string };
export const VISUAL_BINDINGS: { schema: string; glbRoot: string; cases: readonly VisualBinding[] };
export function visualBinding(value: unknown): VisualBinding | null;
export function hasVisualBinding(value: unknown): boolean;
export function bindingGlbPath(value: unknown): string | null;
export function bindingJointModel(binding: VisualBinding, nodeIds: readonly string[]): { schema: string; machineId: string; coordinates: string; units: { length: string; angle: string; gltfLength: string }; controller: { source: string; jointOrder: VisualBindingSlot[] }; calibration: { cOriginMm: number[]; tiltOffsetMm: number[]; physicalZero: number[] }; slotMap: Array<string | null>; missing: string[] };
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
export class XyzacSession extends SimSession { authority(): Record<string, unknown>; }
export function handleWorkerMessage(session: unknown, input: unknown): Promise<unknown>;

View File

@@ -0,0 +1,123 @@
{
"schema": "linuxcnc.web-five-axis-case-programs/1",
"description": "13 case-specific bounded dry-run programs are available through the matching Web/WASM motion and kinematics path.",
"cases": [
{
"id": "xyzac-trt",
"coordinates": "XYZAC",
"kinematics": "xyzac-trt-kins",
"label": "XYZAC TRT 五轴转台倾转",
"webStatus": "supported",
"note": "A 绕 X、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/xyzac-trt.ngc"
},
{
"id": "xyzbc-trt",
"coordinates": "XYZBC",
"kinematics": "xyzbc-trt-kins",
"label": "XYZBC TRT 五轴转台倾转",
"webStatus": "supported",
"note": "B 绕 Y、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/xyzbc-trt.ngc"
},
{
"id": "xyzab-tdr",
"coordinates": "XYZAB",
"kinematics": "xyzab_tdr_kins",
"label": "XYZAB 双转台 TDR",
"webStatus": "supported",
"note": "LinuxCNC xyzab_tdr_kins;Web 通用 WASM 运动学已接入。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/xyzab-tdr.ngc"
},
{
"id": "bridge-mill-5axiskins",
"coordinates": "XYZBCWY",
"kinematics": "5axiskins",
"label": "Bridge Mill / XYZBCWY",
"webStatus": "supported",
"note": "W 沿刀具方向;配置含重复 Y 关节。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/bridge-mill-5axiskins.ngc"
},
{
"id": "max5-maxkins",
"coordinates": "XYZBC",
"kinematics": "maxkins",
"label": "MAX5 / maxkins",
"webStatus": "supported",
"note": "B/C 非恒等旋转映射;不是 TRT 替代。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/max5-maxkins.ngc"
},
{
"id": "hexapod-genhexkins",
"coordinates": "XYZABC",
"kinematics": "genhexkins",
"label": "Hexapod / genhexkins",
"webStatus": "supported",
"note": "六支链并联平台;XYZABC 六自由度。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/hexapod-genhexkins.ngc"
},
{
"id": "pentapod-pentakins",
"coordinates": "XYZAB",
"kinematics": "pentakins",
"label": "Pentapod / pentakins",
"webStatus": "supported",
"note": "五支链并联平台;XYZAB 五轴。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/pentapod-pentakins.ngc"
},
{
"id": "puma-pumakins",
"coordinates": "XYZABC",
"kinematics": "pumakins",
"label": "PUMA / pumakins",
"webStatus": "supported",
"note": "PUMA 六关节串联机器人。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/puma-pumakins.ngc"
},
{
"id": "puma-genserkins",
"coordinates": "XYZABC",
"kinematics": "genserkins",
"label": "PUMA 560 / genserkins",
"webStatus": "supported",
"note": "DH 参数驱动的六轴串联机器人。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/puma-genserkins.ngc"
},
{
"id": "trivkins-xyzab",
"coordinates": "XYZAB",
"kinematics": "trivkins",
"label": "trivkins XYZAB 映射基线",
"webStatus": "supported",
"note": "五轴一一映射基线;不提供 RTCP。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/trivkins-xyzab.ngc"
},
{
"id": "trivkins-xyzcw",
"coordinates": "XYZCW",
"kinematics": "trivkins",
"label": "trivkins XYZCW 映射基线",
"webStatus": "supported",
"note": "C/W 轴字母映射基线;不提供 RTCP。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/trivkins-xyzcw.ngc"
},
{
"id": "trivkins-xyzya",
"coordinates": "XYZYA",
"kinematics": "trivkins",
"label": "trivkins XYZYA 双 Y 映射基线",
"webStatus": "supported",
"note": "Y 轴重复映射与旋转 A 基线;不提供 RTCP。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/trivkins-xyzya.ngc"
},
{
"id": "scorbot-kins",
"coordinates": "XYZAB",
"kinematics": "scorbot-kins",
"label": "SCORBOT / scorbot-kins",
"webStatus": "supported",
"note": "五关节串联机器人;不是五轴铣床 TRT。 Web WASM 通用配置和逆运动学采样已接入。",
"path": "machine-cases/scorbot-kins.ngc"
}
]
}

View File

@@ -5,6 +5,8 @@ export const CNC_AXES = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"] as const;
export type CncAxis = typeof CNC_AXES[number];
export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z" | "custom"; sign: 1 | -1; vector?: Vec3; frame?: "parent" | "local" };
export const isFiveAxisCoordinates = (value: string) => typeof value === "string" && value.length === 5 && new Set(value).size === 5 && [...value].every((axis) => CNC_AXES.includes(axis as CncAxis));
// Slot-based coordinates (v2): 1..9 letters, duplicates allowed (e.g. XYZBCWY, XYZYA).
export const isCncCoordinateString = (value: unknown) => typeof value === "string" && value.length >= 1 && value.length <= 9 && /^[XYZABCUVW]+$/.test(value);
export const isTrtCoordinates = (value: string): value is "XYZAC" | "XYZBC" => value === "XYZAC" || value === "XYZBC";
export type AxisValues = Partial<Record<CncAxis, number>>;
export type JointZero = { positionMm: Vec3; quaternion: [number, number, number, number]; scale: Vec3 };
@@ -23,24 +25,58 @@ export type JointModel = {
axisMap: Partial<Record<CncAxis, string>>;
nodes: JointRecord[];
};
// v2: joints are identified by LinuxCNC joint number (slot), not by axis letter.
// An axis letter is an optional attribute of a slot, so duplicate axes (bridge
// mill's second Y), kinematics-internal joints (hexapod struts have no letter)
// and passive slots (max5 joint 5) all fit the same contract.
export type JointSlotKind = "prismatic" | "revolute" | "strut";
export type JointSlot = {
index: number;
key: string;
axis: CncAxis | null;
kind: JointSlotKind;
unit: "mm" | "deg";
source: "kinematics" | "extra" | "duplicate" | "passive";
drive?: string;
};
export type JointDofV2 = { axis: CncAxis | null; slot: number; direction: "x" | "y" | "z" | "custom"; sign: 1 | -1; vector?: Vec3; frame?: "parent" | "local" };
export type JointRecordV2 = {
id: string; parentId: string | null; name: string; role: string;
source: { file: string; hash: string; path: string };
zero: JointZero;
joint: (JointDofV2 & { dof: 1; type: "prismatic" | "revolute" | "strut"; unit: "mm" | "deg"; frame: "parent" | "local" }) | null;
};
export type JointModelV2 = {
schema: "linuxcnc.joint-model/2";
machineId: string;
coordinates: string;
units: { length: "mm"; angle: "deg"; gltfLength: "m" };
controller: { source: "linuxcnc-wasm.visual.joints"; jointOrder: JointSlot[] };
calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: number[] };
slotMap: Array<string | null>;
nodes: JointRecordV2[];
};
export const JOINT_MODEL_SCHEMA = "linuxcnc.joint-model/1";
export const JOINT_MODEL_SCHEMA_V2 = "linuxcnc.joint-model/2";
export const isLinear = (axis: CncAxis) => "XYZUVW".includes(axis);
export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z", U: "x", V: "y", W: "z" } as const)[axis], sign: 1 });
export const jointFrame = (dof: JointDof) => dof.frame ?? "parent";
export const jointFrame = (dof: JointDof | JointDofV2) => dof.frame ?? "parent";
export const validJointDof = (dof: JointDof) => Boolean(dof && CNC_AXES.includes(dof.axis) && ["x", "y", "z", "custom"].includes(dof.direction) && [1, -1].includes(dof.sign) && ["parent", "local"].includes(jointFrame(dof)) && (dof.direction !== "custom" || (Array.isArray(dof.vector) && dof.vector.length === 3 && dof.vector.every(Number.isFinite) && Math.hypot(...dof.vector) > 1e-9)));
export const jointDirection = (dof: JointDof) => dof.direction === "custom" ? new THREE.Vector3(...dof.vector!).normalize() : new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
export const validJointDofV2 = (dof: JointDofV2, slotCount: number) => Boolean(dof && Number.isInteger(dof.slot) && dof.slot >= 0 && dof.slot < slotCount && (dof.axis === null || CNC_AXES.includes(dof.axis)) && ["x", "y", "z", "custom"].includes(dof.direction) && [1, -1].includes(dof.sign) && ["parent", "local"].includes(jointFrame(dof)) && (dof.direction !== "custom" || (Array.isArray(dof.vector) && dof.vector.length === 3 && dof.vector.every(Number.isFinite) && Math.hypot(...dof.vector) > 1e-9)));
export const jointDirection = (dof: JointDof | JointDofV2) => dof.direction === "custom" ? new THREE.Vector3(...dof.vector!).normalize() : new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
export const jointIsLinear = (dof: JointDof | JointDofV2) => (dof as JointDofV2).slot !== undefined ? (dof as JointDofV2).axis === null || isLinear((dof as JointDofV2).axis as CncAxis) : isLinear((dof as JointDof).axis);
export const jointZero = (node: THREE.Object3D): JointZero => ({ positionMm: node.position.toArray().map((v) => v * 1000) as Vec3, quaternion: node.quaternion.toArray(), scale: node.scale.toArray() });
// Absolute evaluation from a stored zero pose: no incremental accumulation.
// Parent-frame motion remains the default; explicit GLB metadata may use local axes.
export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | null, value: number, physicalZero = 0) {
if (dof && (!validJointDof(dof) || ![value, physicalZero].every(Number.isFinite))) throw new Error(`轴 ${dof.axis} 方向或位置无效`);
export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | JointDofV2 | null, value: number, physicalZero = 0) {
if (dof && ![value, physicalZero].every(Number.isFinite)) throw new Error(`轴 ${(dof as JointDofV2).slot !== undefined ? `J${(dof as JointDofV2).slot}` : (dof as JointDof).axis} 方向或位置无效`);
node.position.fromArray(zero.positionMm).multiplyScalar(.001);
node.quaternion.fromArray(zero.quaternion); node.scale.fromArray(zero.scale);
if (dof) {
const direction = jointDirection(dof);
const q = dof.sign * (value - physicalZero);
if (isLinear(dof.axis)) node.position.addScaledVector(jointFrame(dof) === "local" ? direction.applyQuaternion(node.quaternion) : direction, q / 1000);
if (jointIsLinear(dof)) node.position.addScaledVector(jointFrame(dof) === "local" ? direction.applyQuaternion(node.quaternion) : direction, q / 1000);
else {
const rotation = new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q));
if (jointFrame(dof) === "local") node.quaternion.multiply(rotation); else node.quaternion.premultiply(rotation);
@@ -49,9 +85,11 @@ export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof
node.updateMatrix();
}
export function validateJointModel(model: JointModel, complete = true) {
export function validateJointModel(model: JointModel | JointModelV2, complete = true): JointModel | JointModelV2 {
const fail = (message: string): never => { throw new Error(`关节配置无效:${message}`); };
if (!model || model.schema !== JOINT_MODEL_SCHEMA || !isFiveAxisCoordinates(model.coordinates)) fail("需要五个不重复的数控轴");
if (!model || (model.schema !== JOINT_MODEL_SCHEMA && model.schema !== JOINT_MODEL_SCHEMA_V2)) fail("schema 必须是 linuxcnc.joint-model/1 或 /2");
if (model.schema === JOINT_MODEL_SCHEMA_V2) return validateJointModelV2(model, complete, fail);
if (!isFiveAxisCoordinates(model.coordinates)) fail("需要五个不重复的数控轴");
if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints" || model.controller.axisOrder.join("") !== model.coordinates) fail("控制器来源、单位或轴顺序错误");
if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
@@ -78,20 +116,96 @@ export function validateJointModel(model: JointModel, complete = true) {
return model;
}
export function bindJointModel(scene: THREE.Object3D, model: JointModel) {
function validateJointModelV2(model: JointModelV2, complete: boolean, fail: (message: string) => never): JointModelV2 {
if (typeof model.machineId !== "string" || !model.machineId.trim()) fail("machineId 缺失");
if (!isCncCoordinateString(model.coordinates)) fail("coordinates 必须是 1-9 个 XYZABCUVW 字母");
if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints") fail("控制器来源或单位错误");
const order = model.controller?.jointOrder;
if (!Array.isArray(order) || order.length < 1 || order.length > 9) fail("jointOrder 数量错误");
const slots = new Map<number, JointSlot>(), keys = new Set<string>();
for (const [position, slot] of order.entries()) {
if (!slot || slot.index !== position) fail(`jointOrder 槽位 ${position} 索引不连续`);
if (typeof slot.key !== "string" || !slot.key.trim() || keys.has(slot.key)) fail(`槽位 ${position} key 缺失或重复`);
keys.add(slot.key);
if (!["prismatic", "revolute", "strut"].includes(slot.kind)) fail(`槽位 ${slot.key} 类型错误`);
const wantsMm = slot.kind !== "revolute";
if (slot.unit !== (wantsMm ? "mm" : "deg")) fail(`槽位 ${slot.key} 单位与类型不符`);
if (slot.axis !== null && !CNC_AXES.includes(slot.axis)) fail(`槽位 ${slot.key} 轴字母错误`);
if (!["kinematics", "extra", "duplicate", "passive"].includes(slot.source)) fail(`槽位 ${slot.key} 来源错误`);
slots.set(position, slot);
}
const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
if (!finite(model.calibration?.cOriginMm, 3) || !finite(model.calibration?.tiltOffsetMm, 3) || !finite(model.calibration?.physicalZero, order.length)) fail("标定参数错误");
if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
const nodes = new Map<string, JointRecordV2>(), bound = new Map<number, string>();
for (const record of model.nodes) {
if (!record.id || nodes.has(record.id)) fail("节点 ID 缺失或重复");
nodes.set(record.id, record);
if (!finite(record.zero?.positionMm, 3) || !finite(record.zero?.quaternion, 4) || !finite(record.zero?.scale, 3) || record.zero.scale.some((v) => v <= 0) || Math.abs(record.zero.quaternion.reduce((s, v) => s + v * v, 0) - 1) > 1e-5) fail(`${record.name} 零姿态错误`);
const joint = record.joint;
if (!joint) continue;
if (joint.dof !== 1 || !validJointDofV2(joint, order.length) || !["parent", "local"].includes(joint.frame)) fail(`${record.name} 单自由度错误`);
const slot = slots.get(joint.slot)!;
if (joint.type !== slot.kind || joint.unit !== slot.unit) fail(`${record.name} 与槽位 ${slot.key} 的类型/单位不符`);
if (joint.axis !== null && slot.axis !== null && joint.axis !== slot.axis) fail(`${record.name} 轴字母与槽位 ${slot.key} 不符`);
if (bound.has(joint.slot)) fail(`槽位 ${slot.key} 重复绑定`);
bound.set(joint.slot, record.id);
}
for (const record of model.nodes) {
const seen = new Set<string>([record.id]); let parent = record.parentId;
while (parent !== null) { if (seen.has(parent) || !nodes.has(parent)) fail("父级不存在或层级循环"); seen.add(parent); parent = nodes.get(parent)!.parentId; }
}
if (!Array.isArray(model.slotMap) || model.slotMap.length !== order.length) fail("slotMap 长度与 jointOrder 不符");
for (const [index, id] of model.slotMap.entries()) {
if (id === null || id === undefined) { if (bound.has(index)) fail(`槽位 ${slots.get(index)!.key} 索引与绑定不一致`); continue; }
if (bound.get(index) !== id) fail(`槽位 ${slots.get(index)!.key} 索引与绑定不一致`);
}
if (complete) for (const slot of order) if (slot.source !== "passive" && !bound.has(slot.index)) fail(`请完整绑定槽位 ${slot.key}`);
return model;
}
export function bindJointModel(scene: THREE.Object3D, model: JointModel | JointModelV2) {
validateJointModel(model);
const nodes = new Map<string, THREE.Object3D>();
scene.traverse((node) => { const id = node.userData.linuxcncNodeId; if (id) { if (nodes.has(id)) throw new Error("关节模型节点 ID 重复"); nodes.set(id, node); } });
const bindings = model.nodes.map((record) => {
const records = model.nodes as Array<JointRecord | JointRecordV2>;
const bindings = records.map((record) => {
const node = nodes.get(record.id);
if (!node || (node.parent?.userData.linuxcncNodeId ?? null) !== record.parentId) throw new Error(`关节节点 ${record.name} 与 JSON 层级不一致`);
return { node, record };
});
if (model.schema === JOINT_MODEL_SCHEMA_V2) {
const order = model.controller.jointOrder;
const slotPhysicalZero = model.calibration.physicalZero;
return {
update(values: AxisValues | number[]) {
const list = Array.isArray(values) ? values : order.map((slot) => slot.axis ? (values as AxisValues)[slot.axis] : undefined);
for (const { record } of bindings) {
const joint = record.joint as JointRecordV2["joint"];
if (joint && !Number.isFinite(list[joint.slot]) ) throw new Error(`缺少 J${joint.slot} 实时位置`);
if (joint && !Number.isFinite(slotPhysicalZero[joint.slot])) throw new Error(`缺少 J${joint.slot} 物理零点或数值无效`);
}
for (const { node, record } of bindings) {
const joint = record.joint as JointRecordV2["joint"];
driveJoint(node, record.zero, joint, joint ? Number(list[joint.slot]) : 0, joint ? Number(slotPhysicalZero[joint.slot] ?? 0) : 0);
}
},
// Keyed and ordered by joint slot, not by GLB traversal order.
state: () => Object.fromEntries(bindings
.filter(({ record }) => record.joint)
.sort((a, b) => (a.record.joint as NonNullable<JointRecordV2["joint"]>).slot - (b.record.joint as NonNullable<JointRecordV2["joint"]>).slot)
.map(({ node, record }) => {
const joint = record.joint as NonNullable<JointRecordV2["joint"]>;
const slot = order[joint.slot];
return [slot.key, { index: joint.slot, key: slot.key, axis: slot.axis, id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }];
})),
};
}
return {
update(values: AxisValues, physicalZero = model.calibration.physicalZero) {
for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[record.joint.axis]) || !Number.isFinite(physicalZero[record.joint.axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${record.joint.axis} 轴实时位置或物理零点无效`);
for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint, record.joint ? values[record.joint.axis]! : 0, record.joint ? Number(physicalZero[record.joint.axis.toLowerCase()] ?? 0) : 0);
update(values: AxisValues, physicalZero: Record<string, number> = model.calibration.physicalZero) {
for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[(record.joint as JointDof).axis]) || !Number.isFinite(physicalZero[(record.joint as JointDof).axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${(record.joint as JointDof).axis} 轴实时位置或物理零点无效`);
for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint as JointDof | null, record.joint ? values[(record.joint as JointDof).axis]! : 0, record.joint ? Number(physicalZero[(record.joint as JointDof).axis.toLowerCase()] ?? 0) : 0);
},
state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [record.joint!.axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [(record.joint as JointDof).axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
};
}

View File

@@ -1,4 +1,5 @@
import settings from "../../workspace/machine-manager-settings.json";
import specs from "../../workspace/machine-kinematics-specs.json";
import { isFiveAxisCoordinates, type CncAxis } from "./machine-joints";
import type { Vec3 } from "./machine-urdf";
@@ -30,3 +31,10 @@ for (const profile of settings.profiles) {
if (!limit || limit.length !== 3 || !limit.every(Number.isFinite) || limit[0] >= limit[1] || limit[2] <= 0) throw new Error(`管理器 ${axis} 行程配置无效`);
}
}
// Per-case joint slots and limits, derived from the LinuxCNC INIs by
// web/workspace/machine-kinematics-specs.json. Null means the source config
// declares no limit for that joint; the manager must not invent one.
export type ManagerSpec = { machineId: string; coordinates: string; jointCount: number; jointOrder: string[]; limitsByJoint: Array<[number, number] | null>; parameters: Record<string, number> };
export const MANAGER_SPECS: ManagerSpec[] = specs.cases as unknown as ManagerSpec[];
export const managerSpec = (machineId: string) => MANAGER_SPECS.find((spec) => spec.machineId === machineId) ?? null;

View File

@@ -4,7 +4,7 @@ import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-ur
import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
import { decodeEditorDocument, encodeEditorDocument, partSettings, type DocumentInfo } from "./machine-document";
import type { ImportedMachine } from "./imported-machine";
import { MANAGER_SETTINGS, managerProfile, roleAxis } from "./machine-manager-config";
import { MANAGER_SETTINGS, managerProfile, managerSpec, roleAxis } from "./machine-manager-config";
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record<string, JointDof>; restoredRoles?: Record<string, MachinePartRole | "">; restoredModel?: JointModel; restoredParts?: MachinePartDraft[]; restoredInfo?: DocumentInfo };
@@ -222,9 +222,22 @@ function machineUrdf(coordinates: string, cOriginMm: Vec3, tiltOffsetMm: Vec3) {
].join("\n")}\n</robot>\n`;
}
export async function buildManagedMachine(input: { coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise<ImportedMachine> {
// Generic profile: one link per bound slot plus base/tool/work. No chain
// assertions and no TRT offset algebra: each joint carries its own origin and
// axis from the specification, so robots, parallel platforms and duplicate
// gantry axes fit without a second code path.
function genericMachineUrdf(machineId: string, coordinates: string, slotKeys: string[], limitsBySlot: Array<[number, number] | null>) {
const links = ["base", ...slotKeys.map((key, index) => `slot_${index}_${key.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`), "tool"];
const joints = slotKeys.map((key, index) => {
const limit = limitsBySlot[index] ?? [-360, 360];
const rotary = "ABCUVW".includes(key.replace(/#\d+$/, "").toUpperCase()[0] ?? "");
return ` <joint name="joint_${index}" type="${rotary ? "revolute" : "prismatic"}">\n <parent link="${index === 0 ? "base" : links[index]}"/><child link="${links[index + 1]}"/>\n <origin xyz="0 0 0" rpy="0 0 0"/><axis xyz="${rotary ? "0 0 1" : index === 0 ? "1 0 0" : index === 1 ? "0 1 0" : "0 0 1"}"/><cnc:axis coordinate="${key.replace(/#\d+$/, "")}" joint="${index}"/>\n <limit lower="${limit[0]}" upper="${limit[1]}" velocity="100" effort="1000"/>\n </joint>`;
});
return `<?xml version="1.0"?>\n<robot name="managed_${machineId}" xmlns:cnc="urn:linuxcnc:web:machine:1">\n <cnc:machine profile="generic-wasm/1" base="base" tool="tool" coordinates="${coordinates}"/><!-- slot keys: ${slotKeys.join(",")} -->\n${[...links.map((link) => ` <link name="${link}"/>`), ...joints].join("\n")}\n</robot>\n`;
}
export async function buildManagedMachine(input: { machineId?: string; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise<ImportedMachine> {
if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
const byRole = validateMachineAssembly(input.coordinates, input.parts);
for (const [axis, value] of Object.entries(input.physicalZero)) if (!Number.isFinite(value)) throw new Error(`${axis.toUpperCase()} 物理零点无效`);
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm);
@@ -261,7 +274,7 @@ export function draftNodeDof(part: MachinePartDraft, path: string, coordinates:
return axis ? defaultDof(axis as CncAxis) : null;
}
type ManagedInput = { documentInfo?: DocumentInfo; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
type ManagedInput = { machineId?: string; documentInfo?: DocumentInfo; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
export function makeJointAssembly(input: ManagedInput, deepClone = false) {
if (!input.parts.length) throw new Error("请先导入机床模型");
@@ -300,9 +313,18 @@ export function makeJointAssembly(input: ManagedInput, deepClone = false) {
async function buildJointManagedMachine(input: ManagedInput): Promise<ImportedMachine> {
// Validate using shallow transform clones before allocating deep runtime resources.
validateJointModel(makeJointAssembly(input).model);
if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm), structure = parseMachineUrdf(urdf);
const { assembly, model } = makeJointAssembly(input, true);
const profile = managerProfile(input.coordinates);
// Slot order comes from the joint model itself; limits are looked up per
// machine id and stay null when the INI declares none.
const slotKeys = [..."XYZABCUVW"].length ? [...new Set(input.coordinates)].map((axis, index) => axis) : [];
const spec = managerSpec(input.machineId ?? "");
const urdf = profile
? machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm)
: genericMachineUrdf(input.machineId ?? input.coordinates, input.coordinates,
slotKeys.length === 0 ? [] : slotKeys,
(spec?.limitsByJoint ?? slotKeys.map(() => null)));
const structure = parseMachineUrdf(urdf);
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
scene.userData.linuxcncJointModel = model;

View File

@@ -27,7 +27,11 @@ export function parseMachineUrdf(urdf: string): MachineStructure {
if (robot.tagName !== "robot" || robot.namespaceURI) fail("需要 URDF robot 根元素");
const spec = one(robot, "machine", CNC_NS);
const profile = spec.getAttribute("profile") ?? "";
if (!["trt-xyz-head/1", "trt-xyz-gantry/1"].includes(profile)) fail("不支持的运动学 profile");
// `generic-wasm/1` carries no fixed chain: link/joint counts and the parent
// order come from the managed spec, so robots, parallel platforms and
// duplicate-axis gantries can be described without a second code path.
if (!["trt-xyz-head/1", "trt-xyz-gantry/1", "generic-wasm/1"].includes(profile)) fail("不支持的运动学 profile");
if (profile === "generic-wasm/1") return parseGenericMachineUrdf(urdf, robot, spec);
const gantry = profile === "trt-xyz-gantry/1";
const base = name(spec.getAttribute("base")), tool = name(spec.getAttribute("tool")), work = name(spec.getAttribute("work"));
const links: string[] = [], joints: MachineJoint[] = [];
@@ -86,6 +90,44 @@ export function parseMachineUrdf(urdf: string): MachineStructure {
return { name: name(robot.getAttribute("name")), profile, coordinates, base, tool, work, links, joints, offsets, urdf };
}
function parseGenericMachineUrdf(urdf: string, robot: Element, spec: Element): MachineStructure {
const base = name(spec.getAttribute("base")), tool = name(spec.getAttribute("tool")), work = spec.getAttribute("work") ? name(spec.getAttribute("work")) : "";
const declared = (spec.getAttribute("coordinates") ?? "").replace(/\s+/g, "").toUpperCase();
if (!/^[XYZABCUVW]{1,9}$/.test(declared)) fail("generic 机型的 coordinates 无效");
const links: string[] = [], joints: MachineJoint[] = [];
for (const el of [...robot.children]) {
if (el === spec) continue;
if (el.namespaceURI || !["link", "joint"].includes(el.tagName)) fail(`不支持 ${el.tagName}`);
if (el.tagName === "link") { links.push(name(el.getAttribute("name"))); continue; }
const type = el.getAttribute("type") ?? "";
if (!["fixed", "prismatic", "revolute"].includes(type)) fail(`不支持关节类型 ${type}`);
const origin = one(el, "origin");
if (!close(vector(origin.getAttribute("rpy")), [0, 0, 0])) fail("generic 机型不支持旋转的关节零位 rpy");
const joint: MachineJoint = { name: name(el.getAttribute("name")), type, parent: name(one(el, "parent").getAttribute("link")), child: name(one(el, "child").getAttribute("link")), origin: vector(origin.getAttribute("xyz")), axis: [0, 0, 0], coordinate: "" };
if (type !== "fixed") {
joint.axis = vector(one(el, "axis").getAttribute("xyz"));
const cncAxis = [...el.children].find((c) => c.namespaceURI === CNC_NS && c.localName === "axis");
joint.coordinate = cncAxis?.getAttribute("coordinate") ?? "";
const slot = cncAxis?.getAttribute("joint") ?? "";
if (slot) (joint as MachineJoint & { joint?: string }).joint = slot;
const limit = one(el, "limit");
joint.limit = { lower: scalar(limit.getAttribute("lower")), upper: scalar(limit.getAttribute("upper")), velocity: scalar(limit.getAttribute("velocity")), effort: scalar(limit.getAttribute("effort")) };
if (joint.limit.lower >= joint.limit.upper || joint.limit.velocity <= 0 || joint.limit.effort <= 0) fail("关节限位/速度无效");
}
joints.push(joint);
}
if (new Set(links).size !== links.length || new Set(joints.map((j) => j.name)).size !== joints.length) fail("link/joint 名称重复");
if (!links.includes(base)) fail("base 未在 link 中声明");
if (tool && !links.includes(tool)) fail("tool 未在 link 中声明");
if (new Set(joints.map((j) => j.child)).size !== joints.length || joints.some((j) => !links.includes(j.parent) || !links.includes(j.child) || j.child === base)) fail("重复父节点、未知 link 或根节点错误");
const visited = new Set<string>();
const visit = (link: string) => { if (visited.has(link)) fail("结构存在环"); visited.add(link); for (const j of joints.filter((j) => j.parent === link)) visit(j.child); };
visit(base); if (visited.size !== links.length) fail("存在断开的结构或环");
const active = joints.filter((j) => j.type !== "fixed");
if (active.length !== declared.length) fail(`运动关节数 ${active.length} 与坐标长度 ${declared.length} 不符`);
return { name: name(robot.getAttribute("name")), profile: "generic-wasm/1", coordinates: declared, base, tool, work, links, joints, offsets: {}, urdf };
}
export function inspectMachineGlb(bytes: ArrayBuffer): { json: any; structure: MachineStructure } {
if (bytes.byteLength < 20 || bytes.byteLength > MAX_MACHINE_BYTES) fail("GLB 大小必须在 20 字节至 64 MiB 之间");
const view = new DataView(bytes);

View File

@@ -1,14 +1,43 @@
{
"schema": "linuxcnc.rtcp-program-library/1",
"source": "LinuxCNC 2.10 source tree",
"total": 32,
"total": 45,
"groups": [
{ "id": "trt-axis", "label": "TRT 旋转倾斜台 XYZAC/XYZBC", "count": 16 },
{ "id": "trt-gmoccapy", "label": "gmoccapy TRT 示例", "count": 5 },
{ "id": "xyzab-tdr", "label": "XYZAB 双旋转台", "count": 3 },
{ "id": "bridgemill", "label": "Bridge Mill / 5axiskins", "count": 4 },
{ "id": "hexapod", "label": "Hexapod genhexkins RTCP", "count": 3 },
{ "id": "standalone", "label": "独立五轴动态刀长示例", "count": 1 }
{
"id": "trt-axis",
"label": "TRT 旋转倾斜台 XYZAC/XYZBC",
"count": 16
},
{
"id": "trt-gmoccapy",
"label": "gmoccapy TRT 示例",
"count": 5
},
{
"id": "xyzab-tdr",
"label": "XYZAB 双旋转台",
"count": 3
},
{
"id": "bridgemill",
"label": "Bridge Mill / 5axiskins",
"count": 4
},
{
"id": "hexapod",
"label": "Hexapod genhexkins RTCP",
"count": 3
},
{
"id": "standalone",
"label": "独立五轴动态刀长示例",
"count": 1
},
{
"id": "five-axis-case-tests",
"label": "13 个机型原生干运行测试",
"count": 13
}
],
"programs": [
{
@@ -16,9 +45,17 @@
"group": "trt-axis",
"label": "Boat XYZAC",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["M428", "G43", "G54", "XYZAC TCP", "CAM 点列"],
"features": [
"M428",
"G43",
"G54",
"XYZAC TCP",
"CAM 点列"
],
"note": "XYZAC 连续五轴船体加工示例;程序内显式进入 TCP 模式。"
},
{
@@ -26,9 +63,17 @@
"group": "trt-axis",
"label": "Boat XYZBC",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
"coordinates": ["XYZBC"],
"coordinates": [
"XYZBC"
],
"role": "main",
"features": ["M428", "G43", "G93/G94", "XYZBC TCP", "CAM 点列"],
"features": [
"M428",
"G43",
"G93/G94",
"XYZBC TCP",
"CAM 点列"
],
"note": "XYZBC 连续五轴船体加工示例,包含逆时间进给。"
},
{
@@ -36,9 +81,16 @@
"group": "trt-axis",
"label": "Impeller 7BL XYZAC",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["M428", "G93", "XYZAC TCP", "叶轮 CAM"],
"features": [
"M428",
"G93",
"XYZAC TCP",
"叶轮 CAM"
],
"note": "高密度 XYZAC 叶轮轨迹,用于连续联动与逆时间进给测试。"
},
{
@@ -46,9 +98,16 @@
"group": "trt-axis",
"label": "XYZAC switchkins 四象限螺旋",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["O-call", "M428/M429", "G2 螺旋", "四象限"],
"features": [
"O-call",
"M428/M429",
"G2 螺旋",
"四象限"
],
"note": "调用 xyzac_switchkins_sub,在 identity 与 TCP 运动学之间切换。"
},
{
@@ -56,9 +115,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins test 1",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["M428/M429", "坐标对照", "C 轴旋转"],
"features": [
"M428/M429",
"坐标对照",
"C 轴旋转"
],
"note": "对比 identity/TCP 切换前后的关节与刀尖坐标。"
},
{
@@ -66,9 +131,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins test 2",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_2.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["M428/M429", "G64", "多姿态轨迹"],
"features": [
"M428/M429",
"G64",
"多姿态轨迹"
],
"note": "覆盖多组 A/C 姿态与 TCP/identity 切换。"
},
{
@@ -76,9 +147,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins test 3",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_3.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["M428/M429", "M1", "预期坐标检查"],
"features": [
"M428/M429",
"M1",
"预期坐标检查"
],
"note": "带人工检查点和预期坐标的最小 switchkins 验证程序。"
},
{
@@ -86,9 +163,16 @@
"group": "trt-axis",
"label": "XYZBC switchkins 四象限螺旋",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
"coordinates": ["XYZBC"],
"coordinates": [
"XYZBC"
],
"role": "main",
"features": ["O-call", "M428/M429", "G2 螺旋", "四象限"],
"features": [
"O-call",
"M428/M429",
"G2 螺旋",
"四象限"
],
"note": "调用 xyzbc_switchkins_sub,验证 B/C 型 TRT 的 TCP 切换。"
},
{
@@ -96,9 +180,17 @@
"group": "trt-axis",
"label": "TRT M428 TCP remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
"coordinates": ["XYZAC", "XYZBC"],
"coordinates": [
"XYZAC",
"XYZBC"
],
"role": "support",
"features": ["M68", "M66", "motion.switchkins-type", "TCP"],
"features": [
"M68",
"M66",
"motion.switchkins-type",
"TCP"
],
"note": "支持文件:选择 TRT TCP 运动学并同步确认 HAL 切换结果。"
},
{
@@ -106,9 +198,16 @@
"group": "trt-axis",
"label": "TRT M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
"coordinates": ["XYZAC", "XYZBC"],
"coordinates": [
"XYZAC",
"XYZBC"
],
"role": "support",
"features": ["M68", "M66", "identity kinematics"],
"features": [
"M68",
"M66",
"identity kinematics"
],
"note": "支持文件:选择 identity/trivkins,用于安全定位和坐标建立。"
},
{
@@ -116,9 +215,16 @@
"group": "trt-axis",
"label": "TRT M430 userk remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
"coordinates": ["XYZAC", "XYZBC"],
"coordinates": [
"XYZAC",
"XYZBC"
],
"role": "support",
"features": ["M68", "M66", "user kinematics"],
"features": [
"M68",
"M66",
"user kinematics"
],
"note": "支持文件:选择 userk 扩展运动学。"
},
{
@@ -126,9 +232,15 @@
"group": "trt-axis",
"label": "TRT centering 子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/centering.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "support",
"features": ["HAL 旋转中心", "M428/M429", "小方形轨迹"],
"features": [
"HAL 旋转中心",
"M428/M429",
"小方形轨迹"
],
"note": "支持子程序:检查旋转中心在 C 轴转动时是否保持固定。"
},
{
@@ -136,9 +248,15 @@
"group": "trt-axis",
"label": "TRT helix AC 子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_ac.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "support",
"features": ["M428/M429", "G2 螺旋", "A/C 姿态"],
"features": [
"M428/M429",
"G2 螺旋",
"A/C 姿态"
],
"note": "支持子程序:XYZAC 姿态下的 TCP 螺旋轨迹。"
},
{
@@ -146,9 +264,15 @@
"group": "trt-axis",
"label": "TRT helix BC 子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_bc.ngc",
"coordinates": ["XYZBC"],
"coordinates": [
"XYZBC"
],
"role": "support",
"features": ["M428/M429", "G2 螺旋", "B/C 姿态"],
"features": [
"M428/M429",
"G2 螺旋",
"B/C 姿态"
],
"note": "支持子程序:XYZBC 姿态下的 TCP 螺旋轨迹。"
},
{
@@ -156,9 +280,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins 主子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzac_switchkins_sub.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "support",
"features": ["O-sub", "四象限", "helix_ac"],
"features": [
"O-sub",
"四象限",
"helix_ac"
],
"note": "支持子程序:参数化调用 helix_ac 完成四象限测试。"
},
{
@@ -166,20 +296,31 @@
"group": "trt-axis",
"label": "XYZBC switchkins 主子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzbc_switchkins_sub.ngc",
"coordinates": ["XYZBC"],
"coordinates": [
"XYZBC"
],
"role": "support",
"features": ["O-sub", "四象限", "helix_bc"],
"features": [
"O-sub",
"四象限",
"helix_bc"
],
"note": "支持子程序:参数化调用 helix_bc 完成四象限测试。"
},
{
"id": "gmoccapy-boat-xyzac",
"group": "trt-gmoccapy",
"label": "gmoccapy Boat XYZAC",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzac.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["直接 TRT kinematics", "G43", "CAM 点列"],
"features": [
"直接 TRT kinematics",
"G43",
"CAM 点列"
],
"note": "原配置直接加载 XYZAC TRT;在 Web switchkins 配置中运行前应选择 M428/TCP。"
},
{
@@ -187,9 +328,16 @@
"group": "trt-gmoccapy",
"label": "gmoccapy Boat XYZBC",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc",
"coordinates": ["XYZBC"],
"coordinates": [
"XYZBC"
],
"role": "main",
"features": ["直接 TRT kinematics", "G43", "G93/G94", "CAM 点列"],
"features": [
"直接 TRT kinematics",
"G43",
"G93/G94",
"CAM 点列"
],
"note": "XYZBC 船体示例;Web switchkins 配置运行前应选择 M428/TCP。"
},
{
@@ -197,9 +345,15 @@
"group": "trt-gmoccapy",
"label": "gmoccapy Impeller 7BL XYZAC",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/impeller-7bl-xyzac.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "main",
"features": ["直接 TRT kinematics", "G93", "叶轮 CAM"],
"features": [
"直接 TRT kinematics",
"G93",
"叶轮 CAM"
],
"note": "无显式 M428;Web switchkins 配置运行前应选择 M428/TCP。"
},
{
@@ -207,9 +361,15 @@
"group": "trt-gmoccapy",
"label": "gmoccapy test-xyzac 子程序",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc",
"coordinates": ["XYZAC"],
"coordinates": [
"XYZAC"
],
"role": "support",
"features": ["O-sub", "刀具更换", "几何偏置"],
"features": [
"O-sub",
"刀具更换",
"几何偏置"
],
"note": "支持子程序,需要由 gmoccapy/NGCGUI 传参调用。"
},
{
@@ -217,20 +377,32 @@
"group": "trt-gmoccapy",
"label": "gmoccapy test-xyzbc 子程序",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzbc.ngc",
"coordinates": ["XYZBC"],
"coordinates": [
"XYZBC"
],
"role": "support",
"features": ["O-sub", "刀具更换", "几何偏置"],
"features": [
"O-sub",
"刀具更换",
"几何偏置"
],
"note": "支持子程序,需要由 gmoccapy/NGCGUI 传参调用。"
},
{
"id": "xyzab-tdr-demo",
"group": "xyzab-tdr",
"label": "XYZAB TDR demo",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/demos/xyzab-tdr-demo.ngc",
"coordinates": ["XYZAB"],
"coordinates": [
"XYZAB"
],
"role": "main",
"features": ["M428/M429", "HAL 旋转中心", "G10 L20", "G43"],
"features": [
"M428/M429",
"HAL 旋转中心",
"G10 L20",
"G43"
],
"note": "需要 XYZAB 双旋转台运动学;当前 Web 控制面板仅执行 XYZAC/XYZBC。"
},
{
@@ -238,9 +410,15 @@
"group": "xyzab-tdr",
"label": "XYZAB M428 TCP remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/remap_subs/428remap.ngc",
"coordinates": ["XYZAB"],
"coordinates": [
"XYZAB"
],
"role": "support",
"features": ["M68", "M66", "xyzab_tdr_kins"],
"features": [
"M68",
"M66",
"xyzab_tdr_kins"
],
"note": "XYZAB TCP 运动学切换支持文件。"
},
{
@@ -248,20 +426,33 @@
"group": "xyzab-tdr",
"label": "XYZAB M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/remap_subs/429remap.ngc",
"coordinates": ["XYZAB"],
"coordinates": [
"XYZAB"
],
"role": "support",
"features": ["M68", "M66", "identity kinematics"],
"features": [
"M68",
"M66",
"identity kinematics"
],
"note": "XYZAB identity 运动学切换支持文件。"
},
{
"id": "bridgemill-5axisgui",
"group": "bridgemill",
"label": "Bridge Mill 5axisgui",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axisgui.ngc",
"coordinates": ["XYZBCW"],
"coordinates": [
"XYZBCW"
],
"role": "main",
"features": ["5axiskins", "B/C/W", "G43", "O-code 循环", "球面钻孔"],
"features": [
"5axiskins",
"B/C/W",
"G43",
"O-code 循环",
"球面钻孔"
],
"note": "需要 XYZBCW Bridge Mill / 5axiskins;W 轴沿刀具方向进给。"
},
{
@@ -269,9 +460,15 @@
"group": "bridgemill",
"label": "Bridge Mill M428 remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/428remap.ngc",
"coordinates": ["XYZBCW"],
"coordinates": [
"XYZBCW"
],
"role": "support",
"features": ["M68", "M66", "5axiskins"],
"features": [
"M68",
"M66",
"5axiskins"
],
"note": "Bridge Mill 非平凡运动学切换支持文件。"
},
{
@@ -279,9 +476,15 @@
"group": "bridgemill",
"label": "Bridge Mill M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/429remap.ngc",
"coordinates": ["XYZBCW"],
"coordinates": [
"XYZBCW"
],
"role": "support",
"features": ["M68", "M66", "identity kinematics"],
"features": [
"M68",
"M66",
"identity kinematics"
],
"note": "Bridge Mill identity 运动学切换支持文件。"
},
{
@@ -289,20 +492,32 @@
"group": "bridgemill",
"label": "Bridge Mill M430 userk remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/430remap.ngc",
"coordinates": ["XYZBCW"],
"coordinates": [
"XYZBCW"
],
"role": "support",
"features": ["M68", "M66", "user kinematics"],
"features": [
"M68",
"M66",
"user kinematics"
],
"note": "Bridge Mill userk 运动学切换支持文件。"
},
{
"id": "hexapod-remap-m428",
"group": "hexapod",
"label": "Hexapod M428 genhexkins remap",
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/428remap.ngc",
"coordinates": ["GENHEX"],
"coordinates": [
"GENHEX"
],
"role": "support",
"features": ["genhexkins", "M68", "M66", "RTCP tool-offset"],
"features": [
"genhexkins",
"M68",
"M66",
"RTCP tool-offset"
],
"note": "Hexapod genhexkins RTCP 运动学切换支持文件。"
},
{
@@ -310,9 +525,15 @@
"group": "hexapod",
"label": "Hexapod M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/429remap.ngc",
"coordinates": ["GENHEX"],
"coordinates": [
"GENHEX"
],
"role": "support",
"features": ["identity kinematics", "M68", "M66"],
"features": [
"identity kinematics",
"M68",
"M66"
],
"note": "Hexapod identity 运动学切换支持文件。"
},
{
@@ -320,21 +541,267 @@
"group": "hexapod",
"label": "Hexapod M430 userk remap",
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/430remap.ngc",
"coordinates": ["GENHEX"],
"coordinates": [
"GENHEX"
],
"role": "support",
"features": ["user kinematics", "M68", "M66"],
"features": [
"user kinematics",
"M68",
"M66"
],
"note": "Hexapod userk 运动学切换支持文件。"
},
{
"id": "standalone-cone",
"group": "standalone",
"label": "cone.ngc 动态刀长示例",
"path": "linuxcnc/nc_files/cone.ngc",
"coordinates": ["XYZBCW"],
"coordinates": [
"XYZBCW"
],
"role": "reference",
"features": ["G43.1", "B/C/W", "动态刀长", "锥面轨迹"],
"features": [
"G43.1",
"B/C/W",
"动态刀长",
"锥面轨迹"
],
"note": "独立算法参考程序,需要带 B/C/W 的模拟五轴配置。"
},
{
"id": "web-case-xyzac-trt",
"group": "five-axis-case-tests",
"label": "XYZAC TRT 五轴转台倾转",
"path": "machine-cases/xyzac-trt.ngc",
"coordinates": [
"XYZAC"
],
"role": "main",
"features": [
"xyzac-trt-kins",
"G0/G1 有界试动",
"supported"
],
"note": "A 绕 X、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "xyzac-trt",
"webStatus": "supported"
},
{
"id": "web-case-xyzbc-trt",
"group": "five-axis-case-tests",
"label": "XYZBC TRT 五轴转台倾转",
"path": "machine-cases/xyzbc-trt.ngc",
"coordinates": [
"XYZBC"
],
"role": "main",
"features": [
"xyzbc-trt-kins",
"G0/G1 有界试动",
"supported"
],
"note": "B 绕 Y、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "xyzbc-trt",
"webStatus": "supported"
},
{
"id": "web-case-xyzab-tdr",
"group": "five-axis-case-tests",
"label": "XYZAB 双转台 TDR",
"path": "machine-cases/xyzab-tdr.ngc",
"coordinates": [
"XYZAB"
],
"role": "main",
"features": [
"xyzab_tdr_kins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "LinuxCNC xyzab_tdr_kins;Web 通用 WASM 运动学已接入。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "xyzab-tdr",
"webStatus": "supported"
},
{
"id": "web-case-bridge-mill-5axiskins",
"group": "five-axis-case-tests",
"label": "Bridge Mill / XYZBCWY",
"path": "machine-cases/bridge-mill-5axiskins.ngc",
"coordinates": [
"XYZBCWY"
],
"role": "main",
"features": [
"5axiskins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "W 沿刀具方向;配置含重复 Y 关节。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "bridge-mill-5axiskins",
"webStatus": "supported"
},
{
"id": "web-case-max5-maxkins",
"group": "five-axis-case-tests",
"label": "MAX5 / maxkins",
"path": "machine-cases/max5-maxkins.ngc",
"coordinates": [
"XYZBC"
],
"role": "main",
"features": [
"maxkins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "B/C 非恒等旋转映射;不是 TRT 替代。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "max5-maxkins",
"webStatus": "supported"
},
{
"id": "web-case-hexapod-genhexkins",
"group": "five-axis-case-tests",
"label": "Hexapod / genhexkins",
"path": "machine-cases/hexapod-genhexkins.ngc",
"coordinates": [
"XYZABC"
],
"role": "main",
"features": [
"genhexkins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "六支链并联平台;XYZABC 六自由度。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "hexapod-genhexkins",
"webStatus": "supported"
},
{
"id": "web-case-pentapod-pentakins",
"group": "five-axis-case-tests",
"label": "Pentapod / pentakins",
"path": "machine-cases/pentapod-pentakins.ngc",
"coordinates": [
"XYZAB"
],
"role": "main",
"features": [
"pentakins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "五支链并联平台;XYZAB 五轴。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "pentapod-pentakins",
"webStatus": "supported"
},
{
"id": "web-case-puma-pumakins",
"group": "five-axis-case-tests",
"label": "PUMA / pumakins",
"path": "machine-cases/puma-pumakins.ngc",
"coordinates": [
"XYZABC"
],
"role": "main",
"features": [
"pumakins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "PUMA 六关节串联机器人。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "puma-pumakins",
"webStatus": "supported"
},
{
"id": "web-case-puma-genserkins",
"group": "five-axis-case-tests",
"label": "PUMA 560 / genserkins",
"path": "machine-cases/puma-genserkins.ngc",
"coordinates": [
"XYZABC"
],
"role": "main",
"features": [
"genserkins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "DH 参数驱动的六轴串联机器人。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "puma-genserkins",
"webStatus": "supported"
},
{
"id": "web-case-trivkins-xyzab",
"group": "five-axis-case-tests",
"label": "trivkins XYZAB 映射基线",
"path": "machine-cases/trivkins-xyzab.ngc",
"coordinates": [
"XYZAB"
],
"role": "main",
"features": [
"trivkins",
"G0/G1 有界试动",
"identity-only"
],
"note": "五轴一一映射基线;不提供 RTCP。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "trivkins-xyzab",
"webStatus": "supported"
},
{
"id": "web-case-trivkins-xyzcw",
"group": "five-axis-case-tests",
"label": "trivkins XYZCW 映射基线",
"path": "machine-cases/trivkins-xyzcw.ngc",
"coordinates": [
"XYZCW"
],
"role": "main",
"features": [
"trivkins",
"G0/G1 有界试动",
"identity-only"
],
"note": "C/W 轴字母映射基线;不提供 RTCP。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "trivkins-xyzcw",
"webStatus": "supported"
},
{
"id": "web-case-trivkins-xyzya",
"group": "five-axis-case-tests",
"label": "trivkins XYZYA 双 Y 映射基线",
"path": "machine-cases/trivkins-xyzya.ngc",
"coordinates": [
"XYZYA"
],
"role": "main",
"features": [
"trivkins",
"G0/G1 有界试动",
"identity-only"
],
"note": "Y 轴重复映射与旋转 A 基线;不提供 RTCP。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "trivkins-xyzya",
"webStatus": "supported"
},
{
"id": "web-case-scorbot-kins",
"group": "five-axis-case-tests",
"label": "SCORBOT / scorbot-kins",
"path": "machine-cases/scorbot-kins.ngc",
"coordinates": [
"XYZAB"
],
"role": "main",
"features": [
"scorbot-kins",
"G0/G1 有界试动",
"adapter-required"
],
"note": "五关节串联机器人;不是五轴铣床 TRT。 Web LinuxCNC/WASM 通用运动学链已接入。",
"machineId": "scorbot-kins",
"webStatus": "supported"
}
]
}

View File

@@ -155,6 +155,10 @@ button { color: inherit; }
.manual-action-row { margin-top: 8px; }
.manual-jog-block select { min-height: 25px; min-width: 104px; border: 1px solid var(--border-dark); background: white; }
.axis-text-button { min-height: 26px; padding: 3px 9px; border: 1px solid #858585; border-radius: 2px; background: linear-gradient(#f4f4f4, #d6d6d6); box-shadow: inset 1px 1px #fff; cursor: pointer; }
.axis-header-actions { display: flex; align-items: center; gap: 6px; }
/* 使用说明书入口:在新标签页打开单文件 HTML,样式与标题栏的 Connection 指示一致。 */
.axis-manual-link { padding: 2px 8px; border: 1px solid #2f5d78; border-radius: 2px; color: #fff; background: linear-gradient(#3f7fa5, #2a5f81); font: 11px Arial, Helvetica, sans-serif; text-decoration: none; white-space: nowrap; }
.axis-manual-link:hover { background: linear-gradient(#4a90b8, #31698c); }
.axis-text-button:hover:not(:disabled), .axis-text-button:focus-visible { border-color: var(--focus); background: #d8e5f3; outline: none; }
.axis-text-button:active:not(:disabled), .axis-text-button.danger { background: #c3c3c3; box-shadow: inset 1px 1px #999; }
.axis-text-button:disabled { cursor: not-allowed; opacity: .5; }

View File

@@ -3,9 +3,10 @@ import { readFile, stat } from "node:fs/promises";
const root = new URL("../../../", import.meta.url);
const catalog = JSON.parse(await readFile(new URL("web/react/src/rtcp-program-manifest.json", root), "utf8"));
const caseManifest = JSON.parse(await readFile(new URL("web/react/src/machine-case-program-manifest.json", root), "utf8"));
assert.equal(catalog.schema, "linuxcnc.rtcp-program-library/1");
assert.equal(catalog.total, 32);
assert.equal(catalog.programs.length, 32);
assert.equal(catalog.total, 45);
assert.equal(catalog.programs.length, 45);
assert.deepEqual(Object.fromEntries(catalog.groups.map((group) => [group.id, group.count])), {
"trt-axis": 16,
"trt-gmoccapy": 5,
@@ -13,24 +14,41 @@ assert.deepEqual(Object.fromEntries(catalog.groups.map((group) => [group.id, gro
bridgemill: 4,
hexapod: 3,
standalone: 1,
"five-axis-case-tests": 13,
});
assert.equal(new Set(catalog.programs.map((program) => program.id)).size, 32);
assert.equal(new Set(catalog.programs.map((program) => program.path)).size, 32);
assert.equal(new Set(catalog.programs.map((program) => program.id)).size, 45);
assert.equal(new Set(catalog.programs.map((program) => program.path)).size, 45);
for (const group of catalog.groups) assert.equal(catalog.programs.filter((program) => program.group === group.id).length, group.count, group.id);
for (const program of catalog.programs) {
assert.ok(["main", "support", "reference"].includes(program.role), program.id);
assert.ok(program.path.startsWith("linuxcnc/") && program.path.endsWith(".ngc"), program.path);
assert.ok((await stat(new URL(program.path, root))).isFile(), program.path);
const collected = new URL(`test_Rtcp/programs/${program.path.slice("linuxcnc/".length)}`, root);
assert.equal(await readFile(new URL(program.path, root), "utf8"), await readFile(collected, "utf8"), `${program.id}: collected source drift`);
assert.ok((program.path.startsWith("linuxcnc/") || program.path.startsWith("machine-cases/")) && program.path.endsWith(".ngc"), program.path);
const source = program.path.startsWith("linuxcnc/") ? new URL(program.path, root) : new URL(`web/react/public/${program.path}`, root);
assert.ok((await stat(source)).isFile(), program.path);
if (program.path.startsWith("linuxcnc/")) {
const collected = new URL(`test_Rtcp/programs/${program.path.slice("linuxcnc/".length)}`, root);
assert.equal(await readFile(source, "utf8"), await readFile(collected, "utf8"), `${program.id}: collected source drift`);
}
}
const loadable = (coordinates) => catalog.programs.filter((program) => program.role === "main" && program.coordinates.includes(coordinates));
const related = (coordinates) => catalog.programs.filter((program) => program.coordinates.includes(coordinates));
assert.equal(loadable("XYZAC").length, 8);
assert.equal(loadable("XYZBC").length, 3);
assert.equal(related("XYZAC").length, 15);
assert.equal(related("XYZBC").length, 9);
assert.equal(catalog.programs.filter((program) => program.role === "main").length, 13);
assert.equal(loadable("XYZAC").length, 9);
assert.equal(loadable("XYZBC").length, 5);
assert.equal(related("XYZAC").length, 16);
assert.equal(related("XYZBC").length, 11);
assert.equal(catalog.programs.filter((program) => program.role === "main").length, 26);
assert.equal(catalog.programs.filter((program) => program.role === "support").length, 18);
assert.equal(catalog.programs.filter((program) => program.role === "reference").length, 1);
console.log(JSON.stringify({ schema: "rtcp-program-catalog-test.v1", status: "verified", total: 32, related: { XYZAC: 15, XYZBC: 9 }, loadable: { XYZAC: 8, XYZBC: 3 }, groups: catalog.groups }));
assert.equal(caseManifest.schema, "linuxcnc.web-five-axis-case-programs/1");
assert.equal(caseManifest.cases.length, 13);
for (const item of caseManifest.cases) {
const program = catalog.programs.find((row) => row.id === `web-case-${item.id}`);
assert.ok(program, item.id);
assert.deepEqual(program.coordinates, [item.coordinates]);
const text = await readFile(new URL(`web/react/public/${item.path}`, root), "utf8");
assert.match(text, new RegExp(`\\(CASE_ID: ${item.id}\\)`));
assert.match(text, new RegExp(`\\(CONTROL_PROFILE: ${item.coordinates}\\)`));
assert.match(text, /G21 G90 G94/);
assert.match(text, /M30/);
if (item.webStatus !== "supported") assert.match(program.note, /Web 按钮保持禁用/);
}
console.log(JSON.stringify({ schema: "rtcp-program-catalog-test.v1", status: "verified", total: 45, related: { XYZAC: 16, XYZBC: 11 }, loadable: { XYZAC: 9, XYZBC: 5 }, groups: catalog.groups }));

View File

@@ -88,7 +88,7 @@ async function execute(bundle, source) {
return { trace, offsets: expected, final: trace.at(-1), preview_hash: fnv1a64(preview.payload.preview), trace_hash: fnv1a64(trace), model_samples: samples.length };
}
for (const machine of MACHINE_DEFINITIONS) {
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
assert.equal(bundle.status, "ready");
const initial = readTrtGeometry(bundle);

View File

@@ -17,7 +17,7 @@ const fetchImpl = async (url) => {
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-7, `${message}: ${actual} != ${expected}`);
const rows = [];
for (const machine of MACHINE_DEFINITIONS) {
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const raw = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
const bundle = writeTrtGeometry(raw, { x_rot_point: 9, y_rot_point: -7, z_rot_point: 13 });
const core = await createModule({ wasmBinary });

View File

@@ -37,13 +37,18 @@ type XyzacPanelSource = {
xyzac?: XyzacCapabilitySnapshot;
gates?: Record<string, XyzacGate>;
authoritative?: Record<string, unknown>;
visual?: { pose?: Record<string, number>; joints?: number[] };
visual?: { pose?: Record<string, number>; joints?: number[]; machine_model?: { id?: string; coordinate_system?: string; joint_count?: number } };
};
export function projectXyzacPanel(snapshot: XyzacPanelSource | null | undefined): XyzacPanelView {
const rawKinematics = snapshot?.kinematics;
const rawCapability = snapshot?.xyzac;
const machine = MACHINE_DEFINITIONS.find((entry) => entry.coordinates === rawKinematics?.coordinates) ?? DEFAULT_MACHINE;
// Coordinates are not a key: three cases share XYZAB and three share XYZABC.
// Prefer the worker-asserted machine id, then the entrypoint, then coordinates.
const assertedId = (snapshot?.visual?.machine_model as { id?: string } | undefined)?.id;
const machine = (assertedId ? MACHINE_DEFINITIONS.find((entry) => entry.id === assertedId) : undefined)
?? MACHINE_DEFINITIONS.find((entry) => entry.coordinates === rawKinematics?.coordinates)
?? DEFAULT_MACHINE;
const kinematics = rawKinematics && rawKinematics.coordinates === machine.coordinates && rawKinematics.joint_count === machine.joint_count ? rawKinematics : null;
const capability = rawCapability && rawCapability.coordinates === machine.coordinates && rawCapability.joint_count === machine.joint_count ? rawCapability : null;
const visualPose = snapshot?.visual?.pose;

View File

@@ -6,7 +6,8 @@ export type XyzacVisualState = {
tick: number;
stateHash: string;
pose: XyzacKinematicsSnapshot["pose"];
joints: [number, number, number, number, number];
joints: number[];
jointsAuthoritative: boolean;
livePose: XyzacKinematicsSnapshot["pose"];
activeProgress: number;
poseEpoch: number;
@@ -41,13 +42,16 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
const pose = visualPose && poseAxes.every((axis) => Number.isFinite(visualPose[axis]))
? { ...ZERO_POSE, ...visualPose }
: kinematics?.pose ?? ZERO_POSE;
const visualJoints = snapshot?.visual?.joints;
const joints: [number, number, number, number, number] = visualJoints?.length === 5
? visualJoints.slice(0, 5).map((value) => Number(value)) as [number, number, number, number, number]
: kinematics?.joints?.length === 5 ? [...kinematics.joints] as [number, number, number, number, number] : [0, 0, 0, 0, 0];
// Joint arrays are slot-indexed and may be 5..9 long. Never truncate here:
// the consumer binds slot i to the node recorded for joint i.
const declaredCount = Number((snapshot?.visual?.machine_model as { joint_count?: number } | undefined)?.joint_count ?? kinematics?.joint_count ?? 0);
const visualJoints = Array.isArray(snapshot?.visual?.joints) ? snapshot.visual.joints.map(Number) : null;
const plannerJoints = Array.isArray(kinematics?.joints) ? kinematics.joints.map(Number) : null;
const joints = visualJoints?.length ? visualJoints : plannerJoints?.length ? plannerJoints : [];
const jointsAuthoritative = joints.length > 0 && joints.every(Number.isFinite) && (!declaredCount || joints.length === declaredCount);
const rawProgress = snapshot?.visual?.active_progress;
const activeProgress = Number.isFinite(rawProgress) ? Math.max(0, Math.min(1, Number(rawProgress))) : 0;
const motionTrace = (snapshot?.visual?.motion_trace ?? []).filter((sample) => sample && Number.isSafeInteger(sample.tick) && sample.pose && Array.isArray(sample.joints)).map((sample) => ({ tick: sample.tick, pose: { ...ZERO_POSE, ...sample.pose }, joints: sample.joints.slice(0, 5).map(Number), activeSegmentId: Number(sample.active_segment_id ?? 0), activeProgress: Math.max(0, Math.min(1, Number(sample.active_progress ?? 0))) }));
const motionTrace = (snapshot?.visual?.motion_trace ?? []).filter((sample) => sample && Number.isSafeInteger(sample.tick) && sample.pose && Array.isArray(sample.joints)).map((sample) => ({ tick: sample.tick, pose: { ...ZERO_POSE, ...sample.pose }, joints: sample.joints.map(Number), activeSegmentId: Number(sample.active_segment_id ?? 0), activeProgress: Math.max(0, Math.min(1, Number(sample.active_progress ?? 0))) }));
return {
schema: "xyzac-visual-state.v1",
authoritative: Boolean(snapshot?.kinematics && snapshot?.visual),
@@ -55,6 +59,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
stateHash: snapshot?.state_hash ?? "",
pose,
joints,
jointsAuthoritative,
livePose: pose,
activeProgress,
poseEpoch: Number.isSafeInteger(snapshot?.visual?.pose_epoch) ? Number(snapshot?.visual?.pose_epoch) : 0,

View File

@@ -34,6 +34,12 @@ function linuxcncAssets(): Plugin {
const output = options.dir ? resolve(options.dir) : resolve(repoRoot, "web/react/dist");
const copies = [
["linuxcnc/configs/sim/axis", "linuxcnc/configs/sim/axis"],
// Every case entrypoint in machine-catalog.json must exist in the build.
// These three live outside configs/sim/axis and used to 404 in the
// deployed bundle, leaving the controller stuck in PENDING.
["linuxcnc/configs/sim/woodpecker", "linuxcnc/configs/sim/woodpecker"],
["linuxcnc/configs/sim/gmoccapy", "linuxcnc/configs/sim/gmoccapy"],
["linuxcnc/configs/sim/qtdragon", "linuxcnc/configs/sim/qtdragon"],
["linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples", "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples"],
["linuxcnc/configs/nc_files", "linuxcnc/configs/nc_files"],
["linuxcnc/nc_files", "linuxcnc/nc_files"],
@@ -43,12 +49,14 @@ function linuxcncAssets(): Plugin {
["linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"],
["linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py"],
["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"],
["docs/user-manual/LinuxCNC-Web-使用说明书.html", "manual/index.html"],
];
for (const [from, to] of copies) cpSync(resolve(repoRoot, from), resolve(output, to), { recursive: true, dereference: false });
},
};
}
// 生产构建会把使用说明书一起复制到 dist/manual/index.html。
export default defineConfig({
plugins: [linuxcncAssets(), tailwindcss(), react()],
base: "./",