按关节槽位驱动 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:
13
web/react/public/machine-cases/bridge-mill-5axiskins.ngc
Normal file
13
web/react/public/machine-cases/bridge-mill-5axiskins.ngc
Normal 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
|
||||
9
web/react/public/machine-cases/hexapod-genhexkins.ngc
Normal file
9
web/react/public/machine-cases/hexapod-genhexkins.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/max5-maxkins.ngc
Normal file
13
web/react/public/machine-cases/max5-maxkins.ngc
Normal 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
|
||||
9
web/react/public/machine-cases/pentapod-pentakins.ngc
Normal file
9
web/react/public/machine-cases/pentapod-pentakins.ngc
Normal 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
|
||||
2
web/react/public/machine-cases/physical/LICENSE.txt
Normal file
2
web/react/public/machine-cases/physical/LICENSE.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Procedural design-reference assets for linuxcncWebWASM. CC0-1.0.
|
||||
No manufacturer affiliation or dimensional calibration is implied.
|
||||
Binary file not shown.
BIN
web/react/public/machine-cases/physical/hexapod-genhexkins.glb
Normal file
BIN
web/react/public/machine-cases/physical/hexapod-genhexkins.glb
Normal file
Binary file not shown.
1199
web/react/public/machine-cases/physical/manifest.json
Normal file
1199
web/react/public/machine-cases/physical/manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/react/public/machine-cases/physical/max5-maxkins.glb
Normal file
BIN
web/react/public/machine-cases/physical/max5-maxkins.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/pentapod-pentakins.glb
Normal file
BIN
web/react/public/machine-cases/physical/pentapod-pentakins.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/puma-genserkins.glb
Normal file
BIN
web/react/public/machine-cases/physical/puma-genserkins.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/puma-pumakins.glb
Normal file
BIN
web/react/public/machine-cases/physical/puma-pumakins.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/scorbot-kins.glb
Normal file
BIN
web/react/public/machine-cases/physical/scorbot-kins.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/trivkins-xyzab.glb
Normal file
BIN
web/react/public/machine-cases/physical/trivkins-xyzab.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/trivkins-xyzcw.glb
Normal file
BIN
web/react/public/machine-cases/physical/trivkins-xyzcw.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/trivkins-xyzya.glb
Normal file
BIN
web/react/public/machine-cases/physical/trivkins-xyzya.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/xyzab-tdr.glb
Normal file
BIN
web/react/public/machine-cases/physical/xyzab-tdr.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/xyzac-trt.glb
Normal file
BIN
web/react/public/machine-cases/physical/xyzac-trt.glb
Normal file
Binary file not shown.
BIN
web/react/public/machine-cases/physical/xyzbc-trt.glb
Normal file
BIN
web/react/public/machine-cases/physical/xyzbc-trt.glb
Normal file
Binary file not shown.
13
web/react/public/machine-cases/puma-genserkins.ngc
Normal file
13
web/react/public/machine-cases/puma-genserkins.ngc
Normal 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
|
||||
9
web/react/public/machine-cases/puma-pumakins.ngc
Normal file
9
web/react/public/machine-cases/puma-pumakins.ngc
Normal 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
|
||||
9
web/react/public/machine-cases/scorbot-kins.ngc
Normal file
9
web/react/public/machine-cases/scorbot-kins.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/trivkins-xyzab.ngc
Normal file
13
web/react/public/machine-cases/trivkins-xyzab.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/trivkins-xyzcw.ngc
Normal file
13
web/react/public/machine-cases/trivkins-xyzcw.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/trivkins-xyzya.ngc
Normal file
13
web/react/public/machine-cases/trivkins-xyzya.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/xyzab-tdr.ngc
Normal file
13
web/react/public/machine-cases/xyzab-tdr.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/xyzac-trt.ngc
Normal file
13
web/react/public/machine-cases/xyzac-trt.ngc
Normal 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
|
||||
13
web/react/public/machine-cases/xyzbc-trt.ngc
Normal file
13
web/react/public/machine-cases/xyzbc-trt.ngc
Normal 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
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
10
web/react/src/legacy.d.ts
vendored
10
web/react/src/legacy.d.ts
vendored
@@ -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>;
|
||||
|
||||
123
web/react/src/machine-case-program-manifest.json
Normal file
123
web/react/src/machine-case-program-manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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() }])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "./",
|
||||
|
||||
@@ -361,6 +361,19 @@ export class ProgramRunner {
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
synchronizePosition(position) {
|
||||
if (!this.loaded || !position || ["x", "y", "z", "a", "b", "c", "u", "v", "w"].some((axis) => !Number.isFinite(Number(position[axis] ?? 0)))) return { status: "error", code: "program_position_invalid" };
|
||||
const pointer = this.core._malloc(72);
|
||||
if (!pointer) return { status: "error", code: "program_position_allocation_failed" };
|
||||
try {
|
||||
this.core.HEAPF64.set(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(position[axis] ?? 0)), pointer / 8);
|
||||
const result = call(this.core, "sim_interpreter_sync_position", "number", ["number", "number"], [this.interpreter, pointer]);
|
||||
if (result !== 0) return { status: "error", code: `program_position_sync_${result}` };
|
||||
this.state = { ...this.state, ...position };
|
||||
return { status: "verified", state: { ...this.state } };
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
|
||||
preview() {
|
||||
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
|
||||
const source = this.source;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
|
||||
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
||||
import { WasmNativeHoming } from "./wasm-native-homing.mjs";
|
||||
import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs";
|
||||
import { WasmGenericKinematics } from "./wasm-generic-kinematics.mjs";
|
||||
import { WasmXyzacIo } from "./wasm-xyzac-io.mjs";
|
||||
import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs";
|
||||
import { WasmProgramRuntime } from "./wasm-program-runtime.mjs";
|
||||
@@ -140,6 +141,8 @@ export class SimSession {
|
||||
this.wasmMotion = planner === "linuxcnc-tp" ? new WasmLinuxcncTp(core) : new WasmMotionQueue(core);
|
||||
this.wasmHomeLimit = new WasmHomeLimit(core);
|
||||
this.xyzacKinematics = null;
|
||||
this.genericKinematics = null;
|
||||
this.genericMotionEnabled = false;
|
||||
this.xyzacIo = null;
|
||||
this.xyzacDeviceIo = null;
|
||||
this.xyzacProgram = null;
|
||||
@@ -338,7 +341,25 @@ export class SimSession {
|
||||
if (this.xyzacProgram) return this.wasmMotionProjection();
|
||||
if (this.motionAuthority !== "wasm") return this.motion.export();
|
||||
const wasm = this.wasmMotion.read();
|
||||
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, queue_depth: wasm.state.queue_depth } : this.motion.export();
|
||||
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, joints: [...(wasm.state.joint_commanded ?? this.genericKinematics?.snapshot().joints ?? [])], queue_depth: wasm.state.queue_depth } : this.motion.export();
|
||||
}
|
||||
|
||||
genericWasmProgramLoad(input) {
|
||||
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
|
||||
const loaded = this.program.load(source, input.payload?.path ?? null);
|
||||
if (loaded.status !== "verified") return loaded;
|
||||
this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.wasmMotion.setKinematics(this.genericKinematics);
|
||||
const home = this.genericKinematics.home();
|
||||
if (home.status !== "verified") return home;
|
||||
const aligned = this.wasmMotion.setPosition(home.pose);
|
||||
if (aligned.status !== "verified") return aligned;
|
||||
const synchronized = this.program.synchronizePosition(home.pose);
|
||||
if (synchronized.status !== "verified") return synchronized;
|
||||
this.task.setProgram("loaded", 0);
|
||||
const preview = this.program.preview();
|
||||
loaded.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
|
||||
this.previewState = loaded.preview;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
wasmDeviceProjection({ includeBytes = false } = {}) {
|
||||
@@ -469,12 +490,10 @@ export class SimSession {
|
||||
// Generic WASM-compatible cases expose their historical XYZ panel shape;
|
||||
// the selected XYZAC case is the only panel that publishes all nine pose
|
||||
// coordinates and the five inverse joints.
|
||||
const position = this.xyzacProgram
|
||||
? wasmPosition
|
||||
: { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
|
||||
const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints;
|
||||
const position = this.xyzacProgram || this.genericKinematics ? wasmPosition : { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
|
||||
const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints ?? this.genericKinematics?.snapshot?.()?.joints;
|
||||
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0])
|
||||
? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === this.machineDefinition?.joint_count ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === this.machineDefinition?.joint_count ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0])
|
||||
: authoritative.kinematics.joints;
|
||||
const preview = this.previewState?.status === "verified" ? this.previewState : null;
|
||||
// Keep the Vismach model identity in the same authoritative visual
|
||||
@@ -497,7 +516,7 @@ export class SimSession {
|
||||
rotary_axis: "Z",
|
||||
joints: machineDefinition?.coordinates ? ["table-x", "saddle-y", "spindle-z", `tilt-${machineDefinition.coordinates[3].toLowerCase()}`, "rotate-c"] : ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"],
|
||||
components: ["base", "column", "head", "spindle", "tool", "table", "saddle", "zslide"],
|
||||
} : null;
|
||||
} : this.genericKinematics ? { schema: "linuxcnc-generic-kinematics-model.v1", id: this.machineDefinition?.id ?? this.genericKinematics.type, source: "linuxcnc-wasm-worker", source_path: this.machineDefinition?.source_path ?? "", source_hash: this.machineDefinition?.source_path && this.workspaceFiles?.[this.machineDefinition.source_path] ? fnv1a64(this.workspaceFiles[this.machineDefinition.source_path]) : null, coordinate_system: this.genericKinematics.coordinates, kinematics: this.genericKinematics.type, joint_count: this.genericKinematics.jointCount, components: ["kinematics-driven-joints"] } : null;
|
||||
const visual = {
|
||||
schema: "axis-visual.v1",
|
||||
pose: { ...position },
|
||||
@@ -1704,7 +1723,8 @@ export class SimSession {
|
||||
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
|
||||
let startupLoaded = false;
|
||||
let configuration = input.payload?.configuration ?? null;
|
||||
if (machineDefinition(configuration?.entrypoint) || machineDefinition(configuration?.kinematics?.type)) {
|
||||
const requestedMachine = machineDefinition(configuration?.entrypoint) ?? machineDefinition(configuration?.kinematics?.type);
|
||||
if (requestedMachine?.runtime === "trt") {
|
||||
try {
|
||||
configuration = { ...configuration, ...readTrtConfiguration(configuration) };
|
||||
} catch (error) {
|
||||
@@ -1762,7 +1782,9 @@ export class SimSession {
|
||||
}
|
||||
const configuredMachine = this.machineDefinition ?? machineDefinition(configuration?.kinematics?.type);
|
||||
const isTrt = configuredMachine?.runtime === "trt" || configuration?.kinematics?.type === "xyzac-trt" || configuration?.kinematics?.type === "xyzbc-trt";
|
||||
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt ? "wasm" : "js";
|
||||
const isGenericWasm = configuredMachine?.runtime === "generic-wasm" || configuration?.kinematics?.runtime === "generic-wasm";
|
||||
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt || isGenericWasm ? "wasm" : "js";
|
||||
this.genericKinematics?.destroy(); this.genericKinematics = null;
|
||||
if (isTrt) {
|
||||
this.xyzacKinematics?.destroy();
|
||||
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, coordinates: configuredMachine?.coordinates ?? configuration.kinematics.coordinates ?? "XYZAC", limits: configuration.home_limits });
|
||||
@@ -1796,6 +1818,24 @@ export class SimSession {
|
||||
this.xyzacAxisAccelerationLimits = configuration.kinematics.axis_acceleration_limits;
|
||||
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
|
||||
}
|
||||
if (isGenericWasm) {
|
||||
this.xyzacProgram?.destroy?.(); this.xyzacProgram = null;
|
||||
this.xyzacKinematics?.destroy(); this.xyzacKinematics = null;
|
||||
this.wasmMotion.destroy?.();
|
||||
this.xyzacMotionCycleTime = Number(configuration.kinematics.servo_cycle_time ?? 0.01);
|
||||
this.xyzacTaskCycleTicks = 1;
|
||||
this.wasmMotion = new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: Number(configuration.kinematics.max_linear_velocity ?? 1000) });
|
||||
this.genericKinematics = new WasmGenericKinematics(this.core, {
|
||||
type: configuration.kinematics.type, coordinates: configuration.kinematics.coordinates,
|
||||
jointCount: configuration.kinematics.joint_count, kinematicsJointCount: configuration.kinematics.kinematics_joint_count, extraJointCount: configuration.kinematics.extra_joint_count, limits: configuration.home_limits ?? {},
|
||||
linearUnits: configuration.kinematics.linear_units ?? "mm", angularUnits: configuration.kinematics.angular_units ?? "deg",
|
||||
parameters: configuration.kinematics.parameters ?? {},
|
||||
});
|
||||
this.wasmMotion.setKinematics(this.genericKinematics);
|
||||
const home = this.genericKinematics.snapshot().pose;
|
||||
const aligned = this.wasmMotion.setPosition(home);
|
||||
if (aligned.status !== "verified") throw new Error(aligned.code ?? "generic_wasm_home_position_failed");
|
||||
}
|
||||
if (configuration?.home_limits && this.motionAuthority === "wasm") {
|
||||
this.wasmHomeLimit.destroy();
|
||||
this.wasmHomeLimit = this.xyzacProgram ? new WasmNativeHoming(this.core, configuration, this.xyzacKinematics) : new WasmHomeLimit(this.core, {
|
||||
@@ -1825,7 +1865,7 @@ export class SimSession {
|
||||
}
|
||||
const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null;
|
||||
if (isTrt) this.machineConfigurationHash = fnv1a64({ kinematics: configuration.kinematics, home_limits: configuration.home_limits, motion_scale_min: WEB_SETTINGS.ui.motion_scale_min });
|
||||
if (configuredKinematics && !(this.motionAuthority === "wasm" && isTrt)) {
|
||||
if (configuredKinematics && !(this.motionAuthority === "wasm" && (isTrt || isGenericWasm))) {
|
||||
const kinematics = this.authoritative.setKinematics(configuredKinematics);
|
||||
if (kinematics.status === "blocked") return finish(this.envelope("ERROR", input, { status: "blocked", code: kinematics.code, kinematics: this.authoritative.export() }), "failed");
|
||||
}
|
||||
@@ -2044,6 +2084,24 @@ export class SimSession {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.genericKinematics) {
|
||||
if (this.program.status !== "running" || this.wasmMotion.read().state.queue_depth !== 0) return;
|
||||
const steps = this.program.tick(1);
|
||||
program_steps.push(...steps);
|
||||
for (const step of steps) {
|
||||
if (!step.motion) continue;
|
||||
step.motion.source_line = Number(step.program?.execution_source_line ?? step.line ?? step.motion.source_line ?? 0);
|
||||
step.motion.source_file = step.program?.execution_source_file ?? this.program.path;
|
||||
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => Math.abs(Number(step.motion.start[axis] ?? 0) - Number(step.motion.end[axis] ?? 0)) < 1e-9);
|
||||
if (!linearNoop) {
|
||||
step.motion.start = { ...this.wasmMotion.read().state.position };
|
||||
const queued = this.wasmMotion.enqueue(step.motion);
|
||||
if (queued.status !== "verified") throw new Error(queued.code ?? "generic_wasm_motion_enqueue_failed");
|
||||
}
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const steps = this.program.tick(1);
|
||||
program_steps.push(...steps);
|
||||
for (const step of steps) {
|
||||
@@ -2177,6 +2235,9 @@ export class SimSession {
|
||||
active_progress: traceState.progress,
|
||||
});
|
||||
}
|
||||
} else if (this.genericKinematics && wasmMotion.status === "verified") {
|
||||
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
|
||||
if (position.status !== "verified") throw new Error(position.code);
|
||||
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed) {
|
||||
const homeCheck = this.wasmHomeLimit.check(this.xyzacProgram ? this.wasmMotionProjection().joints : this.motion.state.joints);
|
||||
@@ -2233,7 +2294,7 @@ export class SimSession {
|
||||
if (program) {
|
||||
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
|
||||
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
|
||||
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
||||
if ((status === "completed" || this.program.snapshot().program_status === "completed") && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) { this.task.transition("STOPPED"); if (this.genericKinematics) this.task.setProgram("completed", this.program.currentLine); }
|
||||
if (this.xyzacProgram && this.task.state.mode === "MDI" && this.task.state.state === "RUNNING" && this.wasmMotion.read().state.queue_depth === 0) this.task.transition("STOPPED");
|
||||
}
|
||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
|
||||
@@ -2314,13 +2375,17 @@ export class SimSession {
|
||||
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null,
|
||||
}), finalStatus);
|
||||
}
|
||||
if (this.genericKinematics) {
|
||||
const result = this.genericWasmProgramLoad(input);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), motion_authority: "wasm", wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
restoreMode();
|
||||
const result = this.program.load(source, input.payload?.path ?? null);
|
||||
const result = this.genericKinematics ? this.genericWasmProgramLoad(input) : this.program.load(source, input.payload?.path ?? null);
|
||||
if (result.status === "verified") {
|
||||
this.authoritative.reset();
|
||||
this.wasmMotion.reset();
|
||||
this.wasmMotion.setPaused(false);
|
||||
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
|
||||
if (!this.genericKinematics) {
|
||||
this.authoritative.reset(); this.wasmMotion.reset(); this.wasmMotion.setPaused(false);
|
||||
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
|
||||
}
|
||||
this.task.setProgram("loaded", 0);
|
||||
const preview = this.program.preview();
|
||||
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
|
||||
@@ -2344,6 +2409,16 @@ export class SimSession {
|
||||
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); this.wasmHomeLimit.setMode("COORD"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
if (this.genericKinematics) {
|
||||
const previousProgramStatus = this.program.status;
|
||||
const result = this.program.start();
|
||||
if (result.status === "verified") {
|
||||
this.wasmMotion.setPaused(false); this.task.setMode("AUTO");
|
||||
if (["STOPPED", "READY"].includes(this.task.state.state)) { if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); }
|
||||
this.task.setProgram("running", this.program.currentLine);
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
const result = this.program.start();
|
||||
if (result.status === "verified") {
|
||||
this.task.setMode("AUTO");
|
||||
@@ -2405,14 +2480,16 @@ export class SimSession {
|
||||
if (result.status === "verified") {
|
||||
if (action === "pause") {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
|
||||
this.task.transition("PAUSED");
|
||||
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
|
||||
if (this.genericKinematics) this.task.setProgram("paused", this.program.currentLine);
|
||||
} else if (action === "resume") {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
|
||||
this.task.transition("RUNNING");
|
||||
if (this.task.state.state === "PAUSED") this.task.transition("RUNNING");
|
||||
if (this.genericKinematics) this.task.setProgram("running", this.program.currentLine);
|
||||
} else {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
|
||||
else this.authoritative.abort();
|
||||
this.task.transition("STOPPED");
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
|
||||
this.task.setProgram("aborted", this.program.currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
46
web/worker/test_generic_machine_cases.mjs
Normal file
46
web/worker/test_generic_machine_cases.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
|
||||
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
|
||||
|
||||
const root = "/home/mes006/linuxcncWebWASM";
|
||||
const fetchImpl = async (url) => {
|
||||
const relative = String(url).replace(/^\.?\//, "");
|
||||
for (const path of [`${root}/${relative}`, `${root}/web/react/public/${relative}`]) {
|
||||
try { const body = await readFile(path); return { ok: true, text: async () => body.toString("utf8") }; } catch { /* try next root */ }
|
||||
}
|
||||
return { ok: false, text: async () => "" };
|
||||
};
|
||||
const wasmBinary = await readFile(`${root}/build-wasm/linuxcnc-sim-core.wasm`);
|
||||
const rows = [];
|
||||
|
||||
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "generic-wasm")) {
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: machine.id, planner: "queue" });
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: machine.id, kind, seq: ++seq, operation_id: `${machine.id}-${seq}`, payload });
|
||||
send("HELLO"); send("SESSION_ATTACH");
|
||||
const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
|
||||
assert.equal(bundle.status, "ready", machine.id);
|
||||
const configuration = toWorkerConfiguration(bundle);
|
||||
const workspace = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration });
|
||||
assert.equal(workspace.kind, "COMMAND_RESULT", machine.id);
|
||||
const loaded = send("PROGRAM_LOAD", { source: bundle.files[machine.program], path: machine.program });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT", `${machine.id}: ${loaded.payload?.code ?? "load"}`);
|
||||
assert.equal(send("PROGRAM_RUN").kind, "COMMAND_RESULT", machine.id);
|
||||
let last = null;
|
||||
for (let tick = 0; tick < 12000; tick += 1) {
|
||||
last = send("SIM_STEP", { cycles: 1 });
|
||||
assert.notEqual(last.kind, "ERROR", `${machine.id}: ${last.payload?.code ?? "step"}`);
|
||||
if (last.payload?.program?.program_status === "completed" && Number(last.payload?.wasm_motion?.state?.queue_depth ?? 0) === 0) break;
|
||||
}
|
||||
assert.equal(last?.payload?.program?.program_status, "completed", machine.id);
|
||||
const joints = last.payload.wasm_motion.state.joint_commanded;
|
||||
assert.equal(joints.length, machine.joint_count, machine.id);
|
||||
assert.ok(joints.every(Number.isFinite), machine.id);
|
||||
rows.push({ id: machine.id, kinematics: machine.kinematics, coordinates: machine.coordinates, program: machine.program, joints });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ schema: "generic-wasm-machine-cases.v1", status: "verified", case_count: rows.length, rows }));
|
||||
@@ -11,7 +11,7 @@ await mkdir(output, { recursive: true });
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
|
||||
const report = { report_id: 'jog-limit-recovery-v1', status: 'running', machines: [] };
|
||||
try {
|
||||
for (const definition of MACHINE_DEFINITIONS) {
|
||||
for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
|
||||
const session = new XyzacSession(core, { sessionId: `jog-limit-${definition.id}`, planner: 'linuxcnc-tp' }); let seq = 0;
|
||||
const send = (kind, payload = {}, expected = null) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ async function open(machine) {
|
||||
return { session, core, send, adoptSequence: (value) => { seq = value; } };
|
||||
}
|
||||
const rows = [];
|
||||
for (const machine of MACHINE_DEFINITIONS) {
|
||||
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
|
||||
const original = await open(machine);
|
||||
const idle = original.send("CHECKPOINT").payload.checkpoint;
|
||||
const idleRestored = await open(machine);
|
||||
|
||||
@@ -24,7 +24,7 @@ function edit(bundle, values) {
|
||||
return { ...bundle, files: { ...bundle.files, [bundle.entrypoint]: content } };
|
||||
}
|
||||
const rows = [];
|
||||
for (const machine of MACHINE_DEFINITIONS) {
|
||||
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
|
||||
const original = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
|
||||
const modified = edit(original, { "AXIS_X.MAX_VELOCITY": 4, "AXIS_X.MAX_ACCELERATION": 8, "AXIS_X.MAX_LIMIT": 15, "JOINT_0.MAX_LIMIT": 17, "TRAJ.MAX_LINEAR_VELOCITY": 13, "EMCMOT.SERVO_PERIOD": 2000000, "TASK.CYCLE_TIME": .02, "DISPLAY.MAX_FEED_OVERRIDE": 1.3 });
|
||||
const config = toWorkerConfiguration(modified);
|
||||
|
||||
@@ -10,7 +10,7 @@ const fetchImpl = async (url) => { try { const body = await readFile(new URL(Str
|
||||
const report = { report_id: 'trt-stationary-switch-worker-v1', status: 'running', machines: [] };
|
||||
const closeJoints = (actual, expected, label) => actual.forEach((v, i) => assert.ok(Math.abs(v - expected[i]) < 1e-8, `${label} J${i}: ${v} != ${expected[i]}`));
|
||||
try {
|
||||
for (const definition of MACHINE_DEFINITIONS) {
|
||||
for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
|
||||
const session = new SimSession(core, { sessionId: `switch-${definition.id}` }); let seq = 0;
|
||||
const send = (kind, payload = {}, expected = 'COMMAND_RESULT') => {
|
||||
|
||||
100
web/worker/wasm-generic-kinematics.mjs
Normal file
100
web/worker/wasm-generic-kinematics.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const CONFIG_SIZE = 1608;
|
||||
const KINEMATICS_SIZE = 1760;
|
||||
const POSE_SIZE = 72;
|
||||
const JOINT_BYTES = 9 * 8;
|
||||
const CONFIG = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
|
||||
const POSE_AXES = Object.freeze(["x", "y", "z", "a", "b", "c", "u", "v", "w"]);
|
||||
const TYPES = Object.freeze({ trivkins: 1, "xyzab-tdr": 1, xyzac: 2, xyzbc: 3, xyzbcw: 4, corexy: 5, rose: 6, maxkins: 7, rotatekins: 8, "5axiskins": 9, scara: 10, pumakins: 11, "scorbot-kins": 12, genserkins: 13, lineardeltakins: 14, rotarydeltakins: 15, tripodkins: 16, genhexkins: 17, pentakins: 18 });
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const fn = core?.[`_${name}`] ?? core?.[name];
|
||||
if (typeof fn === "function") return fn(...args);
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
export class WasmGenericKinematics {
|
||||
constructor(core, { type, coordinates, jointCount, kinematicsJointCount = jointCount, extraJointCount = jointCount - kinematicsJointCount, limits = {}, linearUnits = "mm", angularUnits = "deg", parameters = {} }) {
|
||||
this.core = core;
|
||||
this.type = String(type ?? "").toLowerCase();
|
||||
this.coordinates = String(coordinates ?? "").replace(/\s+/g, "").toUpperCase();
|
||||
this.jointCount = Number(jointCount);
|
||||
this.kinematicsJointCount = Number(kinematicsJointCount);
|
||||
this.extraJointCount = Number(extraJointCount);
|
||||
this.limits = structuredClone(limits);
|
||||
this.linearUnits = linearUnits;
|
||||
this.angularUnits = angularUnits;
|
||||
this.parameters = structuredClone(parameters ?? {});
|
||||
const typeId = TYPES[this.type];
|
||||
if (!typeId || !this.coordinates || !Number.isInteger(this.jointCount) || this.jointCount < 1 || this.jointCount > 9 || !Number.isInteger(this.kinematicsJointCount) || this.kinematicsJointCount < 1 || this.kinematicsJointCount > this.jointCount || this.extraJointCount !== this.jointCount - this.kinematicsJointCount) throw new Error("wasm_generic_kinematics_profile_invalid");
|
||||
if (["sim_kinematics_init", "sim_kinematics_inverse", "sim_kinematics_forward", "sim_kinematics_home"].some((name) => typeof core?.[`_${name}`] !== "function" && typeof core?.[name] !== "function") || typeof core?._malloc !== "function") throw new Error("wasm_generic_kinematics_unavailable");
|
||||
this.config = core._malloc(CONFIG_SIZE); this.kinematics = core._malloc(KINEMATICS_SIZE); this.pose = core._malloc(POSE_SIZE); this.joints = core._malloc(JOINT_BYTES); this.info = core._malloc(32);
|
||||
this.pointers = [this.config, this.kinematics, this.pose, this.joints, this.info];
|
||||
if (this.pointers.some((pointer) => !pointer)) throw new Error("wasm_generic_kinematics_allocation_failed");
|
||||
core.HEAPU8.fill(0, this.config, this.config + CONFIG_SIZE); core.HEAPU8.fill(0, this.kinematics, this.kinematics + KINEMATICS_SIZE); core.HEAPU8.fill(0, this.pose, this.pose + POSE_SIZE); core.HEAPU8.fill(0, this.joints, this.joints + JOINT_BYTES); core.HEAPU8.fill(0, this.info, this.info + 32);
|
||||
const view = new DataView(core.HEAPU8.buffer, this.config, CONFIG_SIZE);
|
||||
view.setInt32(CONFIG.type, typeId, true); view.setInt32(CONFIG.mode, 0, true); view.setUint32(CONFIG.joint_count, this.jointCount, true); view.setUint32(CONFIG.kinematics_joint_count, this.kinematicsJointCount, true); view.setUint32(CONFIG.extra_joint_count, this.extraJointCount, true);
|
||||
for (const [index, axis] of [...this.coordinates].entries()) {
|
||||
core.HEAPU8[this.config + CONFIG.coordinates + index] = axis.charCodeAt(0);
|
||||
const range = limits[axis.toLowerCase()];
|
||||
if (!range) continue;
|
||||
if (range) {
|
||||
if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`wasm_generic_kinematics_limit_invalid:${axis}`);
|
||||
core.HEAPU8[this.config + CONFIG.limit_enabled + index] = 1;
|
||||
view.setFloat64(CONFIG.joint_min + index * 8, range[0], true); view.setFloat64(CONFIG.joint_max + index * 8, range[1], true);
|
||||
}
|
||||
}
|
||||
const scalarOffsets = { x_rot_point: 40, y_rot_point: 48, z_rot_point: 56, x_offset: 64, y_offset: 72, z_offset: 80, tool_offset: 88, pivot_length: 96, puma_a2: 104, puma_a3: 112, puma_d3: 120, puma_d4: 128, puma_d6: 136 };
|
||||
for (const [key, offset] of Object.entries(scalarOffsets)) if (Number.isFinite(Number(this.parameters[key]))) view.setFloat64(offset, Number(this.parameters[key]), true);
|
||||
if (Array.isArray(this.parameters.dh_a) && Array.isArray(this.parameters.dh_alpha) && Array.isArray(this.parameters.dh_d)) {
|
||||
if (![this.parameters.dh_a, this.parameters.dh_alpha, this.parameters.dh_d].every((array) => array.length === 6 && array.every(Number.isFinite))) throw new Error("wasm_generic_kinematics_dh_invalid");
|
||||
view.setUint32(192, 6, true);
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
view.setFloat64(200 + index * 8, this.parameters.dh_a[index], true);
|
||||
view.setFloat64(272 + index * 8, this.parameters.dh_alpha[index], true);
|
||||
view.setFloat64(344 + index * 8, this.parameters.dh_d[index], true);
|
||||
}
|
||||
}
|
||||
const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
|
||||
if (initialized !== 0) throw new Error(`wasm_generic_kinematics_init_${initialized}`);
|
||||
this.lastJoints = new Array(this.jointCount).fill(0); this.lastPose = Object.fromEntries(POSE_AXES.map((axis) => [axis, 0]));
|
||||
const home = this.home();
|
||||
if (home.status !== "verified") throw new Error(`wasm_generic_kinematics_home_${home.code}`);
|
||||
}
|
||||
|
||||
inverse(pose) {
|
||||
if (!pose || POSE_AXES.some((axis) => !Number.isFinite(Number(pose[axis] ?? 0)))) return { status: "error", code: "wasm_generic_kinematics_pose_nonfinite" };
|
||||
this.core.HEAPF64.set(POSE_AXES.map((axis) => Number(pose[axis] ?? 0)), this.pose / 8);
|
||||
const result = call(this.core, "sim_kinematics_inverse", "number", ["number", "number", "number", "number"], [this.kinematics, this.pose, this.joints, this.kinematicsJointCount]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_inverse_${result}`, result };
|
||||
this.lastJoints = [...this.core.HEAPF64.subarray(this.joints / 8, this.joints / 8 + this.jointCount)]; this.lastPose = Object.fromEntries(POSE_AXES.map((axis) => [axis, Number(pose[axis] ?? 0)]));
|
||||
return { status: "verified", joints: [...this.lastJoints] };
|
||||
}
|
||||
|
||||
forward(joints = this.lastJoints) {
|
||||
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.slice(0, this.jointCount).some((value) => !Number.isFinite(value))) return { status: "error", code: "wasm_generic_kinematics_joints_invalid" };
|
||||
this.core.HEAPF64.set(joints.slice(0, this.jointCount), this.joints / 8);
|
||||
const result = call(this.core, "sim_kinematics_forward", "number", ["number", "number", "number", "number"], [this.kinematics, this.joints, this.kinematicsJointCount, this.pose]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_forward_${result}`, result };
|
||||
this.lastJoints = joints.slice(0, this.jointCount); this.lastPose = Object.fromEntries(POSE_AXES.map((axis, index) => [axis, this.core.HEAPF64[this.pose / 8 + index]]));
|
||||
return { status: "verified", pose: { ...this.lastPose }, joints: [...this.lastJoints] };
|
||||
}
|
||||
|
||||
home() {
|
||||
const result = call(this.core, "sim_kinematics_home", "number", ["number", "number", "number", "number"], [this.kinematics, this.joints, this.kinematicsJointCount, this.pose]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_home_${result}`, result };
|
||||
this.lastJoints = [...this.core.HEAPF64.subarray(this.joints / 8, this.joints / 8 + this.jointCount)]; this.lastPose = Object.fromEntries(POSE_AXES.map((axis, index) => [axis, this.core.HEAPF64[this.pose / 8 + index]]));
|
||||
return { status: "verified", pose: { ...this.lastPose }, joints: [...this.lastJoints] };
|
||||
}
|
||||
|
||||
snapshot(pose = this.lastPose, joints = this.lastJoints) {
|
||||
const unsigned = { schema: "wasm-generic-kinematics.v1", type: this.type, coordinates: this.coordinates, joint_count: this.jointCount, linear_units: this.linearUnits, angular_units: this.angularUnits, pose: { ...pose }, joints: [...joints], limits: structuredClone(this.limits), source: "linuxcnc-wasm-sim_kinematics" };
|
||||
return { status: "verified", ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
destroy() { for (const pointer of this.pointers ?? []) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointers = []; }
|
||||
}
|
||||
|
||||
export { TYPES as WASM_KINEMATICS_TYPES };
|
||||
@@ -74,6 +74,8 @@ export class WasmMotionQueue {
|
||||
this.cycleTime = cycleTime;
|
||||
this.velocityLimit = velocityLimit;
|
||||
this.controls = controls();
|
||||
this.kinematics = null;
|
||||
this.kinematicState = null;
|
||||
if (!this.available) return;
|
||||
const queueSize = call(core, "sim_motion_size", "number", [], []);
|
||||
this.queueSize = queueSize;
|
||||
@@ -110,6 +112,25 @@ export class WasmMotionQueue {
|
||||
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, source: "linuxcnc-wasm-motion" };
|
||||
}
|
||||
|
||||
setPosition(position) {
|
||||
if (!this.available || !position || this.read().state.queue_depth !== 0) return { status: "error", code: "wasm_motion_position_requires_idle" };
|
||||
const pose = ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(position[axis] ?? 0));
|
||||
if (pose.some((value) => !Number.isFinite(value))) return { status: "error", code: "wasm_motion_position_nonfinite" };
|
||||
const pointer = this.core._malloc(72);
|
||||
if (!pointer) return { status: "error", code: "wasm_motion_position_allocation_failed" };
|
||||
try {
|
||||
this.core.HEAPF64.set(pose, pointer / 8);
|
||||
const result = call(this.core, "sim_motion_set_position", "number", ["number", "number"], [this.queue, pointer]);
|
||||
return result === 0 ? this.read() : { status: "error", code: `wasm_motion_position_${result}` };
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
|
||||
setKinematics(kinematics) {
|
||||
this.kinematics = kinematics ?? null;
|
||||
this.kinematicState = this.kinematics?.snapshot?.() ?? null;
|
||||
return this.read();
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.available) return this.read();
|
||||
const previousFeedOverride = this.controls.feed_override;
|
||||
@@ -203,7 +224,15 @@ export class WasmMotionQueue {
|
||||
if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" };
|
||||
const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, source: "linuxcnc-wasm-motion" };
|
||||
return this.read();
|
||||
const stepped = this.read();
|
||||
if (stepped.status === "verified" && this.kinematics) {
|
||||
const inverse = this.kinematics.inverse(stepped.state.position);
|
||||
if (inverse.status !== "verified") return { status: "error", code: inverse.code ?? "wasm_motion_kinematics_inverse_failed", source: "linuxcnc-wasm-sim_kinematics" };
|
||||
stepped.state.joint_commanded = [...inverse.joints];
|
||||
stepped.state.kinematics = this.kinematics.snapshot(stepped.state.position, inverse.joints);
|
||||
this.kinematicState = stepped.state.kinematics;
|
||||
}
|
||||
return stepped;
|
||||
}
|
||||
|
||||
read() {
|
||||
@@ -212,7 +241,13 @@ export class WasmMotionQueue {
|
||||
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
|
||||
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
|
||||
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
|
||||
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
|
||||
const position = { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] };
|
||||
const state = { position, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" };
|
||||
if (this.kinematics) {
|
||||
const kin = this.kinematics.snapshot();
|
||||
state.joint_commanded = [...kin.joints]; state.kinematics = kin;
|
||||
}
|
||||
return { status: "verified", state };
|
||||
}
|
||||
|
||||
exportState() {
|
||||
|
||||
@@ -37,7 +37,13 @@
|
||||
"linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/xyzac-trt-kins.c",
|
||||
"designParameters": ["rotaryPoint", "tiltToRotaryOffset", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"rotaryPoint",
|
||||
"tiltToRotaryOffset",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#xyzac-trt"
|
||||
},
|
||||
{
|
||||
"id": "xyzbc-trt",
|
||||
@@ -57,7 +63,13 @@
|
||||
"linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/xyzbc-trt-kins.c",
|
||||
"designParameters": ["rotaryPoint", "tiltToRotaryOffset", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"rotaryPoint",
|
||||
"tiltToRotaryOffset",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#xyzbc-trt"
|
||||
},
|
||||
{
|
||||
"id": "xyzab-tdr",
|
||||
@@ -77,7 +89,16 @@
|
||||
"linuxcnc/configs/sim/qtdragon/qtdragon_xyz45/qtdragon_xyzab.ini"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
|
||||
"designParameters": ["xRotPoint", "yRotPoint", "zRotPoint", "xOffset", "zOffset", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"xRotPoint",
|
||||
"yRotPoint",
|
||||
"zRotPoint",
|
||||
"xOffset",
|
||||
"zOffset",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#xyzab-tdr"
|
||||
},
|
||||
{
|
||||
"id": "bridge-mill-5axiskins",
|
||||
@@ -96,7 +117,14 @@
|
||||
"linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/5axiskins.c",
|
||||
"designParameters": ["jointCoordinateMap", "duplicateAxisMap", "toolDirectionAxis", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"jointCoordinateMap",
|
||||
"duplicateAxisMap",
|
||||
"toolDirectionAxis",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#bridge-mill-5axiskins"
|
||||
},
|
||||
{
|
||||
"id": "max5-maxkins",
|
||||
@@ -116,7 +144,14 @@
|
||||
"linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZAB.ini"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/maxkins.c",
|
||||
"designParameters": ["rotaryAxisOrder", "rotaryCenters", "uvwTransform", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"rotaryAxisOrder",
|
||||
"rotaryCenters",
|
||||
"uvwTransform",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#max5-maxkins"
|
||||
},
|
||||
{
|
||||
"id": "hexapod-genhexkins",
|
||||
@@ -136,7 +171,15 @@
|
||||
"linuxcnc/src/emc/kinematics/genhexkins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/genhexkins.c",
|
||||
"designParameters": ["baseJointPoints", "platformJointPoints", "spindleOffset", "screwLead", "iterationLimits", "physicalZero"]
|
||||
"designParameters": [
|
||||
"baseJointPoints",
|
||||
"platformJointPoints",
|
||||
"spindleOffset",
|
||||
"screwLead",
|
||||
"iterationLimits",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#hexapod-genhexkins"
|
||||
},
|
||||
{
|
||||
"id": "pentapod-pentakins",
|
||||
@@ -156,7 +199,14 @@
|
||||
"linuxcnc/src/emc/kinematics/pentakins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/pentakins.c",
|
||||
"designParameters": ["baseJointPoints", "effectorGeometry", "toolOffset", "convergenceCriterion", "physicalZero"]
|
||||
"designParameters": [
|
||||
"baseJointPoints",
|
||||
"effectorGeometry",
|
||||
"toolOffset",
|
||||
"convergenceCriterion",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#pentapod-pentakins"
|
||||
},
|
||||
{
|
||||
"id": "puma-pumakins",
|
||||
@@ -176,7 +226,15 @@
|
||||
"linuxcnc/src/emc/kinematics/pumakins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/pumakins.c",
|
||||
"designParameters": ["A2", "A3", "D3", "D4", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"A2",
|
||||
"A3",
|
||||
"D3",
|
||||
"D4",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#puma-pumakins"
|
||||
},
|
||||
{
|
||||
"id": "puma-genserkins",
|
||||
@@ -196,7 +254,13 @@
|
||||
"linuxcnc/src/emc/kinematics/genserkins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/genserkins.c",
|
||||
"designParameters": ["DHParameters", "jointLimits", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"DHParameters",
|
||||
"jointLimits",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#puma-genserkins"
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzab",
|
||||
@@ -216,7 +280,12 @@
|
||||
"linuxcnc/src/emc/kinematics/trivkins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
|
||||
"designParameters": ["coordinateOrder", "duplicateAxisMap", "physicalZero"]
|
||||
"designParameters": [
|
||||
"coordinateOrder",
|
||||
"duplicateAxisMap",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzab"
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzcw",
|
||||
@@ -234,7 +303,12 @@
|
||||
"linuxcnc/src/emc/kinematics/trivkins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
|
||||
"designParameters": ["coordinateOrder", "toolDirectionAxis", "physicalZero"]
|
||||
"designParameters": [
|
||||
"coordinateOrder",
|
||||
"toolDirectionAxis",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzcw"
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzya",
|
||||
@@ -253,7 +327,12 @@
|
||||
"linuxcnc/src/emc/kinematics/trivkins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
|
||||
"designParameters": ["coordinateOrder", "duplicateAxisMap", "physicalZero"]
|
||||
"designParameters": [
|
||||
"coordinateOrder",
|
||||
"duplicateAxisMap",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzya"
|
||||
},
|
||||
{
|
||||
"id": "scorbot-kins",
|
||||
@@ -271,7 +350,13 @@
|
||||
"linuxcnc/src/emc/kinematics/scorbot-kins.c"
|
||||
],
|
||||
"kinematicsSource": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
|
||||
"designParameters": ["robotLinkParameters", "jointLimits", "toolOffset", "physicalZero"]
|
||||
"designParameters": [
|
||||
"robotLinkParameters",
|
||||
"jointLimits",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"physicalStructureRef": "five-axis-physical-structures.json#scorbot-kins"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
526
web/workspace/five-axis-physical-structures.json
Normal file
526
web/workspace/five-axis-physical-structures.json
Normal file
@@ -0,0 +1,526 @@
|
||||
{
|
||||
"schema": "linuxcnc.five-axis-physical-structures/1",
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"gltf_length": "m",
|
||||
"gltf_angle": "rad"
|
||||
},
|
||||
"sourcePolicy": "real-reference values are not calibration values; design-reference models require explicit design_reference status",
|
||||
"cases": [
|
||||
{
|
||||
"id": "xyzac-trt",
|
||||
"family": "table-rotary-tilting",
|
||||
"reference": "HERMLE C 42 U 类摆动转台",
|
||||
"status": "design-plus-real-reference",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"tool",
|
||||
"a-tilt-frame",
|
||||
"c-table",
|
||||
"workholding",
|
||||
"guard"
|
||||
],
|
||||
"jointTree": "base→x→y→z→tool; base→a→c→work",
|
||||
"parameters": [
|
||||
"rotaryPoint",
|
||||
"tiltToRotaryOffset",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://www.hermle.de/en/machining-centres-automation/models/machining-centre-c-42-gen2/",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xyzbc-trt",
|
||||
"family": "table-rotary-tilting",
|
||||
"reference": "Haas UMC-500/750 B/C 摆台",
|
||||
"status": "real-reference",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"tool",
|
||||
"b-trunnion",
|
||||
"c-table",
|
||||
"workholding",
|
||||
"guard"
|
||||
],
|
||||
"jointTree": "base→x→y→z→tool; base→b→c→work",
|
||||
"parameters": [
|
||||
"MRZP",
|
||||
"bCIntersection",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://www.haascnc.com/service/online-manuals/umc-series/umc---geometry.html",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xyzab-tdr",
|
||||
"family": "table-dual-rotary",
|
||||
"reference": "LinuxCNC xyzab_tdr 双转台;双转台五轴通用设计",
|
||||
"status": "linuxcnc-reference-plus-design",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"tool",
|
||||
"a-outer-table",
|
||||
"b-inner-table",
|
||||
"workholding"
|
||||
],
|
||||
"jointTree": "base→x→y→z→tool; base→a→b→work",
|
||||
"parameters": [
|
||||
"xRotPoint",
|
||||
"yRotPoint",
|
||||
"zRotPoint",
|
||||
"xOffset",
|
||||
"zOffset",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://linuxcnc.org/docs/html/man/man9/xyzab_tdr_kins.9.html",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bridge-mill-5axiskins",
|
||||
"family": "bridge-mill",
|
||||
"reference": "FIDIA BRIDGE / AWEA Mega 5P 固定龙门双摆头",
|
||||
"status": "real-reference-plus-design",
|
||||
"parts": [
|
||||
"foundation",
|
||||
"left-column",
|
||||
"right-column",
|
||||
"bridge",
|
||||
"x-table",
|
||||
"y-carriage",
|
||||
"z-ram",
|
||||
"b-head",
|
||||
"c-head",
|
||||
"y-left-drive",
|
||||
"y-right-drive"
|
||||
],
|
||||
"jointTree": "base→x→y→z→b→c→tool; y-left/y-right duplicate gantry joints; w is a tool-direction coordinate",
|
||||
"parameters": [
|
||||
"jointCoordinateMap",
|
||||
"duplicateAxisMap",
|
||||
"toolDirectionAxis",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://www.fidia.it/en/products/bridge/",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "max5-maxkins",
|
||||
"family": "table-and-head-rotary",
|
||||
"reference": "LinuxCNC MAX5 桌面五轴:B 倾转头+C 水平转台",
|
||||
"status": "linuxcnc-reference-plus-design",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"b-head",
|
||||
"tool",
|
||||
"c-horizontal-table",
|
||||
"workholding"
|
||||
],
|
||||
"jointTree": "base→x→y→z→b→tool; base→c→work",
|
||||
"parameters": [
|
||||
"pivotLength",
|
||||
"rotaryCenters",
|
||||
"uvwTransform",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://linuxcnc.org/docs/master/html/man/man9/kins.9.html",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hexapod-genhexkins",
|
||||
"family": "parallel-platform",
|
||||
"reference": "Giddings & Lewis Variax 六支链平台",
|
||||
"status": "real-reference-plus-design",
|
||||
"parts": [
|
||||
"base-platform",
|
||||
"strut-1",
|
||||
"strut-2",
|
||||
"strut-3",
|
||||
"strut-4",
|
||||
"strut-5",
|
||||
"strut-6",
|
||||
"moving-platform",
|
||||
"spindle",
|
||||
"tool"
|
||||
],
|
||||
"jointTree": "base→six independent struts→moving-platform→tool",
|
||||
"parameters": [
|
||||
"baseJointPoints",
|
||||
"platformJointPoints",
|
||||
"spindleOffset",
|
||||
"screwLead",
|
||||
"iterationLimits",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://collection.sciencemuseumgroup.org.uk/objects/co8003352/pair-of-hexapod-legs-used-with-the-variax-machine-tool-made-by-giddings-lewis-of-wisconsin-in-1993",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pentapod-pentakins",
|
||||
"family": "parallel-platform",
|
||||
"reference": "METROM Pentapod P-Series",
|
||||
"status": "real-reference-plus-design",
|
||||
"parts": [
|
||||
"base-frame",
|
||||
"strut-1",
|
||||
"strut-2",
|
||||
"strut-3",
|
||||
"strut-4",
|
||||
"strut-5",
|
||||
"moving-platform",
|
||||
"spindle",
|
||||
"tool"
|
||||
],
|
||||
"jointTree": "base→five independent struts→platform→tool",
|
||||
"parameters": [
|
||||
"baseJointPoints",
|
||||
"effectorGeometry",
|
||||
"toolOffset",
|
||||
"convergenceCriterion",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://metrom.com/5-axis-machining-center/",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "puma-pumakins",
|
||||
"family": "serial-robot",
|
||||
"reference": "Unimation PUMA 560 类六轴机器人",
|
||||
"status": "real-reference",
|
||||
"parts": [
|
||||
"base",
|
||||
"j1-turntable",
|
||||
"j2-shoulder",
|
||||
"j3-elbow",
|
||||
"j4-wrist-roll",
|
||||
"j5-wrist-pitch",
|
||||
"j6-wrist-yaw",
|
||||
"flange",
|
||||
"tool"
|
||||
],
|
||||
"jointTree": "base→j1→j2→j3→j4→j5→j6→tool",
|
||||
"parameters": [
|
||||
"A2",
|
||||
"A3",
|
||||
"D3",
|
||||
"D4",
|
||||
"D6",
|
||||
"jointLimits",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://www.linuxcnc.org/docs/html/motion/switchkins.html",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "puma-genserkins",
|
||||
"family": "serial-robot",
|
||||
"reference": "PUMA 560 Modified-DH 六轴机器人",
|
||||
"status": "real-reference-plus-design",
|
||||
"parts": [
|
||||
"base",
|
||||
"j1",
|
||||
"j2",
|
||||
"j3",
|
||||
"j4",
|
||||
"j5",
|
||||
"j6",
|
||||
"flange",
|
||||
"tool"
|
||||
],
|
||||
"jointTree": "base→j1→j2→j3→j4→j5→j6→tool",
|
||||
"parameters": [
|
||||
"DHParameters",
|
||||
"jointLimits",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://www.linuxcnc.org/docs/master/html/fr/man/man9/kins.9.html",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzab",
|
||||
"family": "identity-baseline",
|
||||
"reference": "通用立式五轴双转台验证机",
|
||||
"status": "design-reference",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"tool",
|
||||
"a-frame",
|
||||
"b-table",
|
||||
"workholding"
|
||||
],
|
||||
"jointTree": "base→x→y→z→tool; base→a→b→work",
|
||||
"parameters": [
|
||||
"coordinateOrder",
|
||||
"duplicateAxisMap",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": null,
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzcw",
|
||||
"family": "identity-baseline",
|
||||
"reference": "通用桥式 C/W 刀向补偿验证机",
|
||||
"status": "design-reference",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"tool",
|
||||
"c-table",
|
||||
"w-tool-sleeve",
|
||||
"workholding"
|
||||
],
|
||||
"jointTree": "base→x→y→z→tool; base→c→work; tool→w→tip",
|
||||
"parameters": [
|
||||
"coordinateOrder",
|
||||
"toolDirectionAxis",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": null,
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzya",
|
||||
"family": "identity-gantry-rotary",
|
||||
"reference": "双 Y 龙门+A 转台验证机",
|
||||
"status": "design-reference",
|
||||
"parts": [
|
||||
"base",
|
||||
"x",
|
||||
"y-left",
|
||||
"y-right",
|
||||
"z",
|
||||
"tool",
|
||||
"a-table",
|
||||
"workholding"
|
||||
],
|
||||
"jointTree": "five joints: x,y-left,z,y-right,a; y-left/y-right drive one gantry axis",
|
||||
"parameters": [
|
||||
"coordinateOrder",
|
||||
"duplicateAxisMap",
|
||||
"gantrySynchronization",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": null,
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "scorbot-kins",
|
||||
"family": "serial-robot",
|
||||
"reference": "SCORBOT-ER 4U/ER 4PC 五关节机器人",
|
||||
"status": "real-reference-plus-design",
|
||||
"parts": [
|
||||
"base",
|
||||
"j1-base-turn",
|
||||
"j2-shoulder",
|
||||
"j3-elbow",
|
||||
"j4-pitch",
|
||||
"j5-wrist-roll",
|
||||
"flange",
|
||||
"tool"
|
||||
],
|
||||
"jointTree": "base→j1→j2→j3→j4→j5→tool",
|
||||
"parameters": [
|
||||
"robotLinkParameters",
|
||||
"jointLimits",
|
||||
"toolOffset",
|
||||
"physicalZero"
|
||||
],
|
||||
"source": "https://www.theoldrobots.com/book45/USBC-document.pdf",
|
||||
"assetPlan": {
|
||||
"zeroPoseRequired": true,
|
||||
"separateJointNodes": true,
|
||||
"includeOriginHelpers": true,
|
||||
"metadata": [
|
||||
"source",
|
||||
"license",
|
||||
"kinematics",
|
||||
"coordinates",
|
||||
"zero_pose",
|
||||
"joint_limits",
|
||||
"calibration_status"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
146
web/workspace/glb-axis-naming.mjs
Normal file
146
web/workspace/glb-axis-naming.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
// GLB 节点名 → 数控轴 的识别规则,以及由旋转轴世界坐标推导的 TRT 标定候选。
|
||||
// 抽成纯函数模块,便于被分析器(TypeScript)和 Node 单测(test_glb_axis_naming.mjs)共用。
|
||||
|
||||
export const CNC_AXIS_LETTERS = Object.freeze(["X", "Y", "Z", "A", "B", "C", "U", "V", "W"]);
|
||||
|
||||
// 主要运动部件词:轴字母与它相邻时可信度最高(直线部件在前、回转部件在后)。
|
||||
export const PRIMARY_WORDS = Object.freeze(["SLIDE", "CARRIAGE", "SADDLE", "TRUNNION", "TILT", "TABLE", "FRAME", "PLATTER", "RAM", "TOOL"]);
|
||||
// 轴字母后出现这些词只说明“和某个轴相关”,属于零件级线索,优先级最低。
|
||||
export const PART_WORDS = Object.freeze(["LINEAR", "ROTARY", "BEARING", "BEARINGS", "DRIVE", "GUIDE", "RAIL", "BOLT", "CASTING", "MOTOR", "SHAFT", "HOLDER", "HEAD", "SPINDLE", "COLUMN", "SCREW", "WAY", "WAYS", "PEDESTAL", "CRADLE", "PLINTH", "FOOT"]);
|
||||
// 不含轴字母的通用词:需要结合机型或同模型其它命名消歧。
|
||||
export const AMBIGUOUS_WORDS = Object.freeze([{ word: "TILT", kind: "tilt" }, { word: "TRUNNION", kind: "tilt" }, { word: "ROTARY", kind: "rotary" }, { word: "PLATTER", kind: "rotary" }]);
|
||||
|
||||
const axisSet = new Set(CNC_AXIS_LETTERS);
|
||||
const rankOfPrimary = (word) => 2 + PRIMARY_WORDS.indexOf(word) * 0.1;
|
||||
|
||||
/**
|
||||
* 把 GLB 节点名归类为某个数控轴。
|
||||
* 规则优先级:JT_X 精确名 → 轴字母 + AXIS/JOINT → 轴字母 + 主要运动部件词 → 零件级线索 → 待消歧的通用词。
|
||||
* @returns {{axis: string, confidence: "high"|"medium"|"low", rank: number, reason: string, ambiguous?: "tilt"|"rotary"} | null}
|
||||
*/
|
||||
export function classifyAxisNodeName(name) {
|
||||
const normalized = String(name ?? "").trim().toUpperCase();
|
||||
if (!normalized) return null;
|
||||
const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
|
||||
if (exact && axisSet.has(exact[1])) return { axis: exact[1], confidence: "high", rank: 0, reason: "JT_ 轴名(精确)" };
|
||||
const tokens = normalized.split(/[^A-Z0-9]+/).filter(Boolean);
|
||||
const letters = tokens.filter((token) => token.length === 1 && axisSet.has(token));
|
||||
const words = tokens.filter((token) => token.length > 1);
|
||||
const partWord = words.find((word) => PART_WORDS.includes(word));
|
||||
if (letters.length === 1) {
|
||||
const axis = letters[0];
|
||||
// AXIS/JOINT 只算一次:b_axis 是关节本身,b_axis_bearing / b_axis_axis 是它的零件。
|
||||
const jointWords = words.filter((word) => word === "AXIS" || word === "JOINT").length;
|
||||
const hasJointWord = jointWords > 0;
|
||||
if (jointWords === 1 && !partWord) return { axis, confidence: "medium", rank: 1, reason: "轴字母 + AXIS/JOINT 命名" };
|
||||
const letterIndex = tokens.indexOf(letters[0]);
|
||||
const primaryIndex = tokens.findIndex((token) => PRIMARY_WORDS.includes(token));
|
||||
// 允许 moving_x_table / a_outer_table 这类“轴字母在部件词之前”的命名。
|
||||
if (!hasJointWord && primaryIndex > letterIndex) {
|
||||
const primary = tokens[primaryIndex];
|
||||
// 只有真正的零件词才算"附加层级"(y_saddle_casting 低于 y_saddle);
|
||||
// direction / 序号之类的修饰词不降级(w_tool_direction 仍是 W 主节点)。
|
||||
const extras = tokens.slice(primaryIndex + 1).filter((token) => PART_WORDS.includes(token)).length;
|
||||
return { axis, confidence: "medium", rank: rankOfPrimary(primary) + Math.min(extras, 3) * 0.02, reason: `轴字母 + ${primary} 部件命名` };
|
||||
}
|
||||
if (partWord || hasJointWord) return { axis, confidence: "low", rank: 4, reason: "零件级轴名线索,需人工确认" };
|
||||
}
|
||||
const ambiguous = AMBIGUOUS_WORDS.find((item) => words.includes(item.word));
|
||||
if (ambiguous) {
|
||||
// 通用词 + 零件词(tilt_bearing、trunnion_pedestal)只是同轴零件,排序要低于 tilt_table 这类主轴节点。
|
||||
const rank = partWord ? 4 : 3 + AMBIGUOUS_WORDS.indexOf(ambiguous) * 0.1;
|
||||
return { axis: "C", confidence: "low", rank, reason: `通用${ambiguous.kind === "tilt" ? "倾转" : "回转"}部件命名,需结合机型判断`, ambiguous: ambiguous.kind };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由旋转轴节点的世界坐标推导原生 TRT 标定候选:
|
||||
* C 轴原点取 C 轴节点世界坐标,倾转偏置取倾转轴节点与之的差,
|
||||
* 并按运动学约束把沿倾转轴方向(该机型要求为 0)的分量归零。
|
||||
*/
|
||||
export function deriveTrtCalibration({ coordinates, profile, cWorldMm, tiltWorldMm }) {
|
||||
if (!profile || !["XYZAC", "XYZBC"].includes(coordinates)) return null;
|
||||
if (![cWorldMm, tiltWorldMm].every((value) => Array.isArray(value) && value.length === 3 && value.every((item) => Number.isFinite(item)))) return null;
|
||||
const offsetIndex = ["x", "y", "z"].indexOf(profile.forbiddenOffsetDirection);
|
||||
if (offsetIndex < 0) return null;
|
||||
const cOriginMm = cWorldMm.map((value) => Number(value.toFixed(6)));
|
||||
const tiltOffsetMm = tiltWorldMm.map((value, index) => Number((value - cWorldMm[index]).toFixed(6)));
|
||||
const zeroed = tiltOffsetMm[offsetIndex];
|
||||
tiltOffsetMm[offsetIndex] = 0;
|
||||
return {
|
||||
schema: "linuxcnc.trt-calibration-candidate/1",
|
||||
source: "axis-world-origin",
|
||||
cOriginMm,
|
||||
tiltOffsetMm,
|
||||
physicalZero: Object.fromEntries([...coordinates].map((axis) => [axis.toLowerCase(), 0])),
|
||||
...(Math.abs(zeroed) > 1e-6 ? { zeroedOffsetDirection: profile.forbiddenOffsetDirection, zeroedOffsetValueMm: zeroed } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从节点描述里挑选每个数控轴的最佳绑定候选,并判定机型坐标。
|
||||
* 纯函数:不依赖 three.js,可被分析器和 Node 单测共用。
|
||||
*
|
||||
* @param {{name: string, path: string, depth: number, explicitAxis?: string | null}[]} descriptors
|
||||
* @param {{declaredCoordinates?: string | null, expectedCoordinates?: string | null, tiltAxis?: string | null, rotaryAxis?: string | null}} options
|
||||
*/
|
||||
export function selectAxisNodes(descriptors, options = {}) {
|
||||
const warnings = [];
|
||||
const items = [];
|
||||
for (const descriptor of descriptors) {
|
||||
const hint = descriptor.explicitAxis
|
||||
? { axis: descriptor.explicitAxis, confidence: "high", rank: -1, reason: "GLB 关节元数据" }
|
||||
: classifyAxisNodeName(descriptor.name);
|
||||
if (!hint || !axisSet.has(hint.axis)) continue;
|
||||
items.push({ path: descriptor.path, name: descriptor.name, depth: Number(descriptor.depth) || 0, hint, autoBind: false });
|
||||
}
|
||||
|
||||
// 通用倾转/回转命名消歧:优先用调用方给的机型信息,其次用同模型里明确的轴字母。
|
||||
const explicitAxes = new Set(items.filter((item) => !item.hint.ambiguous).map((item) => item.hint.axis));
|
||||
const tiltAxis = options.tiltAxis ?? (explicitAxes.has("A") && !explicitAxes.has("B") ? "A" : explicitAxes.has("B") && !explicitAxes.has("A") ? "B" : null);
|
||||
const rotaryAxis = options.rotaryAxis ?? (explicitAxes.has("C") ? "C" : null);
|
||||
let unresolvedAmbiguous = 0;
|
||||
for (const item of items) {
|
||||
if (!item.hint.ambiguous) continue;
|
||||
const resolved = item.hint.ambiguous === "tilt" ? tiltAxis : rotaryAxis;
|
||||
// 无法消歧的候选不参与机型判定,也不自动绑定。
|
||||
if (!resolved) { unresolvedAmbiguous += 1; item.hint = { ...item.hint, rank: 9, reason: `${item.hint.reason}(缺少机型线索)` }; continue; }
|
||||
item.hint = { ...item.hint, axis: resolved, reason: `${item.hint.reason} → ${resolved}` };
|
||||
}
|
||||
if (unresolvedAmbiguous) warnings.push(`${unresolvedAmbiguous} 个通用倾转/回转节点缺少机型线索(可先选机型再导入)`);
|
||||
|
||||
// 每个轴取最优候选;同级并列视为歧义,不自动绑定。
|
||||
const chosen = new Map();
|
||||
const byAxis = new Map();
|
||||
for (const item of items) { const list = byAxis.get(item.hint.axis) ?? []; list.push(item); byAxis.set(item.hint.axis, list); }
|
||||
for (const [axis, list] of byAxis) {
|
||||
list.sort((a, b) => a.hint.rank - b.hint.rank || a.depth - b.depth);
|
||||
const best = list[0];
|
||||
// 同名重复节点(CAD 导出常见)按深度取最上层;只有不同命名同分才算歧义。
|
||||
const tied = list.filter((item) => item.hint.rank === best.hint.rank && item.name !== best.name);
|
||||
if (tied.length > 1) { warnings.push(`轴 ${axis} 有 ${tied.length} 个同级候选(${tied.map((item) => item.name).join("、")}),未自动绑定;请手动选择唯一节点`); continue; }
|
||||
chosen.set(axis, best);
|
||||
for (const item of list) item.autoBind = item === best && best.hint.rank <= 3;
|
||||
}
|
||||
|
||||
const declared = typeof options.declaredCoordinates === "string" && options.declaredCoordinates.length === 5 ? options.declaredCoordinates : null;
|
||||
const expected = typeof options.expectedCoordinates === "string" && options.expectedCoordinates.length === 5 ? options.expectedCoordinates : null;
|
||||
const primaryAxes = CNC_AXIS_LETTERS.filter((axis) => (chosen.get(axis)?.hint.rank ?? 9) <= 3);
|
||||
const declaredValid = declared ? [...declared].every((axis) => chosen.has(axis)) : false;
|
||||
const expectedValid = expected ? [...expected].every((axis) => chosen.has(axis)) : false;
|
||||
const suggestedCoordinates = declaredValid ? declared : expectedValid ? expected : primaryAxes.length === 5 ? primaryAxes.join("") : "UNKNOWN";
|
||||
if (declared && !declaredValid) warnings.push("GLB 声明的轴顺序与可用关节不一致,未采用该顺序");
|
||||
if (suggestedCoordinates === "UNKNOWN" && primaryAxes.length !== 5) warnings.push(`检测到 ${primaryAxes.length} 个主轴候选(${primaryAxes.join("") || "无"});需要五个轴才能自动绑定`);
|
||||
|
||||
const bound = new Set(suggestedCoordinates === "UNKNOWN" ? [] : [...suggestedCoordinates]);
|
||||
const axes = CNC_AXIS_LETTERS.flatMap((axis) => {
|
||||
const best = chosen.get(axis);
|
||||
if (!best) return [];
|
||||
const autoBind = best.autoBind && bound.has(axis);
|
||||
return [{ axis, name: best.name, path: best.path, depth: best.depth, rank: best.hint.rank, confidence: best.hint.confidence, reason: best.reason ?? best.hint.reason, expectedKind: "XYZUVW".includes(axis) ? "linear" : "rotary", autoBind }];
|
||||
});
|
||||
const available = new Set(axes.filter((item) => item.autoBind).map((item) => item.axis));
|
||||
const missingAxes = suggestedCoordinates === "UNKNOWN" ? [] : [...suggestedCoordinates].filter((axis) => !available.has(axis));
|
||||
return { axes, suggestedCoordinates, missingAxes, warnings, unresolvedAmbiguous };
|
||||
}
|
||||
@@ -30,6 +30,250 @@
|
||||
"helix_bc"
|
||||
],
|
||||
"runtime_fixture": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"
|
||||
},
|
||||
{
|
||||
"id": "xyzab-tdr",
|
||||
"coordinates": "XYZAB",
|
||||
"kinematics_type": 1,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "xyzab-tdr",
|
||||
"kinematics_source": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
|
||||
"kinematics_parameters": {
|
||||
"x_rot_point": 0,
|
||||
"y_rot_point": 0,
|
||||
"z_rot_point": 0,
|
||||
"x_offset": -20,
|
||||
"z_offset": -10,
|
||||
"tool_offset": 0
|
||||
},
|
||||
"joint_count": 5,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
|
||||
"program": "machine-cases/xyzab-tdr.ngc",
|
||||
"source_path": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
|
||||
"model": "dual-rotary-table",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"
|
||||
},
|
||||
{
|
||||
"id": "bridge-mill-5axiskins",
|
||||
"coordinates": "XYZBCWY",
|
||||
"kinematics_type": 9,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "5axiskins",
|
||||
"kinematics_source": "linuxcnc/src/emc/kinematics/5axiskins.c",
|
||||
"kinematics_parameters": {
|
||||
"pivot_length": 100
|
||||
},
|
||||
"joint_count": 7,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
|
||||
"program": "machine-cases/bridge-mill-5axiskins.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/5axiskins.c",
|
||||
"model": "bridge-mill-xyzbcwy",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini"
|
||||
},
|
||||
{
|
||||
"id": "max5-maxkins",
|
||||
"coordinates": "XYZBC",
|
||||
"kinematics_type": 7,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "maxkins",
|
||||
"kinematics_source": "linuxcnc/src/emc/kinematics/maxkins.c",
|
||||
"kinematics_parameters": {
|
||||
"pivot_length": 0
|
||||
},
|
||||
"joint_count": 6,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
|
||||
"program": "machine-cases/max5-maxkins.ngc",
|
||||
"source_path": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
|
||||
"model": "max5-maxkins",
|
||||
"units": {
|
||||
"linear": "machine-config",
|
||||
"angular": "machine-config"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
|
||||
"kinematics_joint_count": 5,
|
||||
"extra_joint_count": 1
|
||||
},
|
||||
{
|
||||
"id": "hexapod-genhexkins",
|
||||
"coordinates": "XYZABC",
|
||||
"kinematics_type": 17,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "genhexkins",
|
||||
"kinematics_source": "linuxcnc/src/emc/kinematics/genhexkins.c",
|
||||
"joint_count": 6,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
|
||||
"program": "machine-cases/hexapod-genhexkins.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/genhexkins.c",
|
||||
"model": "hexapod-six-strut",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"
|
||||
},
|
||||
{
|
||||
"id": "pentapod-pentakins",
|
||||
"coordinates": "XYZAB",
|
||||
"kinematics_type": 18,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "pentakins",
|
||||
"kinematics_source": "linuxcnc/src/emc/kinematics/pentakins.c",
|
||||
"joint_count": 5,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
|
||||
"program": "machine-cases/pentapod-pentakins.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/pentakins.c",
|
||||
"model": "pentapod-five-strut",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini"
|
||||
},
|
||||
{
|
||||
"id": "puma-pumakins",
|
||||
"coordinates": "XYZABC",
|
||||
"kinematics_type": 11,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "pumakins",
|
||||
"kinematics_source": "linuxcnc/src/emc/kinematics/pumakins.c",
|
||||
"kinematics_parameters": {
|
||||
"puma_a2": 400,
|
||||
"puma_a3": 50,
|
||||
"puma_d3": 100,
|
||||
"puma_d4": 400,
|
||||
"puma_d6": 95
|
||||
},
|
||||
"joint_count": 6,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
|
||||
"program": "machine-cases/puma-pumakins.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/pumakins.c",
|
||||
"model": "puma-six-axis-serial",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini"
|
||||
},
|
||||
{
|
||||
"id": "puma-genserkins",
|
||||
"coordinates": "XYZABC",
|
||||
"kinematics_type": 13,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "genserkins",
|
||||
"kinematics_source": "linuxcnc/src/emc/kinematics/genserkins.c",
|
||||
"kinematics_parameters": {
|
||||
"dh_joint_count": 6,
|
||||
"dh_a": [
|
||||
0,
|
||||
17,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"dh_alpha": [
|
||||
0,
|
||||
1.570796326,
|
||||
0,
|
||||
1.570796326,
|
||||
-1.570796326,
|
||||
1.570796326
|
||||
],
|
||||
"dh_d": [
|
||||
26.45,
|
||||
-5.5,
|
||||
0,
|
||||
17.05,
|
||||
0,
|
||||
2.2
|
||||
]
|
||||
},
|
||||
"joint_count": 6,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
|
||||
"program": "machine-cases/puma-genserkins.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/genserkins.c",
|
||||
"model": "puma560-genser-dh",
|
||||
"units": {
|
||||
"linear": "inch-config",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini"
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzab",
|
||||
"coordinates": "XYZAB",
|
||||
"kinematics_type": 1,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "trivkins",
|
||||
"joint_count": 5,
|
||||
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
|
||||
"program": "machine-cases/trivkins-xyzab.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
|
||||
"model": "xyzab-identity",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini"
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzcw",
|
||||
"coordinates": "XYZCW",
|
||||
"kinematics_type": 1,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "trivkins",
|
||||
"joint_count": 5,
|
||||
"entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
|
||||
"program": "machine-cases/trivkins-xyzcw.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
|
||||
"model": "xyzcw-identity",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini"
|
||||
},
|
||||
{
|
||||
"id": "trivkins-xyzya",
|
||||
"coordinates": "XYZYA",
|
||||
"kinematics_type": 1,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "trivkins",
|
||||
"joint_count": 5,
|
||||
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
|
||||
"program": "machine-cases/trivkins-xyzya.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
|
||||
"model": "xyzya-identity-gantry",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"
|
||||
},
|
||||
{
|
||||
"id": "scorbot-kins",
|
||||
"coordinates": "XYZAB",
|
||||
"kinematics_type": 12,
|
||||
"runtime": "generic-wasm",
|
||||
"kinematics": "scorbot-kins",
|
||||
"joint_count": 5,
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
|
||||
"program": "machine-cases/scorbot-kins.ngc",
|
||||
"source_path": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
|
||||
"model": "scorbot-five-axis-serial",
|
||||
"units": {
|
||||
"linear": "mm",
|
||||
"angular": "deg"
|
||||
},
|
||||
"source_entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import catalog from "./machine-catalog.json" with { type: "json" };
|
||||
|
||||
export const MACHINE_DEFINITIONS = Object.freeze(catalog.machines.map((entry) => {
|
||||
if (!entry.id || !entry.entrypoint || !entry.program || !entry.source_path || !["XYZAC", "XYZBC"].includes(entry.coordinates)) throw new Error("machine_catalog_invalid");
|
||||
return Object.freeze({ ...entry, label: `${entry.coordinates} TRT`, axes: [...entry.coordinates.toLowerCase()], joint_count: entry.coordinates.length, runtime: "trt" });
|
||||
if (!entry.id || !entry.entrypoint || !entry.program || !entry.source_path || typeof entry.coordinates !== "string" || !entry.coordinates.length || entry.coordinates.length > 9) throw new Error("machine_catalog_invalid");
|
||||
return Object.freeze({ ...entry, label: entry.runtime === "generic-wasm" ? entry.id : `${entry.coordinates} TRT`, axes: [...entry.coordinates.toLowerCase()], joint_count: entry.joint_count ?? entry.coordinates.length, runtime: entry.runtime ?? "trt" });
|
||||
}));
|
||||
if (new Set(MACHINE_DEFINITIONS.map((entry) => entry.id)).size !== MACHINE_DEFINITIONS.length) throw new Error("machine_catalog_duplicate");
|
||||
export const DEFAULT_MACHINE = MACHINE_DEFINITIONS.find((entry) => entry.id === catalog.default_machine);
|
||||
export const GENERIC_WASM_MACHINES = Object.freeze(MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "generic-wasm"));
|
||||
if (!DEFAULT_MACHINE) throw new Error("machine_catalog_default_missing");
|
||||
export function machineDefinition(value) {
|
||||
return MACHINE_DEFINITIONS.find((entry) => [entry.id, entry.entrypoint, entry.coordinates].includes(value)) ?? null;
|
||||
|
||||
489
web/workspace/machine-kinematics-specs.json
Normal file
489
web/workspace/machine-kinematics-specs.json
Normal file
@@ -0,0 +1,489 @@
|
||||
{
|
||||
"schema": "linuxcnc.machine-kinematics-specs/1",
|
||||
"description": "Per-case joint slots and limits, derived from the LinuxCNC INIs. limits[j] = [min,max] for joint index j; null means the source config declares none.",
|
||||
"cases": [
|
||||
{
|
||||
"machineId": "xyzab-tdr",
|
||||
"coordinates": "XYZAB",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
|
||||
"kinematics": "xyzab-tdr",
|
||||
"jointCount": 5,
|
||||
"jointOrder": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-200.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
-200.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
-300.0,
|
||||
300.0
|
||||
],
|
||||
[
|
||||
-3600.0,
|
||||
3600.0
|
||||
],
|
||||
[
|
||||
-180.0,
|
||||
180.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {
|
||||
"x_rot_point": 0,
|
||||
"y_rot_point": 0,
|
||||
"z_rot_point": 0,
|
||||
"x_offset": -20,
|
||||
"z_offset": -10,
|
||||
"tool_offset": 0
|
||||
},
|
||||
"limitsSource": "linuxcnc-ini"
|
||||
},
|
||||
{
|
||||
"machineId": "bridge-mill-5axiskins",
|
||||
"coordinates": "XYZBCWY",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
|
||||
"kinematics": "5axiskins",
|
||||
"jointCount": 7,
|
||||
"jointOrder": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"B",
|
||||
"C",
|
||||
"W",
|
||||
"Y#2"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-500.0,
|
||||
500.0
|
||||
],
|
||||
[
|
||||
-500.0,
|
||||
500.0
|
||||
],
|
||||
[
|
||||
-500.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
-120.0,
|
||||
120.0
|
||||
],
|
||||
[
|
||||
-36000.0,
|
||||
36000.0
|
||||
],
|
||||
[
|
||||
-100.0,
|
||||
500.0
|
||||
],
|
||||
[
|
||||
-500.0,
|
||||
500.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 7,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {
|
||||
"pivot_length": 100
|
||||
},
|
||||
"limitsSource": "linuxcnc-ini"
|
||||
},
|
||||
{
|
||||
"machineId": "max5-maxkins",
|
||||
"coordinates": "XYZBC",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
|
||||
"kinematics": "maxkins",
|
||||
"jointCount": 6,
|
||||
"jointOrder": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"B",
|
||||
"C",
|
||||
"J5"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-100.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
-60.0,
|
||||
60.0
|
||||
],
|
||||
[
|
||||
-149.0,
|
||||
1.0
|
||||
],
|
||||
[
|
||||
-10.0,
|
||||
10.0
|
||||
],
|
||||
[
|
||||
-90.0,
|
||||
90.0
|
||||
],
|
||||
[
|
||||
-3600.0,
|
||||
3600.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 1,
|
||||
"parameters": {
|
||||
"pivot_length": 0
|
||||
},
|
||||
"limitsSource": "linuxcnc-ini"
|
||||
},
|
||||
{
|
||||
"machineId": "hexapod-genhexkins",
|
||||
"coordinates": "XYZABC",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
|
||||
"kinematics": "genhexkins",
|
||||
"jointCount": 6,
|
||||
"jointOrder": [
|
||||
"J0",
|
||||
"J1",
|
||||
"J2",
|
||||
"J3",
|
||||
"J4",
|
||||
"J5"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-1000.0,
|
||||
1000.0
|
||||
],
|
||||
[
|
||||
-1000.0,
|
||||
1000.0
|
||||
],
|
||||
[
|
||||
-1000.0,
|
||||
1000.0
|
||||
],
|
||||
[
|
||||
-1000.0,
|
||||
1000.0
|
||||
],
|
||||
[
|
||||
-1000.0,
|
||||
1000.0
|
||||
],
|
||||
[
|
||||
-1000.0,
|
||||
1000.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 6,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {},
|
||||
"limitsSource": "linuxcnc-ini"
|
||||
},
|
||||
{
|
||||
"machineId": "pentapod-pentakins",
|
||||
"coordinates": "XYZAB",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
|
||||
"kinematics": "pentakins",
|
||||
"jointCount": 5,
|
||||
"jointOrder": [
|
||||
"J0",
|
||||
"J1",
|
||||
"J2",
|
||||
"J3",
|
||||
"J4"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
400.0,
|
||||
1500.0
|
||||
],
|
||||
[
|
||||
400.0,
|
||||
1500.0
|
||||
],
|
||||
[
|
||||
400.0,
|
||||
1500.0
|
||||
],
|
||||
[
|
||||
400.0,
|
||||
1500.0
|
||||
],
|
||||
[
|
||||
400.0,
|
||||
1500.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {},
|
||||
"limitsSource": "linuxcnc-ini"
|
||||
},
|
||||
{
|
||||
"machineId": "puma-pumakins",
|
||||
"coordinates": "XYZABC",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
|
||||
"kinematics": "pumakins",
|
||||
"jointCount": 6,
|
||||
"jointOrder": [
|
||||
"J0",
|
||||
"J1",
|
||||
"J2",
|
||||
"J3",
|
||||
"J4",
|
||||
"J5"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-170.0,
|
||||
170.0
|
||||
],
|
||||
[
|
||||
-85.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
-70.0,
|
||||
75.0
|
||||
],
|
||||
[
|
||||
-250.0,
|
||||
250.0
|
||||
],
|
||||
[
|
||||
-120.0,
|
||||
120.0
|
||||
],
|
||||
null
|
||||
],
|
||||
"declaredKinematicsJointCount": 6,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {
|
||||
"puma_a2": 400,
|
||||
"puma_a3": 50,
|
||||
"puma_d3": 100,
|
||||
"puma_d4": 400,
|
||||
"puma_d6": 95
|
||||
},
|
||||
"limitsSource": "linuxcnc-ini (missing: J5)"
|
||||
},
|
||||
{
|
||||
"machineId": "puma-genserkins",
|
||||
"coordinates": "XYZABC",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
|
||||
"kinematics": "genserkins",
|
||||
"jointCount": 6,
|
||||
"jointOrder": [
|
||||
"J0",
|
||||
"J1",
|
||||
"J2",
|
||||
"J3",
|
||||
"J4",
|
||||
"J5"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-180.0,
|
||||
180.0
|
||||
],
|
||||
[
|
||||
-360.0,
|
||||
360.0
|
||||
],
|
||||
[
|
||||
-215.0,
|
||||
35.0
|
||||
],
|
||||
[
|
||||
-360.0,
|
||||
360.0
|
||||
],
|
||||
[
|
||||
-360.0,
|
||||
360.0
|
||||
],
|
||||
[
|
||||
-360.0,
|
||||
360.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 6,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {
|
||||
"dh_joint_count": 6,
|
||||
"dh_a": [
|
||||
0,
|
||||
17,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"dh_alpha": [
|
||||
0,
|
||||
1.570796326,
|
||||
0,
|
||||
1.570796326,
|
||||
-1.570796326,
|
||||
1.570796326
|
||||
],
|
||||
"dh_d": [
|
||||
26.45,
|
||||
-5.5,
|
||||
0,
|
||||
17.05,
|
||||
0,
|
||||
2.2
|
||||
]
|
||||
},
|
||||
"limitsSource": "linuxcnc-ini"
|
||||
},
|
||||
{
|
||||
"machineId": "trivkins-xyzab",
|
||||
"coordinates": "XYZAB",
|
||||
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
|
||||
"kinematics": "trivkins",
|
||||
"jointCount": 5,
|
||||
"jointOrder": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-0.001,
|
||||
520.0
|
||||
],
|
||||
[
|
||||
-0.001,
|
||||
630.0
|
||||
],
|
||||
[
|
||||
-115.0,
|
||||
10.0
|
||||
],
|
||||
null,
|
||||
null
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {},
|
||||
"limitsSource": "linuxcnc-ini (missing: A,B)"
|
||||
},
|
||||
{
|
||||
"machineId": "trivkins-xyzcw",
|
||||
"coordinates": "XYZCW",
|
||||
"entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
|
||||
"kinematics": "trivkins",
|
||||
"jointCount": 5,
|
||||
"jointOrder": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"C",
|
||||
"W"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-0.0,
|
||||
600.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
-400.0,
|
||||
4.0
|
||||
],
|
||||
null,
|
||||
[
|
||||
-2.0,
|
||||
4.0
|
||||
]
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {},
|
||||
"limitsSource": "linuxcnc-ini (missing: C)"
|
||||
},
|
||||
{
|
||||
"machineId": "trivkins-xyzya",
|
||||
"coordinates": "XYZYA",
|
||||
"entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
|
||||
"kinematics": "trivkins",
|
||||
"jointCount": 5,
|
||||
"jointOrder": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"Y#2",
|
||||
"A"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
[
|
||||
-0.001,
|
||||
520.0
|
||||
],
|
||||
[
|
||||
-0.001,
|
||||
630.0
|
||||
],
|
||||
[
|
||||
-115.0,
|
||||
10.0
|
||||
],
|
||||
[
|
||||
-0.001,
|
||||
630.0
|
||||
],
|
||||
null
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {},
|
||||
"limitsSource": "linuxcnc-ini (missing: A)"
|
||||
},
|
||||
{
|
||||
"machineId": "scorbot-kins",
|
||||
"coordinates": "XYZAB",
|
||||
"entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
|
||||
"kinematics": "scorbot-kins",
|
||||
"jointCount": 5,
|
||||
"jointOrder": [
|
||||
"J0",
|
||||
"J1",
|
||||
"J2",
|
||||
"J3",
|
||||
"J4"
|
||||
],
|
||||
"limitsByJoint": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"declaredKinematicsJointCount": 5,
|
||||
"extraJointCount": 0,
|
||||
"parameters": {},
|
||||
"limitsSource": "linuxcnc-ini (missing: J0,J1,J2,J3,J4)"
|
||||
}
|
||||
]
|
||||
}
|
||||
1558
web/workspace/machine-visual-bindings.json
Normal file
1558
web/workspace/machine-visual-bindings.json
Normal file
File diff suppressed because it is too large
Load Diff
84
web/workspace/machine-visual-bindings.mjs
Normal file
84
web/workspace/machine-visual-bindings.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// Case id -> GLB node binding. Pure functions shared by the manager dialog and
|
||||
// the runtime view. Motion values always come from LinuxCNC WASM joints; this
|
||||
// module only says which scene node receives which joint slot.
|
||||
import bindings from "./machine-visual-bindings.json" with { type: "json" };
|
||||
import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
|
||||
|
||||
export const VISUAL_BINDINGS_SCHEMA = "linuxcnc.machine-visual-bindings/1";
|
||||
const SLOT_KINDS = new Set(["prismatic", "revolute", "strut"]);
|
||||
const SLOT_SOURCES = new Set(["kinematics", "extra", "duplicate", "passive"]);
|
||||
const AXIS_LETTER = /^[XYZABCUVW]$/;
|
||||
|
||||
const fail = (message) => { throw new Error(`视觉绑定配置无效:${message}`); };
|
||||
|
||||
if (bindings.schema !== VISUAL_BINDINGS_SCHEMA) fail("schema");
|
||||
if (typeof bindings.glbRoot !== "string" || !bindings.glbRoot) fail("glbRoot");
|
||||
if (!Array.isArray(bindings.cases) || !bindings.cases.length) fail("cases");
|
||||
|
||||
const catalog = new Map(MACHINE_DEFINITIONS.map((machine) => [machine.id, machine]));
|
||||
const byId = new Map();
|
||||
const byEntrypoint = new Map();
|
||||
|
||||
for (const item of bindings.cases) {
|
||||
if (!item.machineId || byId.has(item.machineId)) fail(`machineId 缺失或重复:${item.machineId}`);
|
||||
const machine = catalog.get(item.machineId);
|
||||
if (!machine) fail(`案例目录中没有 ${item.machineId}`);
|
||||
if (item.entrypoint !== machine.entrypoint) fail(`${item.machineId} entrypoint 与运行目录不一致`);
|
||||
if (item.coordinates !== machine.coordinates) fail(`${item.machineId} coordinates 与运行目录不一致`);
|
||||
if (!Array.isArray(item.jointOrder) || item.jointOrder.length !== machine.joint_count) fail(`${item.machineId} 槽位数 ${item.jointOrder?.length} 与 joint_count ${machine.joint_count} 不符`);
|
||||
if (!Array.isArray(item.bindings) || item.bindings.length !== item.jointOrder.length) fail(`${item.machineId} 绑定数量与槽位数不符`);
|
||||
const keys = new Set(), bound = new Set();
|
||||
for (const [position, slot] of item.jointOrder.entries()) {
|
||||
if (slot.index !== position) fail(`${item.machineId} 槽位索引不连续`);
|
||||
if (typeof slot.key !== "string" || !slot.key || keys.has(slot.key)) fail(`${item.machineId} 槽位 key 缺失或重复`);
|
||||
keys.add(slot.key);
|
||||
if (!SLOT_KINDS.has(slot.kind)) fail(`${item.machineId}/${slot.key} 类型`);
|
||||
if (slot.unit !== (slot.kind === "revolute" ? "deg" : "mm")) fail(`${item.machineId}/${slot.key} 单位`);
|
||||
if (slot.axis !== null && !AXIS_LETTER.test(slot.axis)) fail(`${item.machineId}/${slot.key} 轴字母`);
|
||||
if (!SLOT_SOURCES.has(slot.source)) fail(`${item.machineId}/${slot.key} 来源`);
|
||||
}
|
||||
for (const binding of item.bindings) {
|
||||
if (!Number.isInteger(binding.slot) || binding.slot < 0 || binding.slot >= item.jointOrder.length) fail(`${item.machineId} 绑定槽位越界`);
|
||||
if (bound.has(binding.slot)) fail(`${item.machineId} 槽位 ${binding.slot} 重复绑定`);
|
||||
bound.add(binding.slot);
|
||||
if (typeof binding.node !== "string" || !binding.node) fail(`${item.machineId} 槽位 ${binding.slot} 缺少节点名`);
|
||||
if (!["x", "y", "z"].includes(binding.direction)) fail(`${item.machineId} 槽位 ${binding.slot} 方向`);
|
||||
if (![1, -1].includes(binding.sign)) fail(`${item.machineId} 槽位 ${binding.slot} 正负向`);
|
||||
}
|
||||
for (const slot of item.jointOrder) if (slot.source !== "passive" && !bound.has(slot.index)) fail(`${item.machineId} 槽位 ${slot.key} 未绑定节点`);
|
||||
const zeros = item.calibration?.physicalZero;
|
||||
if (!Array.isArray(zeros) || zeros.length !== item.jointOrder.length || !zeros.every(Number.isFinite)) fail(`${item.machineId} 物理零点`);
|
||||
byId.set(item.machineId, Object.freeze(item));
|
||||
byEntrypoint.set(item.entrypoint, byId.get(item.machineId));
|
||||
}
|
||||
|
||||
export const VISUAL_BINDINGS = Object.freeze({ schema: VISUAL_BINDINGS_SCHEMA, glbRoot: bindings.glbRoot, cases: Object.freeze([...byId.values()]) });
|
||||
export const glbPath = (binding) => binding ? `${bindings.glbRoot}${binding.glb.default}` : null;
|
||||
export const visualBinding = (value) => byId.get(String(value ?? "")) ?? byEntrypoint.get(String(value ?? "")) ?? null;
|
||||
export const hasVisualBinding = (value) => Boolean(visualBinding(value));
|
||||
export const bindingGlbPath = (value) => glbPath(visualBinding(value));
|
||||
|
||||
// Build a v2 joint model from a binding plus the GLB node ids that carry
|
||||
// userData.linuxcncNodeId. Fails per slot instead of guessing a node.
|
||||
export function bindingJointModel(binding, nodeIds) {
|
||||
if (!binding) fail("缺少绑定");
|
||||
const ids = new Set(nodeIds);
|
||||
const slotMap = binding.jointOrder.map((slot) => {
|
||||
const row = binding.bindings.find((item) => item.slot === slot.index);
|
||||
// Node identity is userData.linuxcncNodeId, not the display name: two
|
||||
// machines may both call their node "x".
|
||||
const id = row?.nodeId ?? row?.node ?? null;
|
||||
return id && ids.has(id) ? id : null;
|
||||
});
|
||||
const missing = binding.jointOrder.filter((slot, index) => slotMap[index] === null && slot.source !== "passive").map((slot) => slot.key);
|
||||
return {
|
||||
schema: "linuxcnc.joint-model/2",
|
||||
machineId: binding.machineId,
|
||||
coordinates: binding.coordinates,
|
||||
units: { length: "mm", angle: "deg", gltfLength: "m" },
|
||||
controller: { source: "linuxcnc-wasm.visual.joints", jointOrder: binding.jointOrder.map((slot) => ({ ...slot })) },
|
||||
calibration: { cOriginMm: [...binding.calibration.cOriginMm], tiltOffsetMm: [...binding.calibration.tiltOffsetMm], physicalZero: [...binding.calibration.physicalZero] },
|
||||
slotMap,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
@@ -147,6 +147,28 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
|
||||
if (typeof fetchImpl !== "function") throw new Error("simulation_case_fetch_required");
|
||||
const catalog = findSimulationCase(path);
|
||||
if (!catalog) throw new Error("simulation_case_unknown");
|
||||
const genericMachine = machineDefinition(path);
|
||||
if (genericMachine?.runtime === "generic-wasm") {
|
||||
const axes = [...genericMachine.coordinates];
|
||||
const linear = genericMachine.units?.linear ?? "mm", angular = genericMachine.units?.angular ?? "deg";
|
||||
const lines = ["[EMC]", `MACHINE = ${genericMachine.label}`, "[DISPLAY]", "DISPLAY = axis", "MAX_FEED_OVERRIDE = 1", "[TASK]", "TASK = milltask", "CYCLE_TIME = 0.010", "[EMCMOT]", "EMCMOT = motmod", "SERVO_PERIOD = 1000000", "[TRAJ]", `COORDINATES = ${axes.join(" ")}`, `LINEAR_UNITS = ${linear}`, `ANGULAR_UNITS = ${angular}`, "DEFAULT_LINEAR_VELOCITY = 10", "MAX_LINEAR_VELOCITY = 20", "MAX_LINEAR_ACCELERATION = 100", "[KINS]", `JOINTS = ${genericMachine.joint_count}`, `KINEMATICS = ${genericMachine.kinematics}`];
|
||||
for (let index = 0; index < genericMachine.joint_count; index += 1) {
|
||||
const axis = axes[index] ?? `J${index}`;
|
||||
const range = axis.toUpperCase() === axis && "XYZ".includes(axis.toUpperCase()) ? [-1000, 1000] : [-360, 360];
|
||||
lines.push(`[JOINT_${index}]`, `TYPE = ${"XYZ".includes(axis.toUpperCase()) && axis !== "W" ? "LINEAR" : "ANGULAR"}`, `MIN_LIMIT = ${range[0]}`, `MAX_LIMIT = ${range[1]}`, "MAX_VELOCITY = 10", "MAX_ACCELERATION = 100", "HOME = 0");
|
||||
}
|
||||
const files = { [genericMachine.entrypoint]: `${lines.join("\n")}\n` };
|
||||
const programPath = genericMachine.program;
|
||||
const program = await fetchText(fetchImpl, baseUrl, programPath);
|
||||
if (program === null) throw new Error(`generic_machine_program_missing:${programPath}`);
|
||||
files[programPath] = program;
|
||||
if (genericMachine.source_path && genericMachine.source_path.startsWith("linuxcnc/") && genericMachine.source_path !== genericMachine.entrypoint) {
|
||||
const source = await fetchText(fetchImpl, baseUrl, genericMachine.source_path);
|
||||
if (source !== null) files[genericMachine.source_path] = source;
|
||||
}
|
||||
const unsigned = { schema: SIMULATION_CASE_SCHEMA, entrypoint: genericMachine.entrypoint, files, fetched: [genericMachine.entrypoint, programPath, genericMachine.source_path].filter((item) => item && item in files).sort(), missing: [], optional_missing: [], runtime_fixtures: [{ kind: "web-generic-kinematics", kinematics: genericMachine.kinematics, source_entrypoint: genericMachine.source_entrypoint }] };
|
||||
return { ...clone(unsigned), source_ini: files[genericMachine.entrypoint], catalog: { path: genericMachine.entrypoint, coordinates: genericMachine.coordinates, label: genericMachine.label, status: "web_generic_wasm" }, halManifest: [], status: "ready", state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
const files = {};
|
||||
const fetched = [];
|
||||
const missing = [];
|
||||
@@ -239,6 +261,23 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
|
||||
|
||||
export function toWorkerConfiguration(bundle) {
|
||||
if (!bundle || bundle.schema !== SIMULATION_CASE_SCHEMA) throw new Error("simulation_case_bundle_invalid");
|
||||
const genericMachine = machineDefinition(bundle.entrypoint);
|
||||
if (genericMachine?.runtime === "generic-wasm") {
|
||||
const parsed = parseIni({ workspace: new WorkspaceRuntime({ files: bundle.files }), entrypoint: bundle.entrypoint });
|
||||
const axes = String(parsed.sections.TRAJ.COORDINATES ?? "").replace(/\s+/g, "").toUpperCase();
|
||||
const joints = Number(parsed.sections.KINS.JOINTS);
|
||||
if (axes !== genericMachine.coordinates || joints !== genericMachine.joint_count) throw new Error("generic_machine_configuration_mismatch");
|
||||
const limits = {};
|
||||
for (let index = 0; index < joints; index += 1) {
|
||||
if (index >= axes.length) continue;
|
||||
const section = parsed.sections[`JOINT_${index}`];
|
||||
const range = [Number(section?.MIN_LIMIT), Number(section?.MAX_LIMIT)];
|
||||
if (!range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`generic_machine_joint_limit:${index}`);
|
||||
limits[axes[index].toLowerCase()] = range;
|
||||
}
|
||||
const kinematicsJointCount = genericMachine.kinematics_joint_count ?? joints;
|
||||
return { entrypoint: bundle.entrypoint, files: clone(bundle.files), halManifest: [], state_hash: bundle.state_hash, kinematics: { type: genericMachine.kinematics, runtime: "generic-wasm", coordinates: axes, joint_count: joints, kinematics_joint_count: kinematicsJointCount, extra_joint_count: genericMachine.extra_joint_count ?? joints - kinematicsJointCount, linear_units: genericMachine.units?.linear ?? "mm", angular_units: genericMachine.units?.angular ?? "deg", limits, parameters: clone(genericMachine.kinematics_parameters ?? {}) }, home_limits: { joint_count: joints, kinematics_type: genericMachine.kinematics_type, coordinates: axes, ...limits } };
|
||||
}
|
||||
const kinematics = CASE_KINEMATICS[bundle.entrypoint] ? clone(CASE_KINEMATICS[bundle.entrypoint]) : null;
|
||||
const trt = machineDefinition(bundle.entrypoint) ? readTrtConfiguration(bundle) : null;
|
||||
let homeLimits = null;
|
||||
|
||||
158
web/workspace/test_glb_axis_naming.mjs
Normal file
158
web/workspace/test_glb_axis_naming.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
// GLB 关节识别与 TRT 标定推导的单测(纯 Node,不需要浏览器)。
|
||||
// 运行:node web/workspace/test_glb_axis_naming.mjs
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { classifyAxisNodeName, deriveTrtCalibration, selectAxisNodes } from "./glb-axis-naming.mjs";
|
||||
import managerSettings from "./machine-manager-settings.json" with { type: "json" };
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const profileOf = (coordinates) => managerSettings.profiles.find((profile) => profile.coordinates === coordinates) ?? null;
|
||||
|
||||
// ---------------------------------------------------------------- 命名规则
|
||||
const NAMING_CASES = [
|
||||
// [节点名, 期望轴, 期望等级, 期望优先级, 说明]
|
||||
["JT_X", "X", "high", 0, "JT_ 精确名"],
|
||||
["JT_C", "C", "high", 0, "JT_ 精确名"],
|
||||
["X_AXIS", "X", "medium", 1, "轴字母 + AXIS"],
|
||||
["AXIS_Z", "Z", "medium", 1, "AXIS + 轴字母"],
|
||||
["b_axis", "B", "medium", 1, "轴字母 + AXIS"],
|
||||
["b_axis_bearing", "B", "low", 4, "同轴零件:优先级低于 b_axis"],
|
||||
["b_axis_axis", "B", "low", 4, "同轴零件:优先级低于 b_axis"],
|
||||
["a_axis_bearings", "A", "low", 4, "同轴零件:优先级低于 a_outer_table"],
|
||||
["x_slide", "X", "medium", 2, "直线滑台"],
|
||||
["y_saddle", "Y", "medium", 2.2, "Y 鞍座"],
|
||||
["y_saddle_casting", "Y", "medium", 2.22, "铸件:优先级低于 y_saddle"],
|
||||
["z_ram", "Z", "medium", 2.8, "Z 滑枕"],
|
||||
["moving_x_table", "X", "medium", 2.5, "轴字母在部件词之前"],
|
||||
["a_outer_table", "A", "medium", 2.5, "外转台 = A"],
|
||||
["b_inner_table", "B", "medium", 2.5, "内转台 = B"],
|
||||
["c_table", "C", "medium", 2.5, "回转工作台 = C"],
|
||||
["b_trunnion", "B", "medium", 2.3, "B 轴摇篮"],
|
||||
["a_tilt_frame", "A", "medium", 2.4, "A 轴倾转架"],
|
||||
["w_tool_direction", "W", "medium", 2.9, "W 刀具方向轴"],
|
||||
["x_linear_rail_guide", "X", "low", 4, "导轨属于零件级线索"],
|
||||
["bolt_x_1", "X", "low", 4, "螺栓属于零件级线索"],
|
||||
];
|
||||
for (const [name, axis, confidence, rank, note] of NAMING_CASES) {
|
||||
const result = classifyAxisNodeName(name);
|
||||
assert.ok(result, `${name} 应当被识别为轴(${note})`);
|
||||
assert.equal(result.axis, axis, `${name} 轴`);
|
||||
assert.equal(result.confidence, confidence, `${name} 可信度`);
|
||||
assert.ok(Math.abs(result.rank - rank) < 1e-9, `${name} 优先级应为 ${rank},实际 ${result.rank}`);
|
||||
}
|
||||
|
||||
for (const name of ["tilt_axis", "rotary_axis", "tilt_table", "rotary_axis_bearing", "work_platter"]) {
|
||||
const result = classifyAxisNodeName(name);
|
||||
assert.ok(result?.ambiguous, `${name} 应当是需要消歧的通用部件名`);
|
||||
}
|
||||
|
||||
for (const name of ["j1_base", "j2_shoulder", "portal_column", "column.001", "fixture_plate", "machine_root", "支腿", "", "tool"]) {
|
||||
assert.equal(classifyAxisNodeName(name), null, `${name} 不应被识别为数控轴`);
|
||||
}
|
||||
// 通用词 + 零件词的优先级低于主轴节点,避免把 tilt_bearing 当成倾转轴。
|
||||
assert.ok(classifyAxisNodeName("tilt_bearing_49").rank > classifyAxisNodeName("tilt_table").rank);
|
||||
assert.ok(classifyAxisNodeName("trunnion_pedestal_48").rank > classifyAxisNodeName("tilt_table").rank);
|
||||
|
||||
// ---------------------------------------------------------------- 真实 GLB 名称
|
||||
function readDescriptor(path) {
|
||||
const buffer = readFileSync(path);
|
||||
const jsonLength = buffer.readUInt32LE(12);
|
||||
const json = JSON.parse(buffer.subarray(20, 20 + jsonLength).toString("utf8"));
|
||||
const nodes = json.nodes ?? [];
|
||||
const depth = new Array(nodes.length).fill(0);
|
||||
const walk = (index, level) => { depth[index] = level; for (const child of nodes[index].children ?? []) walk(child, level + 1); };
|
||||
const scene = json.scenes?.[json.scene ?? 0];
|
||||
(scene?.nodes ?? []).forEach((index) => walk(index, 0));
|
||||
const urdf = scene?.extras?.linuxcncMachine?.urdf;
|
||||
const declared = scene?.extras?.linuxcncMachine?.coordinates ?? (typeof urdf === "string"
|
||||
? [...new Set([...urdf.matchAll(/<cnc:axis\s+coordinate="([XYZABCUVW])"/g)].map((match) => match[1]))].join("")
|
||||
: null);
|
||||
return {
|
||||
// Joint metadata written by scripts/generate_physical_case_glbs.mjs is the
|
||||
// authoritative axis hint; single-letter slot names carry no other clue.
|
||||
nodes: nodes.map((node, index) => ({ name: node.name ?? "", path: `scene/${index}`, depth: depth[index], explicitAxis: node.extras?.linuxcncJoint?.axis ?? node.extras?.["linuxcnc.axis"] ?? null })),
|
||||
declared: typeof declared === "string" && declared.length === 5 ? declared : null,
|
||||
};
|
||||
}
|
||||
|
||||
const analysis = (relativePath) => {
|
||||
const { nodes, declared } = readDescriptor(resolve(repoRoot, relativePath));
|
||||
const profile = declared ? profileOf(declared) : null;
|
||||
return selectAxisNodes(nodes, { declaredCoordinates: declared, tiltAxis: profile?.tiltAxis ?? null, rotaryAxis: profile?.rotaryAxis ?? null });
|
||||
};
|
||||
const boundAxes = (result) => result.axes.filter((item) => item.autoBind).map((item) => `${item.axis}:${item.name}`);
|
||||
|
||||
// 真实机床(带 JT_ 命名)与自带案例:不依赖任何提示即可绑定。
|
||||
const AUTO_CASES = [
|
||||
["glb/m1.glb", "XYZAC", ["X:JT_X", "Y:JT_Y", "Z:JT_Z", "A:JT_A", "C:JT_C"]],
|
||||
["web/react/public/machine-cases/physical/xyzac-trt.glb", "XYZAC", ["X:x", "Y:y", "Z:z", "A:a", "C:c"]],
|
||||
["web/react/public/machine-cases/physical/xyzbc-trt.glb", "XYZBC", ["X:x", "Y:y", "Z:z", "B:b", "C:c"]],
|
||||
["web/react/public/machine-cases/physical/xyzab-tdr.glb", "XYZAB", ["X:x", "Y:y", "Z:z", "A:a", "B:b"]],
|
||||
["web/react/public/machine-cases/physical/trivkins-xyzab.glb", "XYZAB", ["X:x", "Y:y", "Z:z", "A:a", "B:b"]],
|
||||
["web/react/public/machine-cases/physical/trivkins-xyzcw.glb", "XYZCW", ["X:x", "Y:y", "Z:z", "C:c", "W:w"]],
|
||||
];
|
||||
for (const [file, coordinates, expected] of AUTO_CASES) {
|
||||
const result = analysis(file);
|
||||
assert.equal(result.suggestedCoordinates, coordinates, `${file} 机型`);
|
||||
assert.deepEqual(boundAxes(result), expected, `${file} 绑定节点`);
|
||||
assert.deepEqual(result.missingAxes, [], `${file} 不应缺轴`);
|
||||
}
|
||||
|
||||
// 内置示例:场景内嵌 URDF 声明了轴序,按声明消歧后同样"导入即绑定"。
|
||||
const DECLARED_CASES = [
|
||||
["web/react/public/machines/demo-ac-xyz-head.glb", "XYZAC"],
|
||||
["web/react/public/machines/demo-ac-gantry.glb", "XYZAC"],
|
||||
["web/react/public/machines/demo-bc-gantry.glb", "XYZBC"],
|
||||
];
|
||||
for (const [file, coordinates] of DECLARED_CASES) {
|
||||
const result = analysis(file);
|
||||
assert.equal(result.suggestedCoordinates, coordinates, `${file} 机型`);
|
||||
assert.deepEqual(boundAxes(result).map((item) => item.split(":")[0]), [...coordinates], `${file} 应绑定五个轴`);
|
||||
}
|
||||
|
||||
// 重复轴与额外直线轴案例:分析器要求恰好五个唯一字母,因此这两类必须返回
|
||||
// UNKNOWN,由 machine-visual-bindings.json 按槽位显式绑定。
|
||||
// bridge-mill: XYZBCW + 重复 Y —— 六个不同字母
|
||||
// xyzya: XYZ + A + 重复 Y —— 只有四个不同字母
|
||||
for (const [id, reason] of [["bridge-mill-5axiskins", "六个不同轴字母"], ["trivkins-xyzya", "重复 Y 合并后只有四个字母"]]) {
|
||||
const result = analysis(`web/react/public/machine-cases/physical/${id}.glb`);
|
||||
assert.equal(result.suggestedCoordinates, "UNKNOWN", `${id} 不按五轴自动识别(${reason})`);
|
||||
assert.deepEqual(boundAxes(result), [], `${id} 不应自动绑定`);
|
||||
}
|
||||
|
||||
// 并联平台与串联机器人不是 XYZABC 语义,保持"需手动绑定",不误报。
|
||||
for (const id of ["hexapod-genhexkins", "pentapod-pentakins", "puma-pumakins", "puma-genserkins", "scorbot-kins"]) {
|
||||
const result = analysis(`web/react/public/machine-cases/physical/${id}.glb`);
|
||||
assert.equal(result.suggestedCoordinates, "UNKNOWN", `${id} 不应被当作五轴 XYZABC 机床`);
|
||||
assert.deepEqual(boundAxes(result), [], `${id} 不应自动绑定`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- TRT 标定推导
|
||||
const xyzac = profileOf("XYZAC");
|
||||
const calibration = deriveTrtCalibration({ coordinates: "XYZAC", profile: xyzac, cWorldMm: [10, 20, 30], tiltWorldMm: [110, 50, 70] });
|
||||
assert.deepEqual(calibration.cOriginMm, [10, 20, 30], "C 轴原点取 C 轴节点世界坐标");
|
||||
assert.deepEqual(calibration.tiltOffsetMm, [0, 30, 40], "AC 机型把沿 A 轴的 X 分量归零");
|
||||
assert.equal(calibration.zeroedOffsetValueMm, 100, "记录被归零的分量");
|
||||
assert.equal(calibration.zeroedOffsetDirection, "x");
|
||||
assert.deepEqual(calibration.physicalZero, { x: 0, y: 0, z: 0, a: 0, c: 0 });
|
||||
|
||||
const xyzbc = profileOf("XYZBC");
|
||||
const bcCalibration = deriveTrtCalibration({ coordinates: "XYZBC", profile: xyzbc, cWorldMm: [1, 2, 3], tiltWorldMm: [11, 22, 33] });
|
||||
assert.deepEqual(bcCalibration.tiltOffsetMm, [10, 0, 30], "BC 机型把沿 B 轴的 Y 分量归零");
|
||||
assert.equal(bcCalibration.zeroedOffsetValueMm, 20);
|
||||
|
||||
assert.equal(deriveTrtCalibration({ coordinates: "XYZAB", profile: null, cWorldMm: [0, 0, 0], tiltWorldMm: [1, 1, 1] }), null, "非 TRT 机型不产出原生标定");
|
||||
assert.equal(deriveTrtCalibration({ coordinates: "XYZAC", profile: xyzac, cWorldMm: null, tiltWorldMm: [1, 1, 1] }), null, "缺坐标时不产出标定");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
report_id: "glb-axis-naming-v1",
|
||||
status: "verified",
|
||||
naming_cases: NAMING_CASES.length + 5 + 9,
|
||||
auto_binding_cases: AUTO_CASES.length,
|
||||
declared_binding_cases: DECLARED_CASES.length,
|
||||
manual_only_cases: 5,
|
||||
trt_calibration: calibration,
|
||||
}, null, 1));
|
||||
53
web/workspace/test_machine_kinematics_specs.mjs
Normal file
53
web/workspace/test_machine_kinematics_specs.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
// Drift check: machine-kinematics-specs.json must match the limits declared in
|
||||
// the LinuxCNC INIs. A null limit means the source declares none; inventing a
|
||||
// number here would put a fabricated value in front of the operator.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
|
||||
|
||||
// This file lives in web/workspace/, so the repo root is two levels up.
|
||||
const root = resolve(new URL("../..", import.meta.url).pathname);
|
||||
const specs = JSON.parse(readFileSync(join(root, "web/workspace/machine-kinematics-specs.json"), "utf8"));
|
||||
assert.equal(specs.schema, "linuxcnc.machine-kinematics-specs/1");
|
||||
|
||||
function readCase(entrypoint) {
|
||||
const path = join(root, entrypoint);
|
||||
if (!existsSync(path)) return null;
|
||||
let text = readFileSync(path, "utf8");
|
||||
for (const match of text.matchAll(/^\s*(?:#)?INCLUDE\s+(\S+)/gim)) {
|
||||
const include = join(dirname(path), match[1]);
|
||||
if (existsSync(include)) text += `\n${readFileSync(include, "utf8")}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
// `m` alone makes `$` match at the first line end, which truncated every body.
|
||||
// The lookahead uses a "no more input" test instead of `$` so the section runs
|
||||
// to the next header or to the end of the file.
|
||||
const section = (text, name) => {
|
||||
const match = text.match(new RegExp(`^\\[${name}\\]\\s*(?:\\r?\\n)([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "im"));
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
const value = (body, key) => body?.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\S+)`, "im"))?.[1] ?? null;
|
||||
|
||||
const generic = MACHINE_DEFINITIONS.filter((machine) => machine.runtime === "generic-wasm");
|
||||
assert.equal(specs.cases.length, generic.length, "每个通用案例都应有规格");
|
||||
for (const machine of generic) {
|
||||
const spec = specs.cases.find((item) => item.machineId === machine.id);
|
||||
assert.ok(spec, `${machine.id} 缺少规格`);
|
||||
assert.equal(spec.jointCount, machine.joint_count, `${machine.id} 槽位数`);
|
||||
assert.equal(spec.coordinates, machine.coordinates, `${machine.id} 坐标`);
|
||||
assert.equal(spec.jointOrder.length, machine.joint_count, `${machine.id} 槽位键数量`);
|
||||
assert.equal(spec.limitsByJoint.length, machine.joint_count, `${machine.id} 限位数量`);
|
||||
const text = readCase(machine.entrypoint);
|
||||
assert.ok(text, `${machine.id} 的 INI 不存在:${machine.entrypoint}`);
|
||||
for (let index = 0; index < machine.joint_count; index += 1) {
|
||||
const body = section(text, `JOINT_${index}`);
|
||||
const lower = value(body, "MIN_LIMIT"), upper = value(body, "MAX_LIMIT");
|
||||
const expected = lower !== null && upper !== null && Number.isFinite(Number(lower)) && Number.isFinite(Number(upper))
|
||||
? [Number(lower), Number(upper)] : null;
|
||||
assert.deepEqual(spec.limitsByJoint[index], expected,
|
||||
`${machine.id} J${index} 限位与 INI 不一致(spec=${JSON.stringify(spec.limitsByJoint[index])} ini=${JSON.stringify(expected)})`);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ schema: "machine-kinematics-specs.v1", status: "verified", cases: specs.cases.length, checks: ["槽位数与运行目录一致", "逐关节限位与 INI 完全一致", "INI 未声明的限位保持 null"] }));
|
||||
38
web/workspace/test_machine_visual_bindings.mjs
Normal file
38
web/workspace/test_machine_visual_bindings.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
|
||||
import { VISUAL_BINDINGS, bindingGlbPath, bindingJointModel, hasVisualBinding, visualBinding } from "./machine-visual-bindings.mjs";
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
assert.equal(VISUAL_BINDINGS.cases.length, MACHINE_DEFINITIONS.length, "每个案例都必须有绑定");
|
||||
for (const machine of MACHINE_DEFINITIONS) {
|
||||
const binding = visualBinding(machine.id);
|
||||
assert.ok(binding, `${machine.id} 缺少绑定`);
|
||||
assert.equal(binding.jointCount, machine.joint_count, `${machine.id} 槽位数`);
|
||||
assert.equal(binding.coordinates, machine.coordinates, `${machine.id} 坐标`);
|
||||
const file = `web/react/public/${bindingGlbPath(machine.id)}`;
|
||||
assert.ok(existsSync(file), `${machine.id} GLB 缺失`);
|
||||
assert.ok(statSync(file).size <= MAX_BYTES, `${machine.id} GLB 超限`);
|
||||
}
|
||||
assert.ok(hasVisualBinding("xyzac-trt"));
|
||||
assert.ok(visualBinding("linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"), "entrypoint 查找");
|
||||
assert.equal(visualBinding("no-such-case"), null);
|
||||
assert.equal(visualBinding("XYZAB"), null, "坐标字母不得作为键(三个案例共用)");
|
||||
|
||||
// bindingJointModel reports per-slot gaps instead of guessing nodes.
|
||||
const bridge = visualBinding("bridge-mill-5axiskins");
|
||||
const nodes = bridge.bindings.map((item) => item.nodeId);
|
||||
const full = bindingJointModel(bridge, nodes);
|
||||
assert.deepEqual(full.missing, []);
|
||||
assert.equal(full.slotMap.length, 7);
|
||||
assert.equal(full.controller.jointOrder[6].key, "Y#2");
|
||||
const partial = bindingJointModel(bridge, nodes.filter((id) => id !== bridge.bindings.find((item) => item.node === "w").nodeId));
|
||||
assert.deepEqual(partial.missing, ["W"]);
|
||||
assert.equal(partial.slotMap[5], null);
|
||||
|
||||
// Passive slots are allowed to stay unbound (max5 joint 5 has no visual motion).
|
||||
const max5 = visualBinding("max5-maxkins");
|
||||
const max5Model = bindingJointModel(max5, max5.bindings.map((item) => item.nodeId));
|
||||
assert.deepEqual(max5Model.missing, []);
|
||||
|
||||
console.log(JSON.stringify({ schema: "machine-visual-bindings.v1", status: "verified", cases: VISUAL_BINDINGS.cases.length, checks: ["13 案例全覆盖", "槽位数与 joint_count 一致", "GLB 存在且 ≤64 MiB", "坐标字母不作键", "缺节点逐槽报告", "被动槽允许不绑定"] }));
|
||||
Reference in New Issue
Block a user