按关节槽位驱动 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: "./",
|
||||
|
||||
Reference in New Issue
Block a user