Add AC/BC RTCP programs and 3D machine manager
This commit is contained in:
4
web/react/public/machines/LICENSE.txt
Normal file
4
web/react/public/machines/LICENSE.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Original procedural AC/BC machine demonstration assets generated for linuxcncWebWASM.
|
||||
Dedicated to the public domain under CC0 1.0 Universal.
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
No manufacturer affiliation or dimensional calibration is implied.
|
||||
BIN
web/react/public/machines/demo-ac-gantry.glb
Normal file
BIN
web/react/public/machines/demo-ac-gantry.glb
Normal file
Binary file not shown.
45
web/react/public/machines/demo-ac-gantry.urdf
Normal file
45
web/react/public/machines/demo-ac-gantry.urdf
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot name="demo_ac_gantry" xmlns:cnc="urn:linuxcnc:web:machine:1">
|
||||
<cnc:machine profile="trt-xyz-gantry/1" base="base" tool="z_slide" work="work_frame"/>
|
||||
<link name="base"/>
|
||||
<link name="x_slide"/>
|
||||
<link name="y_slide"/>
|
||||
<link name="z_slide"/>
|
||||
<link name="tilt_table"/>
|
||||
<link name="c_table"/>
|
||||
<link name="work_frame"/>
|
||||
<joint name="x_joint" type="prismatic">
|
||||
<parent link="base"/><child link="x_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/><cnc:axis coordinate="X"/>
|
||||
<limit lower="-0.2" upper="0.2" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="y_joint" type="prismatic">
|
||||
<parent link="x_slide"/><child link="y_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/><cnc:axis coordinate="Y"/>
|
||||
<limit lower="-0.1" upper="0.1" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="z_joint" type="prismatic">
|
||||
<parent link="y_slide"/><child link="z_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="Z"/>
|
||||
<limit lower="-0.12" upper="0.12" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="tilt_joint" type="revolute">
|
||||
<parent link="base"/><child link="tilt_table"/>
|
||||
<origin xyz="0.024 0.005999999999999998 -0.037" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/><cnc:axis coordinate="A"/>
|
||||
<limit lower="-1.7453292519943295" upper="0.8726646259971648" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="c_joint" type="revolute">
|
||||
<parent link="tilt_table"/><child link="c_table"/>
|
||||
<origin xyz="0 -0.018 0.045" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="C"/>
|
||||
<limit lower="-628.3185307179587" upper="628.3185307179587" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="work_mount" type="fixed">
|
||||
<parent link="c_table"/><child link="work_frame"/>
|
||||
<origin xyz="-0.024 0.012 -0.008" rpy="0 0 0"/>
|
||||
</joint>
|
||||
</robot>
|
||||
BIN
web/react/public/machines/demo-ac-xyz-head.glb
Normal file
BIN
web/react/public/machines/demo-ac-xyz-head.glb
Normal file
Binary file not shown.
50
web/react/public/machines/demo-ac-xyz-head.urdf
Normal file
50
web/react/public/machines/demo-ac-xyz-head.urdf
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot name="demo_ac_xyz_head" xmlns:cnc="urn:linuxcnc:web:machine:1">
|
||||
<cnc:machine profile="trt-xyz-head/1" base="base" tool="spindle_head" work="work_frame"/>
|
||||
<link name="base"/>
|
||||
<link name="x_slide"/>
|
||||
<link name="y_slide"/>
|
||||
<link name="z_slide"/>
|
||||
<link name="spindle_head"/>
|
||||
<link name="tilt_table"/>
|
||||
<link name="c_table"/>
|
||||
<link name="work_frame"/>
|
||||
<joint name="x_joint" type="prismatic">
|
||||
<parent link="base"/><child link="x_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/><cnc:axis coordinate="X"/>
|
||||
<limit lower="-0.2" upper="0.2" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="y_joint" type="prismatic">
|
||||
<parent link="x_slide"/><child link="y_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/><cnc:axis coordinate="Y"/>
|
||||
<limit lower="-0.1" upper="0.1" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="z_joint" type="prismatic">
|
||||
<parent link="y_slide"/><child link="z_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="Z"/>
|
||||
<limit lower="-0.12" upper="0.12" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="spindle_mount" type="fixed">
|
||||
<parent link="z_slide"/><child link="spindle_head"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
</joint>
|
||||
<joint name="tilt_joint" type="revolute">
|
||||
<parent link="base"/><child link="tilt_table"/>
|
||||
<origin xyz="0.024 0.005999999999999998 -0.037" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/><cnc:axis coordinate="A"/>
|
||||
<limit lower="-1.7453292519943295" upper="0.8726646259971648" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="c_joint" type="revolute">
|
||||
<parent link="tilt_table"/><child link="c_table"/>
|
||||
<origin xyz="0 -0.018 0.045" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="C"/>
|
||||
<limit lower="-628.3185307179587" upper="628.3185307179587" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="work_mount" type="fixed">
|
||||
<parent link="c_table"/><child link="work_frame"/>
|
||||
<origin xyz="-0.024 0.012 -0.008" rpy="0 0 0"/>
|
||||
</joint>
|
||||
</robot>
|
||||
BIN
web/react/public/machines/demo-bc-gantry.glb
Normal file
BIN
web/react/public/machines/demo-bc-gantry.glb
Normal file
Binary file not shown.
45
web/react/public/machines/demo-bc-gantry.urdf
Normal file
45
web/react/public/machines/demo-bc-gantry.urdf
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot name="demo_bc_gantry" xmlns:cnc="urn:linuxcnc:web:machine:1">
|
||||
<cnc:machine profile="trt-xyz-gantry/1" base="base" tool="z_slide" work="work_frame"/>
|
||||
<link name="base"/>
|
||||
<link name="x_slide"/>
|
||||
<link name="y_slide"/>
|
||||
<link name="z_slide"/>
|
||||
<link name="tilt_table"/>
|
||||
<link name="c_table"/>
|
||||
<link name="work_frame"/>
|
||||
<joint name="x_joint" type="prismatic">
|
||||
<parent link="base"/><child link="x_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/><cnc:axis coordinate="X"/>
|
||||
<limit lower="-0.2" upper="0.2" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="y_joint" type="prismatic">
|
||||
<parent link="x_slide"/><child link="y_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/><cnc:axis coordinate="Y"/>
|
||||
<limit lower="-0.1" upper="0.1" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="z_joint" type="prismatic">
|
||||
<parent link="y_slide"/><child link="z_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="Z"/>
|
||||
<limit lower="-0.12" upper="0.12" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="tilt_joint" type="revolute">
|
||||
<parent link="base"/><child link="tilt_table"/>
|
||||
<origin xyz="0.004 0.014 -0.028" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/><cnc:axis coordinate="B"/>
|
||||
<limit lower="-628.3185307179587" upper="628.3185307179587" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="c_joint" type="revolute">
|
||||
<parent link="tilt_table"/><child link="c_table"/>
|
||||
<origin xyz="-0.022 0 0.04" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="C"/>
|
||||
<limit lower="-628.3185307179587" upper="628.3185307179587" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="work_mount" type="fixed">
|
||||
<parent link="c_table"/><child link="work_frame"/>
|
||||
<origin xyz="0.018 -0.014 -0.012" rpy="0 0 0"/>
|
||||
</joint>
|
||||
</robot>
|
||||
BIN
web/react/public/machines/demo-bc-xyz-head.glb
Normal file
BIN
web/react/public/machines/demo-bc-xyz-head.glb
Normal file
Binary file not shown.
50
web/react/public/machines/demo-bc-xyz-head.urdf
Normal file
50
web/react/public/machines/demo-bc-xyz-head.urdf
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot name="demo_bc_xyz_head" xmlns:cnc="urn:linuxcnc:web:machine:1">
|
||||
<cnc:machine profile="trt-xyz-head/1" base="base" tool="spindle_head" work="work_frame"/>
|
||||
<link name="base"/>
|
||||
<link name="x_slide"/>
|
||||
<link name="y_slide"/>
|
||||
<link name="z_slide"/>
|
||||
<link name="spindle_head"/>
|
||||
<link name="tilt_table"/>
|
||||
<link name="c_table"/>
|
||||
<link name="work_frame"/>
|
||||
<joint name="x_joint" type="prismatic">
|
||||
<parent link="base"/><child link="x_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/><cnc:axis coordinate="X"/>
|
||||
<limit lower="-0.2" upper="0.2" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="y_joint" type="prismatic">
|
||||
<parent link="x_slide"/><child link="y_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/><cnc:axis coordinate="Y"/>
|
||||
<limit lower="-0.1" upper="0.1" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="z_joint" type="prismatic">
|
||||
<parent link="y_slide"/><child link="z_slide"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="Z"/>
|
||||
<limit lower="-0.12" upper="0.12" velocity="0.02" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="spindle_mount" type="fixed">
|
||||
<parent link="z_slide"/><child link="spindle_head"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
</joint>
|
||||
<joint name="tilt_joint" type="revolute">
|
||||
<parent link="base"/><child link="tilt_table"/>
|
||||
<origin xyz="0.004 0.014 -0.028" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/><cnc:axis coordinate="B"/>
|
||||
<limit lower="-628.3185307179587" upper="628.3185307179587" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="c_joint" type="revolute">
|
||||
<parent link="tilt_table"/><child link="c_table"/>
|
||||
<origin xyz="-0.022 0 0.04" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/><cnc:axis coordinate="C"/>
|
||||
<limit lower="-628.3185307179587" upper="628.3185307179587" velocity="0.5235987755982988" effort="1000"/>
|
||||
</joint>
|
||||
<joint name="work_mount" type="fixed">
|
||||
<parent link="c_table"/><child link="work_frame"/>
|
||||
<origin xyz="0.018 -0.014 -0.012" rpy="0 0 0"/>
|
||||
</joint>
|
||||
</robot>
|
||||
130
web/react/public/machines/manifest.json
Normal file
130
web/react/public/machines/manifest.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"schema": "linuxcnc.machine.examples/1",
|
||||
"generator": "scripts/generate_glb_machines.mjs",
|
||||
"provenance": "Original procedural demonstration machines; no third-party mesh assets",
|
||||
"license": "CC0-1.0",
|
||||
"machines": [
|
||||
{
|
||||
"id": "ac",
|
||||
"label": "AC 示例",
|
||||
"profile": "trt-xyz-head/1",
|
||||
"filename": "demo-ac-xyz-head.glb",
|
||||
"urdf": "demo-ac-xyz-head.urdf",
|
||||
"bytes": 530848,
|
||||
"sha256": "93698435206864ae42cdd2121be570538e977e32c6a3a99095a93dd1518f8da1",
|
||||
"coordinates": "XYZAC",
|
||||
"rotation_point_mm": [
|
||||
24,
|
||||
-12,
|
||||
8
|
||||
],
|
||||
"offsets_mm": [
|
||||
0,
|
||||
18,
|
||||
-45
|
||||
],
|
||||
"links": [
|
||||
"base",
|
||||
"x_slide",
|
||||
"y_slide",
|
||||
"z_slide",
|
||||
"spindle_head",
|
||||
"tilt_table",
|
||||
"c_table",
|
||||
"work_frame"
|
||||
],
|
||||
"meshes": 71
|
||||
},
|
||||
{
|
||||
"id": "bc",
|
||||
"label": "BC 示例",
|
||||
"profile": "trt-xyz-head/1",
|
||||
"filename": "demo-bc-xyz-head.glb",
|
||||
"urdf": "demo-bc-xyz-head.urdf",
|
||||
"bytes": 530532,
|
||||
"sha256": "394c7cf3c8b246e1fc88e74301d9447f4e0892381ffa4fe1f86653a678efe95b",
|
||||
"coordinates": "XYZBC",
|
||||
"rotation_point_mm": [
|
||||
-18,
|
||||
14,
|
||||
12
|
||||
],
|
||||
"offsets_mm": [
|
||||
22,
|
||||
0,
|
||||
-40
|
||||
],
|
||||
"links": [
|
||||
"base",
|
||||
"x_slide",
|
||||
"y_slide",
|
||||
"z_slide",
|
||||
"spindle_head",
|
||||
"tilt_table",
|
||||
"c_table",
|
||||
"work_frame"
|
||||
],
|
||||
"meshes": 71
|
||||
},
|
||||
{
|
||||
"id": "ac-gantry",
|
||||
"label": "AC 示例桁架型",
|
||||
"profile": "trt-xyz-gantry/1",
|
||||
"filename": "demo-ac-gantry.glb",
|
||||
"urdf": "demo-ac-gantry.urdf",
|
||||
"bytes": 526004,
|
||||
"sha256": "cee9fe5080fac3f87f8daca5d0f89ddb0c3c3eb7657fbdc81a7664ba9c4e4995",
|
||||
"coordinates": "XYZAC",
|
||||
"rotation_point_mm": [
|
||||
24,
|
||||
-12,
|
||||
8
|
||||
],
|
||||
"offsets_mm": [
|
||||
0,
|
||||
18,
|
||||
-45
|
||||
],
|
||||
"links": [
|
||||
"base",
|
||||
"x_slide",
|
||||
"y_slide",
|
||||
"z_slide",
|
||||
"tilt_table",
|
||||
"c_table",
|
||||
"work_frame"
|
||||
],
|
||||
"meshes": 89
|
||||
},
|
||||
{
|
||||
"id": "bc-gantry",
|
||||
"label": "BC 示例桁架型",
|
||||
"profile": "trt-xyz-gantry/1",
|
||||
"filename": "demo-bc-gantry.glb",
|
||||
"urdf": "demo-bc-gantry.urdf",
|
||||
"bytes": 525688,
|
||||
"sha256": "d7188520ef1e79576d99861294e9dbdf24deb6fe14697ad5106fe605fa1766b5",
|
||||
"coordinates": "XYZBC",
|
||||
"rotation_point_mm": [
|
||||
-18,
|
||||
14,
|
||||
12
|
||||
],
|
||||
"offsets_mm": [
|
||||
22,
|
||||
0,
|
||||
-40
|
||||
],
|
||||
"links": [
|
||||
"base",
|
||||
"x_slide",
|
||||
"y_slide",
|
||||
"z_slide",
|
||||
"tilt_table",
|
||||
"c_table",
|
||||
"work_frame"
|
||||
],
|
||||
"meshes": 89
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
55
web/react/src/DraggableDialog.tsx
Normal file
55
web/react/src/DraggableDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
|
||||
type Props = { open: boolean; title: string; className?: string; onClose: () => void; children: ReactNode };
|
||||
|
||||
// Native dialog provides focus containment, inert background, Escape and focus return.
|
||||
// Keeping children mounted preserves drafts and pending import results when closed.
|
||||
export function DraggableDialog({ open, title, className = "", onClose, children }: Props) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const drag = useRef<{ id: number; x: number; y: number; left: number; top: number } | null>(null);
|
||||
const position = (left: number, top: number) => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
const width = document.documentElement.clientWidth, height = window.innerHeight;
|
||||
dialog.style.left = `${Math.max(8, Math.min(left, width - bounds.width - 8))}px`;
|
||||
dialog.style.top = `${Math.max(8, Math.min(top, height - bounds.height - 8))}px`;
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
const dialog = ref.current!;
|
||||
if (!open) { if (dialog.open) dialog.close(); return; }
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
position((document.documentElement.clientWidth - bounds.width) / 2, (window.innerHeight - bounds.height) / 2);
|
||||
const keepVisible = () => { const rect = dialog.getBoundingClientRect(); position(rect.left, rect.top); };
|
||||
const observer = new ResizeObserver(keepVisible);
|
||||
observer.observe(dialog); window.addEventListener("resize", keepVisible);
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", keepVisible); drag.current = null; };
|
||||
}, [open]);
|
||||
useEffect(() => () => { if (ref.current?.open) ref.current.close(); }, []);
|
||||
const start = (event: PointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 || (event.target as HTMLElement).closest("button")) return;
|
||||
const bounds = ref.current!.getBoundingClientRect();
|
||||
drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY, left: bounds.left, top: bounds.top };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const move = (event: PointerEvent<HTMLElement>) => {
|
||||
const from = drag.current;
|
||||
if (from?.id !== event.pointerId) return;
|
||||
position(from.left + event.clientX - from.x, from.top + event.clientY - from.y);
|
||||
};
|
||||
const stop = () => { drag.current = null; };
|
||||
return <dialog ref={ref} className={`machine-dialog ${className}`} aria-modal="true" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); onClose(); }} onKeyDown={(event) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const elements = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]')].filter((element) => element.getClientRects().length > 0);
|
||||
const first = elements[0], last = elements.at(-1);
|
||||
if (event.shiftKey && (document.activeElement === first || !elements.includes(document.activeElement as HTMLElement))) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && (document.activeElement === last || !elements.includes(document.activeElement as HTMLElement))) { event.preventDefault(); first?.focus(); }
|
||||
}}>
|
||||
<header className="machine-dialog-titlebar" onPointerDown={start} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={stop}>
|
||||
<strong id={titleId}>{title}</strong><button type="button" autoFocus aria-label={`关闭${title}`} onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="machine-dialog-body">{children}</div>
|
||||
</dialog>;
|
||||
}
|
||||
32
web/react/src/MachineImportPanel.tsx
Normal file
32
web/react/src/MachineImportPanel.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
import type { ImportedMachine } from "./imported-machine";
|
||||
|
||||
import exampleCatalog from "../public/machines/manifest.json";
|
||||
const examples = exampleCatalog.machines;
|
||||
export function MachineImportPanel({ model, disabled, onImport }: { model: ImportedMachine | null; disabled: boolean; onImport: (source: File | string) => Promise<void> }) {
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const run = async (source: File | string) => {
|
||||
if (disabled) return;
|
||||
setMessage("正在读取机床结构…");
|
||||
try { await onImport(source); setMessage("模型及参数已应用;请重新回零。页面重载后需重新导入 GLB。"); }
|
||||
catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
};
|
||||
const s = model?.structure;
|
||||
return <section className="machine-import" aria-label="GLB 机床导入" data-machine-import={model?.hash ?? "builtin"}>
|
||||
<div className="machine-import-toolbar"><button type="button" data-axis-control="machine-glb-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>导入机床模型</button></div>
|
||||
<DraggableDialog open={open} title="三维机床模型" className="model-dialog" onClose={() => setOpen(false)}>
|
||||
<header><strong>三维机床</strong><span>{s ? `${s.coordinates} · ${model.filename}` : "内置模型"}</span></header>
|
||||
<div className="machine-import-actions">
|
||||
<button type="button" data-axis-control="machine-glb-import" disabled={disabled} onClick={() => file.current?.click()}>导入 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>
|
||||
<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>;
|
||||
}
|
||||
122
web/react/src/MachineManagerPanel.tsx
Normal file
122
web/react/src/MachineManagerPanel.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
||||
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
|
||||
import { buildManagedMachine, cloneMachinePartScene, expectedParentRole, loadMachinePart, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type PhysicalZero } from "./machine-manager";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||
const zero3 = (): Vec3 => [0, 0, 0];
|
||||
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
|
||||
function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: MachinePartDraft[]; selectedId: string | null; mode: "translate" | "rotate"; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform;
|
||||
useEffect(() => {
|
||||
const element = host.current; if (!element) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); renderer.shadowMap.enabled = true; renderer.setSize(Math.max(element.clientWidth, 320), Math.max(element.clientHeight, 360), false); element.appendChild(renderer.domElement);
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x10191d); scene.add(new THREE.HemisphereLight(0xffffff, 0x405057, 2));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 3); light.position.set(3, 5, 4); light.castShadow = true; scene.add(light);
|
||||
const grid = new THREE.GridHelper(4, 40, 0x55777b, 0x294347); scene.add(grid);
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.position.set(2.4, 1.8, 2.8);
|
||||
const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true;
|
||||
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
|
||||
const nodes = new Map<string, THREE.Group>(); const disposable: THREE.Object3D[] = [];
|
||||
for (const part of parts) {
|
||||
const node = new THREE.Group(); node.name = `draft_${part.id}`; node.visible = part.visible; node.position.fromArray(part.translationMm).multiplyScalar(.001); node.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3);
|
||||
const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual); disposable.push(visual);
|
||||
const axes = new THREE.AxesHelper(.12); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node);
|
||||
}
|
||||
const cycle = (child: string, parent: string) => { let cursor: string | null = parent; const seen = new Set([child]); while (cursor) { if (seen.has(cursor)) return true; seen.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return false; };
|
||||
for (const part of parts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!);
|
||||
const bounds = new THREE.Box3().setFromObject(root); const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()); const size = bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .2);
|
||||
orbit.target.copy(center); camera.position.copy(center).add(new THREE.Vector3(size, size * .7, size * 1.15)); orbit.update();
|
||||
const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper());
|
||||
const selected = selectedId ? nodes.get(selectedId) : null; if (selected) transform.attach(selected);
|
||||
transform.addEventListener("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never);
|
||||
transform.addEventListener("mouseUp", (() => {
|
||||
if (!selectedId || !selected) return;
|
||||
onTransformRef.current(selectedId, selected.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selected.rotation.x), THREE.MathUtils.radToDeg(selected.rotation.y), THREE.MathUtils.radToDeg(selected.rotation.z)]);
|
||||
}) as never);
|
||||
const resize = () => { const width = Math.max(element.clientWidth, 1), height = Math.max(element.clientHeight, 1); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
|
||||
const observer = new ResizeObserver(resize); observer.observe(element); resize(); let frame = 0;
|
||||
const render = () => { orbit.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render); }; render();
|
||||
return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); for (const object of disposable) disposeMachineScene(object); renderer.dispose(); renderer.domElement.remove(); };
|
||||
}, [parts, selectedId, mode]);
|
||||
return <div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览" />;
|
||||
}
|
||||
|
||||
export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
|
||||
const [parts, setParts] = useState<MachinePartDraft[]>([]); const partsRef = useRef(parts); partsRef.current = parts;
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null); const [mode, setMode] = useState<"translate" | "rotate">("translate");
|
||||
const [cOriginMm, setCOriginMm] = useState<Vec3>([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState<Vec3>([0, 0, 0]);
|
||||
const [physicalZero, setPhysicalZero] = useState<PhysicalZero>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => () => { for (const part of partsRef.current) disposeMachineScene(part.scene); }, []);
|
||||
useEffect(() => { setMessage(""); setCOriginMm([0, 0, 0]); setTiltOffsetMm([0, 0, 0]); setPhysicalZero({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); }, [coordinates]);
|
||||
const selected = parts.find((part) => part.id === selectedId) ?? null;
|
||||
const update = (id: string, values: Partial<MachinePartDraft>) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part));
|
||||
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) => <fieldset className="machine-vector"><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<input type="number" step="any" value={value[index]} onChange={(event) => { const next = [...value] as Vec3; next[index] = number(event.target.value); set(next); }} /></label>)}</fieldset>;
|
||||
const importFiles = async (list: FileList | File[] | null) => {
|
||||
if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||
try {
|
||||
const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name)));
|
||||
setParts((current) => {
|
||||
const next = [...current];
|
||||
for (const source of loaded) {
|
||||
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
|
||||
const parentRole = role ? expectedParentRole(role) : null;
|
||||
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3() });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectedId(loaded[0].id); setMessage(`已导入 ${loaded.length} 个部件`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const remove = (part: MachinePartDraft) => { disposeMachineScene(part.scene); setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); if (selectedId === part.id) setSelectedId(null); };
|
||||
const apply = async () => {
|
||||
if (disabled) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
|
||||
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setMessage("三维机床已应用;请重新回零后运行程序"); }
|
||||
catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const exportGlb = async () => {
|
||||
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
|
||||
try {
|
||||
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
|
||||
const bytes = await new GLTFExporter().parseAsync(machine.scene, { binary: true, trs: true, onlyVisible: false });
|
||||
const url = URL.createObjectURL(new Blob([bytes as ArrayBuffer], { type: "model/gltf-binary" }));
|
||||
const link = document.createElement("a"); link.href = url; link.download = machine.filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
||||
};
|
||||
const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => {
|
||||
const parentRole = role ? expectedParentRole(role) : null;
|
||||
update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null });
|
||||
};
|
||||
return <section className="machine-manager" aria-label="三维机床管理">
|
||||
<button type="button" data-axis-control="machine-manager-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>三维机床管理</button>
|
||||
<DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" onClose={() => setOpen(false)}>
|
||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-translate" aria-pressed={mode === "translate"} onClick={() => setMode("translate")}>平移部件</button><button type="button" data-axis-control="machine-manager-rotate" aria-pressed={mode === "rotate"} onClick={() => setMode("rotate")}>旋转部件</button><span>{parts.length}/8 部件</span></div>
|
||||
<div className="machine-manager-layout">
|
||||
<MachineManagerPanelPreview parts={parts} selectedId={selectedId} mode={mode} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} />
|
||||
<div className="machine-manager-editor">
|
||||
<section className="machine-part-list"><h3>部件与层级</h3>{parts.length ? parts.map((part) => <div key={part.id} className={part.id === selectedId ? "selected" : ""}><button type="button" onClick={() => setSelectedId(part.id)}>{part.role ? roleLabel(part.role, coordinates) : "未分配"}<small>{part.filename}</small></button><button type="button" aria-label={`删除 ${part.filename}`} onClick={() => remove(part)}>×</button></div>) : <p>导入八个 GLB 部件后建立机械链。</p>}</section>
|
||||
{selected && <section className="machine-part-editor"><h3>{selected.filename}</h3><label>机床角色<select data-axis-control="machine-manager-role" value={selected.role} 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><label>父部件<select data-axis-control="machine-manager-parent" value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => part.id !== selected.id).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label><div className="machine-part-flags"><label><input type="checkbox" checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div>{vectorInput("模型局部原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm")}{vectorInput("部件平移", selected.translationMm, (translationMm) => update(selected.id, { translationMm }), "mm")}{vectorInput("部件旋转", selected.rotationDeg, (rotationDeg) => update(selected.id, { rotationDeg }), "deg")}</section>}
|
||||
</div>
|
||||
</div>
|
||||
<section className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点 (XYZ mm / ABC deg)</legend>{["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => <label key={axis}>{axis.toUpperCase()}<input type="number" step="any" value={physicalZero[axis as keyof PhysicalZero]} onChange={(event) => setPhysicalZero({ ...physicalZero, [axis]: number(event.target.value) })} /></label>)}</fieldset></section>
|
||||
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || parts.length !== 8} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || parts.length !== 8} onClick={() => void exportGlb()}>导出组合 GLB</button><output role="status">{message || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}</output></footer>
|
||||
<input ref={file} data-axis-control="machine-manager-files" hidden multiple type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
|
||||
</DraggableDialog>
|
||||
</section>;
|
||||
}
|
||||
|
||||
// Named wrapper keeps the recursive panel JSX readable while retaining one preview implementation.
|
||||
const MachineManagerPanelPreview = ManagerPreview;
|
||||
96
web/react/src/RtcpProgramLibrary.tsx
Normal file
96
web/react/src/RtcpProgramLibrary.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import catalogJson from "./rtcp-program-manifest.json";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
|
||||
export type RtcpProgram = {
|
||||
id: string;
|
||||
group: string;
|
||||
label: string;
|
||||
path: string;
|
||||
coordinates: string[];
|
||||
role: "main" | "support" | "reference";
|
||||
features: string[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
type Catalog = {
|
||||
schema: string;
|
||||
total: number;
|
||||
groups: Array<{ id: string; label: string; count: number }>;
|
||||
programs: RtcpProgram[];
|
||||
};
|
||||
|
||||
export const RTCP_PROGRAM_CATALOG = catalogJson as Catalog;
|
||||
|
||||
const roleLabel = (role: RtcpProgram["role"]) => role === "main" ? "主程序" : role === "support" ? "支持/子程序" : "参考程序";
|
||||
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 }: {
|
||||
coordinates: string;
|
||||
disabled: boolean;
|
||||
disabledReason: string;
|
||||
onLoad: (program: RtcpProgram, source: string) => Promise<void>;
|
||||
}) {
|
||||
const defaultProgram = RTCP_PROGRAM_CATALOG.programs.find((program) => program.role === "main" && program.coordinates.includes(coordinates)) ?? RTCP_PROGRAM_CATALOG.programs[0];
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scope, setScope] = useState<"machine" | "all">("machine");
|
||||
const [selectedId, setSelectedId] = useState(defaultProgram.id);
|
||||
const [source, setSource] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState("");
|
||||
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 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]);
|
||||
|
||||
const readSource = async () => {
|
||||
if (sourcePath === selected.path && source) return source;
|
||||
const response = await fetch(assetUrl(selected.path));
|
||||
if (!response.ok) throw new Error(`程序资源读取失败: HTTP ${response.status}`);
|
||||
const text = (await response.text()).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!text.trim()) throw new Error("程序资源为空");
|
||||
setSource(text);
|
||||
setSourcePath(selected.path);
|
||||
return text;
|
||||
};
|
||||
const run = async (action: "source" | "load") => {
|
||||
setPending(true); setMessage(action === "source" ? "正在读取 LinuxCNC 源程序…" : "正在交给 LinuxCNC WASM 解析…");
|
||||
try {
|
||||
const text = await readSource();
|
||||
if (action === "load") { await onLoad(selected, text); setMessage("程序已加载到 LinuxCNC WASM 控制器"); }
|
||||
else setMessage(`已读取 ${text.split("\n").length} 行`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const compatibility = compatible
|
||||
? `${coordinates} 当前机型可加载;运动由 LinuxCNC WASM 解释器与运动学执行。`
|
||||
: selected.role !== "main"
|
||||
? `${roleLabel(selected.role)}用于 remap、O-call 或算法查阅,不作为独立主程序加载。`
|
||||
: `需要 ${selected.coordinates.join("/")} 运动学;当前机型为 ${coordinates}。`;
|
||||
|
||||
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>
|
||||
<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>
|
||||
<dl className="rtcp-program-meta"><div><dt>类型</dt><dd>{roleLabel(selected.role)}</dd></div><div><dt>机型</dt><dd>{selected.coordinates.join(" / ")}</dd></div><div><dt>特点</dt><dd>{selected.features.join(" · ")}</dd></div><div><dt>源路径</dt><dd><code>{selected.path}</code></dd></div></dl>
|
||||
<p>{selected.note}</p><p className={compatible ? "rtcp-compatible" : "rtcp-incompatible"}>{compatibility}</p>
|
||||
<div className="rtcp-program-actions">
|
||||
<button type="button" data-axis-control="rtcp-program-source" disabled={pending} onClick={() => void run("source")}>查看源程序</button>
|
||||
<button type="button" data-axis-control="rtcp-program-load" disabled={pending || disabled || !compatible} title={disabled ? disabledReason : !compatible ? compatibility : undefined} onClick={() => void run("load")}>加载到控制器</button>
|
||||
<a data-axis-control="rtcp-program-download" href={assetUrl(selected.path)} download={selected.path.split("/").at(-1)}>下载 .ngc</a>
|
||||
</div>
|
||||
<output role="status">{message}</output>
|
||||
{source && sourcePath === selected.path && <textarea className="rtcp-program-source" readOnly value={source} aria-label={`${selected.label} 源程序`} spellCheck={false} />}
|
||||
</DraggableDialog>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { readTrtGeometry, writeTrtGeometry, TRT_GEOMETRY_KEYS } from "../../workspace/trt-geometry.mjs";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
|
||||
type Bundle = { entrypoint: string; files: Record<string, string>; source_ini?: string };
|
||||
type Props = { bundle: Bundle | null; disabled: boolean; busy: boolean; onApply: (bundle: Bundle) => Promise<void>; onReload: () => Promise<void> };
|
||||
|
||||
export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [message, setMessage] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -47,6 +49,8 @@ export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }:
|
||||
};
|
||||
const tool = (id: string, label: string, asset: string, action: () => void, off = locked) => <button type="button" data-axis-control={`geometry-${id}`} aria-label={label} title={label} disabled={off} onClick={action}><img src={axisIcon(asset)} alt="" /></button>;
|
||||
return <section className="trt-geometry" aria-label="机床几何参数" aria-busy={pending || busy} data-dirty={dirty}>
|
||||
<div className="trt-geometry-toolbar"><button type="button" data-axis-control="geometry-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>机床参数</button></div>
|
||||
<DraggableDialog open={open} title="机床几何参数" className="geometry-dialog" onClose={() => setOpen(false)}>
|
||||
<header><strong>{bundle?.entrypoint.split("/").at(-1) ?? "INI"}</strong><span>mm</span><output>{dirty ? "待应用" : "已生效"}</output></header>
|
||||
<div className="trt-geometry-fields">{["旋转中心", "轴间偏置"].map((title, index) => <fieldset key={title}><legend>{title}</legend>{TRT_GEOMETRY_KEYS.slice(index * 3, index * 3 + 3).map((key: string) => <label key={key} title={key === geometry?.unsupported ? "此机型原生运动学不使用该偏置" : key.replaceAll("_", "-")}><span>{key.replaceAll("_", "-")}</span><input aria-label={key.replaceAll("_", "-")} type="number" step="any" disabled={locked || key === geometry?.unsupported} value={values[key] ?? "0"} onChange={(e) => setValues({ ...values, [key]: e.target.value })} /></label>)}</fieldset>)}</div>
|
||||
<footer>
|
||||
@@ -65,5 +69,6 @@ export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }:
|
||||
setValues(Object.fromEntries(TRT_GEOMETRY_KEYS.map((key: string) => [key, String(imported.offsets[key])])));
|
||||
}, "INI 参数已导入,待应用");
|
||||
}} />
|
||||
</DraggableDialog>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,19 @@ const CAMERA = VIEWER.camera;
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { buildImportedMachine, type ImportedMachine } from "./imported-machine";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model";
|
||||
import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter";
|
||||
|
||||
type Point = Record<string, number>;
|
||||
type Segment = { id?: number; active?: boolean; motion_type?: number; samples?: Point[]; start?: Point; end?: Point };
|
||||
type Props = { visual: XyzacVisualState; toolpath?: Segment[]; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
|
||||
type Props = { importedMachine?: ImportedMachine | null; visual: XyzacVisualState; toolpath?: Segment[]; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
|
||||
type Controller = Record<string, () => void>;
|
||||
type Host = HTMLDivElement & { xyzacMachineController?: Controller };
|
||||
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * VIEWER.mm_scale, Number(point.z ?? 0) * VIEWER.mm_scale, Number(point.y ?? 0) * VIEWER.mm_scale);
|
||||
|
||||
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = false, showLive = false, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
|
||||
export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = false, showLive = false, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
|
||||
const host = useRef<Host>(null);
|
||||
const visualRef = useRef(visual); visualRef.current = visual;
|
||||
const settings = useRef({ toolpath, view, zoom, rotateMode, showPath, showLive, showLimits, showOffsets, showExtents });
|
||||
@@ -30,7 +31,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
||||
element.appendChild(renderer.domElement);
|
||||
|
||||
@@ -50,7 +51,9 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
|
||||
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor);
|
||||
|
||||
const coordinates = visualRef.current.machineModel?.coordinate_system ?? "XYZAC";
|
||||
const assembly = coordinates === "XYZBC" ? buildXyzbcMachine() : buildXyzacMachine(); scene.add(assembly.machine);
|
||||
const assembly = importedMachine ? buildImportedMachine(importedMachine) : coordinates === "XYZBC" ? buildXyzbcMachine() : buildXyzacMachine();
|
||||
const pathPoint = importedMachine ? (point: Point) => new THREE.Vector3(Number(point.x ?? 0), Number(point.y ?? 0), Number(point.z ?? 0)).multiplyScalar(.001) : toView;
|
||||
scene.add(assembly.machine);
|
||||
assembly.update(visualRef.current);
|
||||
const nameplateCanvas = document.createElement("canvas"); nameplateCanvas.width = 512; nameplateCanvas.height = 128;
|
||||
const label = nameplateCanvas.getContext("2d");
|
||||
@@ -61,7 +64,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
|
||||
}
|
||||
const nameplateTexture = new THREE.CanvasTexture(nameplateCanvas); nameplateTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
const nameplate = new THREE.Mesh(new THREE.PlaneGeometry(.84, .21), new THREE.MeshBasicMaterial({ map: nameplateTexture }));
|
||||
nameplate.position.set(-.57, -1.48, -1.046); nameplate.rotation.y = Math.PI; assembly.machine.add(nameplate);
|
||||
nameplate.position.set(-.57, -1.48, -1.046); nameplate.rotation.y = Math.PI; if (!importedMachine) assembly.machine.add(nameplate);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, CAMERA.near, CAMERA.far);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
@@ -78,6 +81,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
|
||||
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN;
|
||||
let frame = 0, lastPreset = "", lastGeometry: Segment[] | null = null, lastPathKey = "";
|
||||
const initialBounds = new THREE.Box3().setFromObject(assembly.machine);
|
||||
if (importedMachine) floor.position.y = initialBounds.min.y - .03;
|
||||
const modelCenter = initialBounds.getCenter(new THREE.Vector3());
|
||||
const presetCamera = () => {
|
||||
const { view: selected, zoom: scale } = settings.current;
|
||||
@@ -156,7 +160,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
|
||||
if (config.showPath || config.showLive) for (const raw of paths) {
|
||||
const live = raw.active || raw.id === state.activeSegmentId;
|
||||
if (live ? !config.showLive : !config.showPath) continue;
|
||||
const points = raw.samples?.length ? raw.samples.map(toView) : [toView(raw.start ?? {}), toView(raw.end ?? {})];
|
||||
const points = raw.samples?.length ? raw.samples.map(pathPoint) : [pathPoint(raw.start ?? {}), pathPoint(raw.end ?? {})];
|
||||
(live ? liveGroup : pathGroup).add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), pathMaterials[live ? 1 : raw.motion_type === 0 ? 2 : 0]));
|
||||
}
|
||||
lastGeometry = paths; lastPathKey = pathKey;
|
||||
@@ -188,12 +192,13 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
|
||||
floor.geometry.dispose(); floor.material.dispose(); key.shadow.map?.dispose(); environment.dispose();
|
||||
renderer.dispose(); renderer.domElement.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [importedMachine]);
|
||||
|
||||
const model = visual.machineModel;
|
||||
const coordinates = model?.coordinate_system ?? "XYZAC";
|
||||
const managerGeometry = importedMachine?.scene.userData.linuxcncMachineGeometry;
|
||||
const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => <button type="button" data-axis-control={`machine-${action}`} data-xyzac-machine-controller={action} title={label} aria-label={label} onClick={() => host.current?.xyzacMachineController?.[action]?.()}>{asset ? <img src={axisIcon(asset)} alt="" /> : glyph}</button>;
|
||||
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-preview="machine" data-xyzac-model-source="linuxcnc-wasm" data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={model?.source_path ?? ""} data-xyzac-model-source-hash={model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label={`${coordinates} TRT Vismach machine model`}>
|
||||
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-preview="machine" data-machine-glb={importedMachine?.hash ?? ""} data-machine-manager-schema={managerGeometry?.schema ?? ""} data-machine-manager-geometry={managerGeometry ? JSON.stringify(managerGeometry) : ""} data-xyzac-model-source={importedMachine ? "glb-urdf/linuxcnc-wasm" : "linuxcnc-wasm"} data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={importedMachine?.filename ?? model?.source_path ?? ""} data-xyzac-model-source-hash={importedMachine?.hash ?? model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label={`${coordinates} ${importedMachine ? "URDF" : "TRT Vismach"} machine model`}>
|
||||
<div className="xyzac-view-controller xyzac-machine-controller" role="toolbar" aria-label="Machine 3D view controller">
|
||||
{controllerButton("zoomIn", "Zoom in", "tool_zoomin.gif")}{controllerButton("zoomOut", "Zoom out", "tool_zoomout.gif")}
|
||||
{controllerButton("panLeft", "Pan left", undefined, "\u2190")}{controllerButton("panRight", "Pan right", undefined, "\u2192")}
|
||||
|
||||
115
web/react/src/imported-machine.ts
Normal file
115
web/react/src/imported-machine.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as THREE from "three";
|
||||
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";
|
||||
export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial<Record<"x" | "y" | "z" | "a" | "b" | "c", number>> };
|
||||
export function disposeMachineScene(scene: THREE.Object3D) {
|
||||
const geometries = new Set<THREE.BufferGeometry>(), materials = new Set<THREE.Material>(), textures = new Set<THREE.Texture>();
|
||||
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> {
|
||||
const { structure } = inspectMachineGlb(bytes);
|
||||
const manager = new THREE.LoadingManager();
|
||||
manager.setURLModifier((url) => { if (!url.startsWith("blob:")) throw new Error("机床文件不能加载外部资源"); return url; });
|
||||
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
||||
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; } });
|
||||
return { scene: gltf.scene, structure, hash, filename };
|
||||
}
|
||||
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record<string, string> }, structure: MachineStructure) {
|
||||
const config = readTrtConfiguration(bundle);
|
||||
if (config.kinematics.coordinates !== structure.coordinates) throw new Error("GLB 与运行配置机型不同");
|
||||
for (const joint of structure.joints.filter((j) => j.coordinate)) {
|
||||
const axis = joint.coordinate.toLowerCase(), factor = joint.type === "prismatic" ? 1000 : 180 / Math.PI;
|
||||
const limit = joint.limit!;
|
||||
const lower = limit.lower * factor, upper = limit.upper * factor;
|
||||
for (const range of [config.kinematics.axis_limits[axis], config.home_limits[axis]]) {
|
||||
if (range[0] < lower - 1e-7 || range[1] > upper + 1e-7) throw new Error(`${joint.coordinate}:INI 行程超出 URDF,先调整运行配置`);
|
||||
}
|
||||
if (config.kinematics.axis_velocity_limits[axis] > limit.velocity * factor + 1e-7) throw new Error(`${joint.coordinate}:INI 速度超出 URDF`);
|
||||
}
|
||||
}
|
||||
export function configureImportedMachine<T extends { entrypoint: string; files: Record<string, string>; source_ini?: string }>(bundle: T, structure: MachineStructure): T {
|
||||
validateImportedConfiguration(bundle, structure);
|
||||
const next = writeTrtGeometry(bundle, structure.offsets);
|
||||
if (bundle.source_ini) next.source_ini = writeTrtGeometry({ ...bundle, files: { ...bundle.files, [bundle.entrypoint]: bundle.source_ini } }, structure.offsets).files[bundle.entrypoint];
|
||||
return next;
|
||||
}
|
||||
|
||||
// Keep the source scene/meshes and file hash immutable. The effective URDF is
|
||||
// a geometry override for this session, prepared before LOAD_WORKSPACE commits.
|
||||
export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: { entrypoint: string; files: Record<string, string> }): ImportedMachine {
|
||||
validateImportedConfiguration(bundle, asset.structure);
|
||||
const { offsets } = readTrtGeometry(bundle);
|
||||
if (Object.entries(asset.structure.offsets).every(([key, value]) => Math.abs(value - offsets[key]) < 1e-9)) return asset;
|
||||
const p = ["x", "y", "z"].map((axis) => offsets[`${axis}_rot_point`] / 1000) as Vec3;
|
||||
const d = ["x", "y", "z"].map((axis) => offsets[`${axis}_offset`] / 1000) as Vec3;
|
||||
const tilt = asset.structure.joints.find((j) => j.coordinate === asset.structure.coordinates[3])!;
|
||||
const rotary = asset.structure.joints.find((j) => j.coordinate === "C")!;
|
||||
const work = asset.structure.joints.find((j) => j.child === asset.structure.work)!;
|
||||
const origins = new Map<string, number[]>([
|
||||
[tilt.name, p.map((v, i) => v + d[i])],
|
||||
[rotary.name, d.map((v) => -v)],
|
||||
[work.name, p.map((v) => -v)],
|
||||
]);
|
||||
const xml = new DOMParser().parseFromString(asset.structure.urdf, "application/xml");
|
||||
for (const joint of [...xml.documentElement.children].filter((node) => node.tagName === "joint")) {
|
||||
const origin = origins.get(joint.getAttribute("name")!);
|
||||
if (origin) [...joint.children].find((node) => node.tagName === "origin")!.setAttribute("xyz", origin.join(" "));
|
||||
}
|
||||
const structure = parseMachineUrdf(new XMLSerializer().serializeToString(xml));
|
||||
return { ...asset, structure };
|
||||
}
|
||||
|
||||
export function buildImportedMachine(asset: ImportedMachine) {
|
||||
const scene = asset.scene.clone(true);
|
||||
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
|
||||
const managed = scene.userData.linuxcncMachineGeometry;
|
||||
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;
|
||||
const base = scene.getObjectByName(asset.structure.base); if (base) base.userData.linuxcncMachineGeometry = effective;
|
||||
}
|
||||
// Include fixed work-frame joints as well as moving pivots. The source GLB
|
||||
// stays unchanged; only the runtime clone uses the applied geometry.
|
||||
for (const joint of asset.structure.joints) {
|
||||
const link = scene.getObjectByName(joint.child)!;
|
||||
link.position.fromArray(joint.origin);
|
||||
link.updateMatrix();
|
||||
}
|
||||
// Viewer legacy coordinates are (X,Z,Y). glTF is already (X,Z,-Y), in metres.
|
||||
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 node = (name: string) => { let result: THREE.Object3D | null = null; scene.traverse((candidate) => { if (!result && candidate.userData.link === name) result = candidate; }); return result ?? scene.getObjectByName(name)!; };
|
||||
const moving = asset.structure.joints.filter((j) => j.coordinate).map((j) => ({ joint: j, node: node(j.child), axis: new THREE.Vector3(...j.axis) }));
|
||||
const rotate = node(asset.structure.joints.find((j) => j.coordinate === "C")!.child);
|
||||
const workOrigin = node(asset.structure.work);
|
||||
const originalWork = workOrigin.position.clone();
|
||||
const toolTip = new THREE.Group(); toolTip.name = "imported-tool-tip"; node(asset.structure.tool).add(toolTip);
|
||||
// A visible cutter extension follows the runtime tool length, not the GLB mesh dimensions.
|
||||
const cutter = new THREE.Mesh(new THREE.CylinderGeometry(.004, .004, 1, 24), new THREE.MeshStandardMaterial({ color: 0xe4b34e, metalness: .7, roughness: .25 }));
|
||||
cutter.rotation.x = Math.PI / 2; node(asset.structure.tool).add(cutter);
|
||||
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
|
||||
for (const { joint, node: link, axis } of moving) {
|
||||
const coordinate = joint.coordinate.toLowerCase() as "x" | "y" | "z" | "a" | "b" | "c";
|
||||
const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(asset.physicalZero?.[coordinate] ?? 0);
|
||||
link.position.fromArray(joint.origin); link.quaternion.identity();
|
||||
if (joint.type === "prismatic") link.position.addScaledVector(axis, value / 1000);
|
||||
else link.quaternion.setFromAxisAngle(axis, value * Math.PI / 180);
|
||||
}
|
||||
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;
|
||||
// trtfuncs.c adds tool-offset to dz; the programmed frame is shifted by
|
||||
// the master-tool difference along local C-Z, while physical pivots stay fixed.
|
||||
workOrigin.position.copy(originalWork); workOrigin.position.z -= length;
|
||||
machine.updateMatrixWorld(true);
|
||||
};
|
||||
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
|
||||
}
|
||||
1
web/react/src/legacy.d.ts
vendored
1
web/react/src/legacy.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
declare module "*.mjs" {
|
||||
export const WEB_SETTINGS: typeof import("../../workspace/web-settings.json");
|
||||
export function axisImagePath(name: string, base?: string): string;
|
||||
export function readTrtConfiguration(configuration: { entrypoint: string; files: Record<string, string> }): { kinematics: { coordinates: string; axis_limits: Record<string, number[]>; axis_velocity_limits: Record<string, number> }; home_limits: Record<string, any> };
|
||||
export const TRT_GEOMETRY_KEYS: readonly string[];
|
||||
export function readTrtGeometry(configuration: { entrypoint: string; files: Record<string, string> }): { coordinates: string; offsets: Record<string, number>; sources: Record<string, { path: string; line: number; value: string }>; unsupported: string; state_hash: string };
|
||||
export function writeTrtGeometry<T extends { entrypoint: string; files: Record<string, string> }>(configuration: T, values: Record<string, string | number>): T;
|
||||
|
||||
132
web/react/src/machine-manager.ts
Normal file
132
web/react/src/machine-manager.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||
import type { ImportedMachine } from "./imported-machine";
|
||||
|
||||
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
|
||||
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group };
|
||||
export type MachinePartDraft = MachinePartSource & {
|
||||
role: MachinePartRole | "";
|
||||
parentId: string | null;
|
||||
visible: boolean;
|
||||
showOrigin: boolean;
|
||||
originMm: Vec3;
|
||||
translationMm: Vec3;
|
||||
rotationDeg: Vec3;
|
||||
};
|
||||
export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||
export type ManagedMachineGeometry = {
|
||||
schema: "linuxcnc.machine-manager/1";
|
||||
coordinates: "XYZAC" | "XYZBC";
|
||||
cOriginMm: Vec3;
|
||||
tiltOffsetMm: Vec3;
|
||||
physicalZero: PhysicalZero;
|
||||
parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean }>;
|
||||
};
|
||||
|
||||
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
|
||||
export const roleLabel = (role: MachinePartRole, coordinates: string) => ({ base: "床身底座", x: "X 轴部件", y: "Y 轴部件", z: "Z 轴部件", tool: "主轴/刀具", tilt: `${coordinates === "XYZBC" ? "B" : "A"} 轴部件`, c: "C 轴部件", work: "工件/工作台" }[role]);
|
||||
export const expectedParentRole = (role: MachinePartRole): MachinePartRole | null => ({ base: null, x: "base", y: "x", z: "y", tool: "z", tilt: "base", c: "tilt", work: "c" }[role] as MachinePartRole | null);
|
||||
|
||||
const finite3 = (value: Vec3, label: string) => {
|
||||
if (value.length !== 3 || value.some((number) => !Number.isFinite(number))) throw new Error(`${label}需要三个有限数值`);
|
||||
};
|
||||
const cloneMaterial = (material: THREE.Material) => {
|
||||
const cloned = material.clone();
|
||||
for (const [key, value] of Object.entries(cloned)) if (value instanceof THREE.Texture) (cloned as unknown as Record<string, unknown>)[key] = value.clone();
|
||||
return cloned;
|
||||
};
|
||||
export function cloneMachinePartScene(source: THREE.Object3D) {
|
||||
const clone = source.clone(true);
|
||||
clone.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry = mesh.geometry.clone();
|
||||
if (mesh.material) mesh.material = Array.isArray(mesh.material) ? mesh.material.map(cloneMaterial) : cloneMaterial(mesh.material);
|
||||
});
|
||||
return clone;
|
||||
}
|
||||
|
||||
export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Promise<MachinePartSource> {
|
||||
if (!bytes.byteLength) throw new Error("GLB 部件为空");
|
||||
const manager = new THREE.LoadingManager();
|
||||
manager.setURLModifier((url) => { if (!url.startsWith("blob:")) throw new Error("GLB 部件不能加载外部资源"); return url; });
|
||||
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((number) => number.toString(16).padStart(2, "0")).join("");
|
||||
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
|
||||
return { id: crypto.randomUUID(), filename, hash, scene: gltf.scene };
|
||||
}
|
||||
|
||||
export function validateMachineAssembly(coordinates: "XYZAC" | "XYZBC", parts: MachinePartDraft[]) {
|
||||
if (parts.length !== MACHINE_PART_ROLES.length) throw new Error("应用前需要正好八个机床角色部件");
|
||||
const byRole = new Map<MachinePartRole, MachinePartDraft>();
|
||||
const ids = new Set(parts.map((part) => part.id));
|
||||
if (ids.size !== parts.length) throw new Error("部件 ID 重复");
|
||||
for (const part of parts) {
|
||||
if (!part.role) throw new Error(`${part.filename} 尚未指定机床角色`);
|
||||
if (byRole.has(part.role)) throw new Error(`${roleLabel(part.role, coordinates)}角色重复`);
|
||||
finite3(part.originMm, `${part.filename}局部原点`); finite3(part.translationMm, `${part.filename}平移`); finite3(part.rotationDeg, `${part.filename}旋转`);
|
||||
byRole.set(part.role, part);
|
||||
}
|
||||
for (const role of MACHINE_PART_ROLES) if (!byRole.has(role)) throw new Error(`缺少${roleLabel(role, coordinates)}`);
|
||||
for (const [role, part] of byRole) {
|
||||
const parentRole = expectedParentRole(role);
|
||||
if (parentRole === null) { if (part.parentId !== null) throw new Error("床身底座不能设置父部件"); continue; }
|
||||
const expected = byRole.get(parentRole)!;
|
||||
if (part.parentId !== expected.id) throw new Error(`${roleLabel(role, coordinates)}必须挂到${roleLabel(parentRole, coordinates)}`);
|
||||
}
|
||||
return byRole;
|
||||
}
|
||||
|
||||
function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffsetMm: Vec3) {
|
||||
finite3(cOriginMm, "C 轴原点"); finite3(tiltOffsetMm, "A/B 轴偏置");
|
||||
if (coordinates === "XYZAC" && Math.abs(tiltOffsetMm[0]) > 1e-9) throw new Error("AC 原生 TRT 不支持沿 A 轴的 X 偏置");
|
||||
if (coordinates === "XYZBC" && Math.abs(tiltOffsetMm[1]) > 1e-9) throw new Error("BC 原生 TRT 不支持沿 B 轴的 Y 偏置");
|
||||
const metres = (values: Vec3) => values.map((value) => value / 1000) as Vec3;
|
||||
const p = metres(cOriginMm), d = metres(tiltOffsetMm);
|
||||
const t = p.map((value, index) => value + d[index]) as Vec3;
|
||||
const u = d.map((value) => -value) as Vec3;
|
||||
const work = p.map((value) => -value) as Vec3;
|
||||
const tilt = coordinates === "XYZAC" ? "A" : "B";
|
||||
const tiltAxis = coordinates === "XYZAC" ? "1 0 0" : "0 1 0";
|
||||
const radians = (degrees: number) => degrees * Math.PI / 180;
|
||||
const joint = (name: string, type: string, parent: string, child: string, origin: Vec3, axis = "", coordinate = "", limits: number[] = []) => ` <joint name="${name}" type="${type}">\n <parent link="${parent}"/><child link="${child}"/>\n <origin xyz="${origin.join(" ")}" rpy="0 0 0"/>${axis ? `\n <axis xyz="${axis}"/><cnc:axis coordinate="${coordinate}"/>\n <limit lower="${limits[0]}" upper="${limits[1]}" velocity="${limits[2]}" effort="1000"/>` : ""}\n </joint>`;
|
||||
return `<?xml version="1.0"?>\n<robot name="managed_${coordinates.toLowerCase()}" xmlns:cnc="urn:linuxcnc:web:machine:1">\n <cnc:machine profile="trt-xyz-head/1" base="base" tool="tool" work="work"/>\n${["base", "x", "y", "z", "tool", "tilt", "c", "work"].map((name) => ` <link name="${name}"/>`).join("\n")}\n${[
|
||||
joint("x_joint", "prismatic", "base", "x", [0, 0, 0], "1 0 0", "X", [-.2, .2, .02]),
|
||||
joint("y_joint", "prismatic", "x", "y", [0, 0, 0], "0 1 0", "Y", [-.1, .1, .02]),
|
||||
joint("z_joint", "prismatic", "y", "z", [0, 0, 0], "0 0 1", "Z", [-.12, .12, .02]),
|
||||
joint("tool_mount", "fixed", "z", "tool", [0, 0, 0]),
|
||||
joint("tilt_joint", "revolute", "base", "tilt", t, tiltAxis, tilt, [radians(coordinates === "XYZAC" ? -100 : -36000), radians(coordinates === "XYZAC" ? 50 : 36000), radians(30)]),
|
||||
joint("c_joint", "revolute", "tilt", "c", u, "0 0 1", "C", [radians(-36000), radians(36000), radians(30)]),
|
||||
joint("work_mount", "fixed", "c", "work", work),
|
||||
].join("\n")}\n</robot>\n`;
|
||||
}
|
||||
|
||||
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise<ImportedMachine> {
|
||||
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);
|
||||
const structure: MachineStructure = parseMachineUrdf(urdf);
|
||||
const metadata: ManagedMachineGeometry = {
|
||||
schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero },
|
||||
parts: MACHINE_PART_ROLES.map((role) => { const part = byRole.get(role)!; return { id: part.id, filename: part.filename, hash: part.hash, role, parentId: part.parentId, originMm: [...part.originMm], translationMm: [...part.translationMm], rotationDeg: [...part.rotationDeg], visible: part.visible }; }),
|
||||
};
|
||||
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
|
||||
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
|
||||
scene.userData.linuxcncMachineGeometry = metadata;
|
||||
const frame = new THREE.Group(); frame.name = "urdf_frame"; frame.rotation.x = -Math.PI / 2; scene.add(frame);
|
||||
const links = Object.fromEntries(MACHINE_PART_ROLES.map((role) => { const node = new THREE.Group(); node.name = role; node.userData.link = role; return [role, node]; })) as Record<MachinePartRole, THREE.Group>;
|
||||
frame.add(links.base); links.base.add(links.x, links.tilt); links.x.add(links.y); links.y.add(links.z); links.z.add(links.tool); links.tilt.add(links.c); links.c.add(links.work);
|
||||
for (const joint of structure.joints) links[joint.child as MachinePartRole].position.fromArray(joint.origin);
|
||||
for (const role of MACHINE_PART_ROLES) {
|
||||
const part = byRole.get(role)!;
|
||||
const placement = new THREE.Group(); placement.name = `component_${part.id}`; placement.visible = part.visible;
|
||||
placement.position.fromArray(part.translationMm).multiplyScalar(.001);
|
||||
placement.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3);
|
||||
placement.userData.machinePart = { role, filename: part.filename, hash: part.hash, originMm: [...part.originMm] };
|
||||
const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); placement.add(visual); links[role].add(placement);
|
||||
}
|
||||
links.base.userData.linuxcncMachineGeometry = metadata;
|
||||
const signature = JSON.stringify({ metadata, hashes: MACHINE_PART_ROLES.map((role) => byRole.get(role)!.hash) });
|
||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signature)))].map((number) => number.toString(16).padStart(2, "0")).join("");
|
||||
return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } };
|
||||
}
|
||||
140
web/react/src/machine-urdf.ts
Normal file
140
web/react/src/machine-urdf.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Matrix4, Quaternion, Vector3 } from "three";
|
||||
|
||||
// Restricted, versioned URDF profile for the two native LinuxCNC TRT solvers.
|
||||
export const MACHINE_SCHEMA = "linuxcnc.machine/1";
|
||||
export const CNC_NS = "urn:linuxcnc:web:machine:1";
|
||||
export const MAX_MACHINE_BYTES = 32 * 1024 * 1024;
|
||||
export type Vec3 = [number, number, number];
|
||||
export type MachineJoint = { name: string; type: string; parent: string; child: string; origin: Vec3; axis: Vec3; coordinate: string; limit?: { lower: number; upper: number; velocity: number; effort: number } };
|
||||
export type MachineStructure = { name: string; profile: string; coordinates: "XYZAC" | "XYZBC"; base: string; tool: string; work: string; links: string[]; joints: MachineJoint[]; offsets: Record<string, number>; urdf: string };
|
||||
const fail = (detail: string): never => { throw new Error(`机床模型无效:${detail}`); };
|
||||
const close = (a: number[], b: number[], tolerance = 1e-8) => a.length === b.length && a.every((v, i) => Number.isFinite(v) && Math.abs(v - b[i]) < tolerance);
|
||||
const name = (value: string | null) => value && /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value) ? value : fail("名称缺失或含不支持的字符");
|
||||
const scalar = (value: string | null) => value?.trim() && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()) && Number.isFinite(Number(value)) ? Number(value) : fail("需要有限数值");
|
||||
const vector = (value: string | null): Vec3 => { const parts = value?.trim().split(/\s+/); if (parts?.length !== 3) fail("需要三个坐标分量"); return parts!.map(scalar) as Vec3; };
|
||||
const one = (parent: Element, tag: string, ns: string | null = null) => {
|
||||
const matches = [...parent.children].filter((el) => el.localName === tag && el.namespaceURI === ns);
|
||||
if (matches.length !== 1) fail(`${parent.tagName} 必须有唯一 ${tag}`);
|
||||
return matches[0];
|
||||
};
|
||||
export function parseMachineUrdf(urdf: string): MachineStructure {
|
||||
if (typeof urdf !== "string" || urdf.length > 128 * 1024 || /<!DOCTYPE|<!ENTITY/i.test(urdf)) fail("URDF 长度或 XML 声明不支持");
|
||||
const doc = new DOMParser().parseFromString(urdf, "application/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length) fail("URDF XML 语法错误");
|
||||
const robot = doc.documentElement;
|
||||
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");
|
||||
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[] = [];
|
||||
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}`);
|
||||
for (const child of [...el.children]) {
|
||||
if (child.namespaceURI === CNC_NS && child.localName === "axis") continue;
|
||||
if (child.namespaceURI || !["parent", "child", "origin", "axis", "limit"].includes(child.tagName)) fail(`不支持 ${child.tagName}`);
|
||||
}
|
||||
const origin = one(el, "origin");
|
||||
if (!close(vector(origin.getAttribute("rpy")), [0, 0, 0])) fail("v1 不支持旋转的关节零位 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"));
|
||||
joint.coordinate = one(el, "axis", CNC_NS).getAttribute("coordinate") ?? "";
|
||||
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.lower > 0 || joint.limit.upper < 0 || joint.limit.velocity <= 0 || joint.limit.effort <= 0) fail("关节限位/速度无效或零位不在行程内");
|
||||
} else if ([...el.children].some((c) => c.localName === "axis" || c.localName === "limit")) fail("固定关节不能绑定运动轴");
|
||||
joints.push(joint);
|
||||
}
|
||||
if (links.length !== (gantry ? 7 : 8) || joints.length !== links.length - 1 || new Set(links).size !== links.length || new Set(joints.map((j) => j.name)).size !== joints.length) fail("link/joint 数量或唯一性不符合指定 profile");
|
||||
if (![base, tool, work].every((s) => links.includes(s)) || new Set([base, tool, work]).size !== 3) fail("base/tool/work 绑定无效");
|
||||
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");
|
||||
const keys = active.map((j) => j.coordinate).sort().join("");
|
||||
const coordinates = keys === "ACXYZ" ? "XYZAC" : keys === "BCXYZ" ? "XYZBC" : fail("需要 XYZAC 或 XYZBC 五个唯一运动轴");
|
||||
const axis = (c: string) => active.find((j) => j.coordinate === c)!;
|
||||
let parent = base;
|
||||
for (const [i, c] of [..."XYZ"].entries()) {
|
||||
const j = axis(c), expected: Vec3 = [0, 0, 0]; expected[i] = 1;
|
||||
if (j.type !== "prismatic" || j.parent !== parent || !close(j.axis, expected) || !close(j.origin, [0, 0, 0])) fail("要求底座 → X → Y → Z 正交刀具链,零位 origin=0");
|
||||
parent = j.child;
|
||||
}
|
||||
if (gantry) {
|
||||
if (tool !== parent) fail("桁架型刀具基准必须直接绑定 Z link");
|
||||
} else {
|
||||
const toolJoint = joints.find((j) => j.child === tool)!;
|
||||
if (toolJoint?.parent !== parent || toolJoint.type !== "fixed" || !close(toolJoint.origin, [0, 0, 0])) fail("主轴箱必须固定于 Z,刀具基准 origin=0");
|
||||
}
|
||||
const tilt = axis(coordinates[3]), rotary = axis("C");
|
||||
if (tilt.type !== "revolute" || tilt.parent !== base || !close(tilt.axis, coordinates === "XYZAC" ? [1, 0, 0] : [0, 1, 0]) || rotary.type !== "revolute" || rotary.parent !== tilt.child || !close(rotary.axis, [0, 0, 1])) fail("要求底座 → A/B → C 正向转台链");
|
||||
const p = tilt.origin.map((v, i) => v + rotary.origin[i]) as Vec3;
|
||||
const d = rotary.origin.map((v) => -v) as Vec3;
|
||||
if (Math.abs(d[coordinates === "XYZAC" ? 0 : 1]) > 1e-9) fail("原生 TRT 不支持沿倾转轴的偏置");
|
||||
const workJoint = joints.find((j) => j.child === work)!;
|
||||
if (workJoint?.parent !== rotary.child || workJoint.type !== "fixed" || !close(workJoint.origin, p.map((v) => -v))) fail("work 必须固定于 C 且 origin=-零位C轴原点");
|
||||
const offsets = Object.fromEntries(["x", "y", "z"].flatMap((c, i) => [[`${c}_rot_point`, p[i] * 1000], [`${c}_offset`, d[i] * 1000]]));
|
||||
return { name: name(robot.getAttribute("name")), profile, coordinates, 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 字节至 32 MiB 之间");
|
||||
const view = new DataView(bytes);
|
||||
if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.byteLength) fail("GLB 头部或长度错误");
|
||||
let offset = 12, json: any = null, binaryLength = -1;
|
||||
while (offset < bytes.byteLength) {
|
||||
if (offset + 8 > bytes.byteLength) fail("GLB chunk 被截断");
|
||||
const length = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
|
||||
if (length % 4 || offset + 8 + length > bytes.byteLength) fail("GLB chunk 长度错误");
|
||||
if (offset === 12 && type === 0x4e4f534a) json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes, offset + 8, length)));
|
||||
else if (type === 0x004e4942 && binaryLength === -1 && json) binaryLength = length;
|
||||
else fail("不支持的 GLB chunk");
|
||||
offset += 8 + length;
|
||||
}
|
||||
if (!json || json.asset?.version !== "2.0" || binaryLength < 0) fail("需要 glTF 2.0 JSON 和 BIN");
|
||||
if (json.buffers?.length !== 1 || json.buffers[0].uri !== undefined || !Number.isInteger(json.buffers[0].byteLength) || json.buffers[0].byteLength <= 0 || binaryLength < json.buffers[0].byteLength || binaryLength - json.buffers[0].byteLength > 3 || json.images?.some((im: any) => im.uri !== undefined || im.bufferView === undefined)) fail("GLB 必须自包含,禁止外部 buffer/图片");
|
||||
if (json.animations?.length || json.skins?.length || json.extensionsRequired?.length || json.meshes?.some((m: any) => m.primitives?.some((p: any) => p.targets))) fail("运动节点不能由动画、蒙皮或压缩扩展驱动");
|
||||
const scene = json.scenes?.[json.scene ?? 0];
|
||||
const metadata = scene?.extras?.linuxcncMachine;
|
||||
if (metadata?.schema !== MACHINE_SCHEMA) fail("缺少 linuxcnc.machine/1 内嵌 URDF");
|
||||
const structure = parseMachineUrdf(metadata.urdf);
|
||||
const nodes: any[] = json.nodes;
|
||||
if (!Array.isArray(nodes) || nodes.length > 10000 || scene.nodes?.length !== 1) fail("需要唯一坐标转换根节点");
|
||||
const parents = new Map<number, number>();
|
||||
const seen = new Set<number>();
|
||||
const visit = (index: number, parent: number) => {
|
||||
if (!Number.isInteger(index) || !nodes[index] || seen.has(index)) fail("GLB 节点环、重复父节点或索引无效");
|
||||
seen.add(index); parents.set(index, parent);
|
||||
for (const child of nodes[index].children ?? []) visit(child, index);
|
||||
};
|
||||
visit(scene.nodes[0], -1);
|
||||
if (seen.size !== nodes.length) fail("GLB 有未绑定到默认场景的节点");
|
||||
const find = (n: string) => { const indices = nodes.flatMap((node, i) => node.name === n ? [i] : []); if (indices.length !== 1) fail(`GLB link ${n} 缺失或重名`); return indices[0]; };
|
||||
const transform = (node: any, position: number[], rotation = [0, 0, 0, 1]) => {
|
||||
if (node.matrix) {
|
||||
const expected = new Matrix4().compose(new Vector3(...position), new Quaternion(...rotation), new Vector3(1, 1, 1));
|
||||
if (node.translation || node.rotation || node.scale || !close(node.matrix, expected.elements)) fail(`GLB 节点 ${node.name} 矩阵与 URDF 不同`);
|
||||
return;
|
||||
}
|
||||
if (!close(node.translation ?? [0, 0, 0], position) || !close(node.rotation ?? [0, 0, 0, 1], rotation) || !close(node.scale ?? [1, 1, 1], [1, 1, 1])) fail(`GLB 节点 ${node.name} 零位与 URDF 不同`);
|
||||
};
|
||||
const root = scene.nodes[0];
|
||||
if (nodes[root].name !== "urdf_frame" || nodes[root].children?.length !== 1 || nodes[root].mesh !== undefined) fail("需要 urdf_frame → base");
|
||||
transform(nodes[root], [0, 0, 0], [-Math.SQRT1_2, 0, 0, Math.SQRT1_2]);
|
||||
const baseIndex = find(structure.base);
|
||||
if (parents.get(baseIndex) !== root) fail("GLB 底座父节点错误");
|
||||
transform(nodes[baseIndex], [0, 0, 0]);
|
||||
for (const j of structure.joints) {
|
||||
const index = find(j.child);
|
||||
if (parents.get(index) !== find(j.parent)) fail(`GLB 层级与 URDF 不同:${j.child}`);
|
||||
transform(nodes[index], j.origin);
|
||||
}
|
||||
return { json, structure };
|
||||
}
|
||||
340
web/react/src/rtcp-program-manifest.json
Normal file
340
web/react/src/rtcp-program-manifest.json
Normal file
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"schema": "linuxcnc.rtcp-program-library/1",
|
||||
"source": "LinuxCNC 2.10 source tree",
|
||||
"total": 32,
|
||||
"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 }
|
||||
],
|
||||
"programs": [
|
||||
{
|
||||
"id": "trt-boat-xyzac",
|
||||
"group": "trt-axis",
|
||||
"label": "Boat XYZAC",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "main",
|
||||
"features": ["M428", "G43", "G54", "XYZAC TCP", "CAM 点列"],
|
||||
"note": "XYZAC 连续五轴船体加工示例;程序内显式进入 TCP 模式。"
|
||||
},
|
||||
{
|
||||
"id": "trt-boat-xyzbc",
|
||||
"group": "trt-axis",
|
||||
"label": "Boat XYZBC",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
|
||||
"coordinates": ["XYZBC"],
|
||||
"role": "main",
|
||||
"features": ["M428", "G43", "G93/G94", "XYZBC TCP", "CAM 点列"],
|
||||
"note": "XYZBC 连续五轴船体加工示例,包含逆时间进给。"
|
||||
},
|
||||
{
|
||||
"id": "trt-impeller-xyzac",
|
||||
"group": "trt-axis",
|
||||
"label": "Impeller 7BL XYZAC",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "main",
|
||||
"features": ["M428", "G93", "XYZAC TCP", "叶轮 CAM"],
|
||||
"note": "高密度 XYZAC 叶轮轨迹,用于连续联动与逆时间进给测试。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins",
|
||||
"group": "trt-axis",
|
||||
"label": "XYZAC switchkins 四象限螺旋",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "main",
|
||||
"features": ["O-call", "M428/M429", "G2 螺旋", "四象限"],
|
||||
"note": "调用 xyzac_switchkins_sub,在 identity 与 TCP 运动学之间切换。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-test-1",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["M428/M429", "坐标对照", "C 轴旋转"],
|
||||
"note": "对比 identity/TCP 切换前后的关节与刀尖坐标。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-test-2",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["M428/M429", "G64", "多姿态轨迹"],
|
||||
"note": "覆盖多组 A/C 姿态与 TCP/identity 切换。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-test-3",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["M428/M429", "M1", "预期坐标检查"],
|
||||
"note": "带人工检查点和预期坐标的最小 switchkins 验证程序。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzbc-switchkins",
|
||||
"group": "trt-axis",
|
||||
"label": "XYZBC switchkins 四象限螺旋",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
|
||||
"coordinates": ["XYZBC"],
|
||||
"role": "main",
|
||||
"features": ["O-call", "M428/M429", "G2 螺旋", "四象限"],
|
||||
"note": "调用 xyzbc_switchkins_sub,验证 B/C 型 TRT 的 TCP 切换。"
|
||||
},
|
||||
{
|
||||
"id": "trt-remap-m428",
|
||||
"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"],
|
||||
"role": "support",
|
||||
"features": ["M68", "M66", "motion.switchkins-type", "TCP"],
|
||||
"note": "支持文件:选择 TRT TCP 运动学并同步确认 HAL 切换结果。"
|
||||
},
|
||||
{
|
||||
"id": "trt-remap-m429",
|
||||
"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"],
|
||||
"role": "support",
|
||||
"features": ["M68", "M66", "identity kinematics"],
|
||||
"note": "支持文件:选择 identity/trivkins,用于安全定位和坐标建立。"
|
||||
},
|
||||
{
|
||||
"id": "trt-remap-m430",
|
||||
"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"],
|
||||
"role": "support",
|
||||
"features": ["M68", "M66", "user kinematics"],
|
||||
"note": "支持文件:选择 userk 扩展运动学。"
|
||||
},
|
||||
{
|
||||
"id": "trt-centering",
|
||||
"group": "trt-axis",
|
||||
"label": "TRT centering 子程序",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/centering.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "support",
|
||||
"features": ["HAL 旋转中心", "M428/M429", "小方形轨迹"],
|
||||
"note": "支持子程序:检查旋转中心在 C 轴转动时是否保持固定。"
|
||||
},
|
||||
{
|
||||
"id": "trt-helix-ac",
|
||||
"group": "trt-axis",
|
||||
"label": "TRT helix AC 子程序",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_ac.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "support",
|
||||
"features": ["M428/M429", "G2 螺旋", "A/C 姿态"],
|
||||
"note": "支持子程序:XYZAC 姿态下的 TCP 螺旋轨迹。"
|
||||
},
|
||||
{
|
||||
"id": "trt-helix-bc",
|
||||
"group": "trt-axis",
|
||||
"label": "TRT helix BC 子程序",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_bc.ngc",
|
||||
"coordinates": ["XYZBC"],
|
||||
"role": "support",
|
||||
"features": ["M428/M429", "G2 螺旋", "B/C 姿态"],
|
||||
"note": "支持子程序:XYZBC 姿态下的 TCP 螺旋轨迹。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-sub",
|
||||
"group": "trt-axis",
|
||||
"label": "XYZAC switchkins 主子程序",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzac_switchkins_sub.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "support",
|
||||
"features": ["O-sub", "四象限", "helix_ac"],
|
||||
"note": "支持子程序:参数化调用 helix_ac 完成四象限测试。"
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzbc-switchkins-sub",
|
||||
"group": "trt-axis",
|
||||
"label": "XYZBC switchkins 主子程序",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzbc_switchkins_sub.ngc",
|
||||
"coordinates": ["XYZBC"],
|
||||
"role": "support",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["直接 TRT kinematics", "G43", "CAM 点列"],
|
||||
"note": "原配置直接加载 XYZAC TRT;在 Web switchkins 配置中运行前应选择 M428/TCP。"
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-boat-xyzbc",
|
||||
"group": "trt-gmoccapy",
|
||||
"label": "gmoccapy Boat XYZBC",
|
||||
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc",
|
||||
"coordinates": ["XYZBC"],
|
||||
"role": "main",
|
||||
"features": ["直接 TRT kinematics", "G43", "G93/G94", "CAM 点列"],
|
||||
"note": "XYZBC 船体示例;Web switchkins 配置运行前应选择 M428/TCP。"
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-impeller-xyzac",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["直接 TRT kinematics", "G93", "叶轮 CAM"],
|
||||
"note": "无显式 M428;Web switchkins 配置运行前应选择 M428/TCP。"
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-test-xyzac",
|
||||
"group": "trt-gmoccapy",
|
||||
"label": "gmoccapy test-xyzac 子程序",
|
||||
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc",
|
||||
"coordinates": ["XYZAC"],
|
||||
"role": "support",
|
||||
"features": ["O-sub", "刀具更换", "几何偏置"],
|
||||
"note": "支持子程序,需要由 gmoccapy/NGCGUI 传参调用。"
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-test-xyzbc",
|
||||
"group": "trt-gmoccapy",
|
||||
"label": "gmoccapy test-xyzbc 子程序",
|
||||
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzbc.ngc",
|
||||
"coordinates": ["XYZBC"],
|
||||
"role": "support",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["M428/M429", "HAL 旋转中心", "G10 L20", "G43"],
|
||||
"note": "需要 XYZAB 双旋转台运动学;当前 Web 控制面板仅执行 XYZAC/XYZBC。"
|
||||
},
|
||||
{
|
||||
"id": "xyzab-remap-m428",
|
||||
"group": "xyzab-tdr",
|
||||
"label": "XYZAB M428 TCP remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/remap_subs/428remap.ngc",
|
||||
"coordinates": ["XYZAB"],
|
||||
"role": "support",
|
||||
"features": ["M68", "M66", "xyzab_tdr_kins"],
|
||||
"note": "XYZAB TCP 运动学切换支持文件。"
|
||||
},
|
||||
{
|
||||
"id": "xyzab-remap-m429",
|
||||
"group": "xyzab-tdr",
|
||||
"label": "XYZAB M429 identity remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/remap_subs/429remap.ngc",
|
||||
"coordinates": ["XYZAB"],
|
||||
"role": "support",
|
||||
"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"],
|
||||
"role": "main",
|
||||
"features": ["5axiskins", "B/C/W", "G43", "O-code 循环", "球面钻孔"],
|
||||
"note": "需要 XYZBCW Bridge Mill / 5axiskins;W 轴沿刀具方向进给。"
|
||||
},
|
||||
{
|
||||
"id": "bridgemill-remap-m428",
|
||||
"group": "bridgemill",
|
||||
"label": "Bridge Mill M428 remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/428remap.ngc",
|
||||
"coordinates": ["XYZBCW"],
|
||||
"role": "support",
|
||||
"features": ["M68", "M66", "5axiskins"],
|
||||
"note": "Bridge Mill 非平凡运动学切换支持文件。"
|
||||
},
|
||||
{
|
||||
"id": "bridgemill-remap-m429",
|
||||
"group": "bridgemill",
|
||||
"label": "Bridge Mill M429 identity remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/429remap.ngc",
|
||||
"coordinates": ["XYZBCW"],
|
||||
"role": "support",
|
||||
"features": ["M68", "M66", "identity kinematics"],
|
||||
"note": "Bridge Mill identity 运动学切换支持文件。"
|
||||
},
|
||||
{
|
||||
"id": "bridgemill-remap-m430",
|
||||
"group": "bridgemill",
|
||||
"label": "Bridge Mill M430 userk remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/430remap.ngc",
|
||||
"coordinates": ["XYZBCW"],
|
||||
"role": "support",
|
||||
"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"],
|
||||
"role": "support",
|
||||
"features": ["genhexkins", "M68", "M66", "RTCP tool-offset"],
|
||||
"note": "Hexapod genhexkins RTCP 运动学切换支持文件。"
|
||||
},
|
||||
{
|
||||
"id": "hexapod-remap-m429",
|
||||
"group": "hexapod",
|
||||
"label": "Hexapod M429 identity remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/429remap.ngc",
|
||||
"coordinates": ["GENHEX"],
|
||||
"role": "support",
|
||||
"features": ["identity kinematics", "M68", "M66"],
|
||||
"note": "Hexapod identity 运动学切换支持文件。"
|
||||
},
|
||||
{
|
||||
"id": "hexapod-remap-m430",
|
||||
"group": "hexapod",
|
||||
"label": "Hexapod M430 userk remap",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/430remap.ngc",
|
||||
"coordinates": ["GENHEX"],
|
||||
"role": "support",
|
||||
"features": ["user kinematics", "M68", "M66"],
|
||||
"note": "Hexapod userk 运动学切换支持文件。"
|
||||
},
|
||||
|
||||
{
|
||||
"id": "standalone-cone",
|
||||
"group": "standalone",
|
||||
"label": "cone.ngc 动态刀长示例",
|
||||
"path": "linuxcnc/nc_files/cone.ngc",
|
||||
"coordinates": ["XYZBCW"],
|
||||
"role": "reference",
|
||||
"features": ["G43.1", "B/C/W", "动态刀长", "锥面轨迹"],
|
||||
"note": "独立算法参考程序,需要带 B/C/W 的模拟五轴配置。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -262,7 +262,7 @@ export function reduceFrontendState(state: FrontendState, message: MessageEnvelo
|
||||
const value = payload<Record<string, unknown>>(message);
|
||||
const projected = projection(value, message, state);
|
||||
const taskState = String((value.task as Record<string, unknown> | undefined)?.state ?? "").toUpperCase();
|
||||
const status = ["ESTOP", "FAULT"].includes(taskState) ? "blocked" : "error";
|
||||
const status = value.status === "blocked" || ["ESTOP", "FAULT"].includes(taskState) ? "blocked" : "error";
|
||||
const readable = value.reason ?? value.gate_reason ?? value.message_key ?? value.code ?? "worker_error";
|
||||
return { ...base, status, error: { code: String(value.code ?? "worker_error"), message: String(readable) }, ...projected };
|
||||
}
|
||||
|
||||
@@ -57,6 +57,23 @@ button { color: inherit; }
|
||||
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.case-status { min-width: 0; flex: 1 1 auto; }
|
||||
.case-status.loading { color: #805400; font-weight: 700; }
|
||||
.rtcp-program-library { flex: 0 0 auto; }
|
||||
.rtcp-program-library > button { min-height: 26px; padding: 3px 8px; border: 1px solid #7c8e91; background: #f7fbfa; color: #203d40; cursor: pointer; }
|
||||
.rtcp-program-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding-bottom: 10px; border-bottom: 1px solid #b5c3c4; }
|
||||
.rtcp-program-scope, .rtcp-program-select { display: grid; grid-template-columns: 74px minmax(0, 1fr); align-items: center; gap: 8px; margin: 12px 0; }
|
||||
.rtcp-program-scope select, .rtcp-program-select select { min-width: 0; height: 32px; }
|
||||
.rtcp-program-meta { display: grid; gap: 6px; margin: 10px 0; }
|
||||
.rtcp-program-meta div { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; }
|
||||
.rtcp-program-meta dt { font-weight: 700; }
|
||||
.rtcp-program-meta dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.rtcp-compatible { color: #176237; font-weight: 700; }
|
||||
.rtcp-incompatible { color: #8a4c00; }
|
||||
.rtcp-program-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; }
|
||||
.rtcp-program-actions button, .rtcp-program-actions a { min-height: 30px; padding: 5px 10px; border: 1px solid #7c8e91; background: #f7fbfa; color: #203d40; font: inherit; text-decoration: none; cursor: pointer; }
|
||||
.rtcp-program-actions button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.rtcp-program-dialog { width: min(900px, calc(100vw - 16px)); }
|
||||
.rtcp-program-dialog output { display: block; min-height: 1.4em; text-align: left; }
|
||||
.rtcp-program-source { width: 100%; min-height: 320px; max-height: 48vh; margin-top: 8px; resize: vertical; white-space: pre; tab-size: 2; font: 12px/1.35 ui-monospace, monospace; }
|
||||
.command-status { margin-left: auto; }
|
||||
.preview-load-indicator { display: flex; align-items: center; min-width: min(360px, 30vw); max-width: 420px; flex: 1 1 260px; gap: 6px; color: #805400; font: 10px ui-monospace, monospace; }
|
||||
.preview-load-indicator span { min-width: 0; overflow: hidden; flex: 1 1 auto; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -174,7 +191,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.axis-preview-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: 100%; min-width: 0; min-height: 0; }
|
||||
.axis-preview-pane { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.axis-preview-machine { flex-direction: column; }
|
||||
.axis-preview-machine > .three-viewer { height: auto; min-height: 220px; }
|
||||
.axis-preview-machine > .three-viewer { height: 0; flex: 1 1 0; min-height: 0; }
|
||||
.trt-geometry { flex: 0 0 auto; background: #edf0f0; color: #202829; padding: 6px; font-size: 11px; border-top: 1px solid #899596; }
|
||||
.trt-geometry header, .trt-geometry footer { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
.trt-geometry header strong { overflow-wrap: anywhere; }
|
||||
@@ -275,7 +292,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
|
||||
.axis-notebook { min-height: 330px; }
|
||||
.axis-preview { min-height: 732px; }
|
||||
.axis-preview-split { grid-template-columns: 1fr; grid-template-rows: 360px 550px; align-self: start; }
|
||||
.axis-preview-split { grid-template-columns: 1fr; grid-template-rows: 360px 420px; align-self: start; }
|
||||
.axis-program-pane { flex: 0 0 auto; height: 740px; min-height: 740px; grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 140px; }
|
||||
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); grid-column: 1; grid-row: 1; overflow: hidden; }
|
||||
.program-editor-wrap { grid-column: 1; grid-row: 1; }
|
||||
@@ -308,3 +325,66 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.machine-status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 18px; padding: 6px 8px; border-bottom: 1px solid #999; min-width: 0; }
|
||||
.machine-status-dimensions { display: flex; flex: 1 1 auto; flex-wrap: wrap; gap: 6px 18px; }
|
||||
.machine-status-dimensions output { font-weight: bold; margin-left: 5px; }
|
||||
|
||||
.machine-import { padding: .55rem .7rem; border-top: 1px solid #b5c3c4; background: #eff5f4; color: #233f43; font-size: .78rem; }
|
||||
.machine-import header, .machine-import-actions { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
||||
.machine-import header span { overflow-wrap: anywhere; min-width: 0; }
|
||||
.machine-import-actions { margin: .4rem 0; }
|
||||
.machine-import-actions span { display: inline-flex; gap: .35rem; align-items: center; }
|
||||
.machine-import button { padding: .22rem .5rem; }
|
||||
.machine-import a { color: #09676a; text-decoration: underline; }
|
||||
.machine-import output { display: block; margin-top: .3rem; overflow-wrap: anywhere; }
|
||||
.machine-import details p { margin: .25rem 0; }
|
||||
|
||||
/* Keep forms out of the model's flex layout. Native dialogs render in the top layer. */
|
||||
.machine-preview-actions { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 5px; padding: 5px 7px; background: #edf3f2; border: 1px solid #899596; }
|
||||
.machine-preview-actions > section { padding: 0; border: 0; flex: 0 0 auto; background: transparent; }
|
||||
.machine-import-toolbar button, .trt-geometry-toolbar button, .machine-manager > button { min-height: 28px; padding: 3px 10px; border: 1px solid #7c8e91; background: #f7fbfa; color: #203d40; cursor: pointer; font-size: 12px; }
|
||||
.machine-dialog { position: fixed; inset: auto; margin: 0; padding: 0; width: min(580px, calc(100vw - 16px)); max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); border: 1px solid #52666a; background: #edf3f2; color: #202829; box-shadow: 0 12px 34px #0008; overflow: hidden; }
|
||||
.machine-dialog[open] { display: flex; flex-direction: column; }
|
||||
.machine-dialog::backdrop { background: rgba(10, 18, 20, .25); }
|
||||
.machine-dialog .machine-dialog-titlebar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 10px; background: #27585d; color: #fff; cursor: move; user-select: none; touch-action: none; }
|
||||
.machine-dialog-titlebar strong { min-width: 0; font-size: 14px; }
|
||||
.machine-dialog-titlebar button { flex: 0 0 auto; width: 30px; height: 28px; border: 1px solid #c4d9d9; background: #e8f0ef; color: #18373a; font-size: 21px; line-height: 1; cursor: pointer; }
|
||||
.machine-dialog-body { min-height: 0; overflow: auto; padding: 14px; font-size: 13px; }
|
||||
.machine-dialog-body > header { margin-bottom: 12px; }
|
||||
.machine-dialog-body output { display: block; margin-top: 9px; overflow-wrap: anywhere; }
|
||||
.geometry-dialog .trt-geometry-fields { gap: 16px; margin: 6px 0 16px; }
|
||||
.geometry-dialog .trt-geometry-fields label { grid-template-columns: minmax(0, 1fr) 90px; gap: 8px; margin: 8px 0; }
|
||||
.geometry-dialog input { height: 30px; }
|
||||
.machine-dialog button:focus-visible { outline: 2px solid #bf8b17; outline-offset: 2px; }
|
||||
@media (max-width: 620px) {
|
||||
.geometry-dialog .trt-geometry-fields { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
.machine-manager-dialog { width: min(1180px, calc(100vw - 16px)); }
|
||||
.machine-manager-toolbar, .machine-manager-footer { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.machine-manager-toolbar { margin-bottom: 10px; }
|
||||
.machine-manager-toolbar button[aria-pressed="true"] { background: #27585d; color: #fff; }
|
||||
.machine-manager-layout { display: grid; grid-template-columns: minmax(380px, 1.35fr) minmax(330px, .9fr); gap: 12px; min-height: 430px; }
|
||||
.machine-manager-preview { min-width: 0; min-height: 430px; border: 1px solid #52666a; background: #10191d; overflow: hidden; }
|
||||
.machine-manager-preview canvas { display: block; width: 100%; height: 100%; }
|
||||
.machine-manager-editor { display: grid; grid-template-rows: minmax(120px, .7fr) minmax(260px, 1.3fr); min-height: 0; gap: 8px; }
|
||||
.machine-part-list, .machine-part-editor, .machine-manager-calibration { border: 1px solid #9babad; background: #f6f9f8; padding: 8px; min-height: 0; overflow: auto; }
|
||||
.machine-part-list h3, .machine-part-editor h3, .machine-manager-calibration h3 { margin: 0 0 7px; font-size: 13px; }
|
||||
.machine-part-list > div { display: grid; grid-template-columns: minmax(0, 1fr) 30px; margin: 3px 0; }
|
||||
.machine-part-list > div > button:first-child { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; text-align: left; min-width: 0; }
|
||||
.machine-part-list > div.selected > button:first-child { background: #d6e9e6; border-color: #27585d; }
|
||||
.machine-part-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-part-editor > label { display: grid; grid-template-columns: 82px minmax(0, 1fr); align-items: center; gap: 8px; margin: 6px 0; }
|
||||
.machine-part-editor select { height: 30px; min-width: 0; }
|
||||
.machine-part-flags { display: flex; gap: 16px; margin: 8px 0; }
|
||||
.machine-vector { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; padding: 6px; }
|
||||
.machine-vector legend { font-weight: 700; }
|
||||
.machine-vector label { display: grid; grid-template-columns: 16px 76px; align-items: center; gap: 3px; }
|
||||
.machine-vector input { min-width: 0; height: 28px; }
|
||||
.machine-manager-calibration { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 10px; overflow: visible; }
|
||||
.machine-manager-calibration h3 { grid-column: 1 / -1; }
|
||||
.machine-manager-footer { margin-top: 10px; }
|
||||
.machine-manager-footer button { min-height: 32px; }
|
||||
.machine-manager-footer output { flex: 1 1 320px; text-align: left; }
|
||||
@media (max-width: 850px) {
|
||||
.machine-manager-layout { grid-template-columns: 1fr; }
|
||||
.machine-manager-preview { min-height: 360px; }
|
||||
.machine-manager-calibration { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ const css = await readFile(new URL("./styles.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(app, /axis-preview-split/);
|
||||
assert.match(app, /XyzacToolPreviewFromSnapshot snapshot=\{state\.snapshot\}/);
|
||||
assert.match(app, /XyzacMachineViewFromSnapshot snapshot=\{state\.snapshot\}/);
|
||||
assert.match(app, /showPath=\{false\} showLive=\{false\}/);
|
||||
assert.match(app, /<XyzacMachineViewFromSnapshot[^>]*snapshot=\{state\.snapshot\}/);
|
||||
assert.match(app, /<XyzacMachineViewFromSnapshot[^>]*importedMachine=\{importedMachine\}/);
|
||||
assert.match(app, /showPath=\{layers\.path\} showLive=\{layers\.live\}/);
|
||||
assert.match(tool, /new THREE\.WebGLRenderer/);
|
||||
assert.match(tool, /data-xyzac-preview="toolpath"/);
|
||||
assert.match(machine, /new THREE\.WebGLRenderer/);
|
||||
@@ -18,4 +19,4 @@ assert.match(machine, /data-xyzac-model-source-hash/);
|
||||
for (const token of ["OrbitControls", "enableRotate", "mouseButtons.LEFT = THREE.MOUSE.ROTATE", "mouseButtons.MIDDLE = THREE.MOUSE.DOLLY", "mouseButtons.RIGHT = THREE.MOUSE.PAN", "touches.ONE = THREE.TOUCH.ROTATE", "touches.TWO = THREE.TOUCH.DOLLY_PAN", "xyzacMachineController", "zoomIn", "zoomOut", "panLeft", "panRight", "panUp", "panDown", "rotateLeft", "rotateRight", "rotateUp", "rotateDown", "Reset machine view"]) assert.match(machine, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(css, /\.axis-preview-split\s*\{/);
|
||||
|
||||
console.log(JSON.stringify({ schema: "xyzac-dual-canvas-contract.v1", status: "verified", shared_snapshot: true, independent_renderers: 2, machine_path_layers: "disabled" }));
|
||||
console.log(JSON.stringify({ schema: "xyzac-dual-canvas-contract.v1", status: "verified", shared_snapshot: true, independent_renderers: 2, machine_path_layers: "worker_projected" }));
|
||||
|
||||
36
web/react/src/test_rtcp_program_catalog.mjs
Normal file
36
web/react/src/test_rtcp_program_catalog.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
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"));
|
||||
assert.equal(catalog.schema, "linuxcnc.rtcp-program-library/1");
|
||||
assert.equal(catalog.total, 32);
|
||||
assert.equal(catalog.programs.length, 32);
|
||||
assert.deepEqual(Object.fromEntries(catalog.groups.map((group) => [group.id, group.count])), {
|
||||
"trt-axis": 16,
|
||||
"trt-gmoccapy": 5,
|
||||
"xyzab-tdr": 3,
|
||||
bridgemill: 4,
|
||||
hexapod: 3,
|
||||
standalone: 1,
|
||||
});
|
||||
assert.equal(new Set(catalog.programs.map((program) => program.id)).size, 32);
|
||||
assert.equal(new Set(catalog.programs.map((program) => program.path)).size, 32);
|
||||
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`);
|
||||
}
|
||||
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(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 }));
|
||||
@@ -34,6 +34,7 @@ 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"],
|
||||
["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"],
|
||||
["linuxcnc/linuxcnc.gif", "linuxcnc/linuxcnc.gif"],
|
||||
|
||||
@@ -54,9 +54,18 @@ export class SimScheduler {
|
||||
}
|
||||
for (let index = 0; index < cycles; index += 1) {
|
||||
const nextTick = this.tick + 1;
|
||||
for (const phase of SCHEDULER_PHASES) {
|
||||
this.phase_trace = [...SCHEDULER_PHASES];
|
||||
if (typeof callbacks[phase] === "function") callbacks[phase](nextTick);
|
||||
try {
|
||||
for (const phase of SCHEDULER_PHASES) {
|
||||
this.phase_trace = [...SCHEDULER_PHASES];
|
||||
if (typeof callbacks[phase] === "function") callbacks[phase](nextTick);
|
||||
}
|
||||
} catch (error) {
|
||||
// Callbacks may already have advanced motion inside this tick. Do
|
||||
// not replay it or retain the native scheduler's unexecuted future.
|
||||
this.tick = nextTick;
|
||||
this.last_cycles = index + 1;
|
||||
this.restore(this.export());
|
||||
throw error;
|
||||
}
|
||||
this.tick = nextTick;
|
||||
}
|
||||
|
||||
@@ -683,11 +683,19 @@ export class SimSession {
|
||||
const joints = Array.isArray(current.state.joint_commanded) && current.state.joint_commanded.length === 5
|
||||
? [...current.state.joint_commanded]
|
||||
: [...this.xyzacKinematics.snapshot().joints];
|
||||
const selected = this.xyzacKinematics.select(mode, joints);
|
||||
// A switchkins button changes the coordinate interpretation of the
|
||||
// stopped machine. Preserve the physical joint sample and recompute
|
||||
// only the coordinate pose in the new kinematics. `select()` converts
|
||||
// the old pose back into the target mode, which changes joints and makes
|
||||
// an idle M429/M428 button appear to move the machine.
|
||||
const selected = this.xyzacKinematics.selectPhysical(mode, joints);
|
||||
if (selected.status !== "verified") return selected;
|
||||
this.xyzacProgramKinematicsMode = mode;
|
||||
if (typeof this.wasmMotion.setActiveKinematicsTag === "function") this.wasmMotion.setActiveKinematicsTag(mode);
|
||||
if (selected.pose && typeof this.wasmMotion.setPosition === "function") this.wasmMotion.setPosition(selected.pose);
|
||||
if (selected.pose && typeof this.wasmMotion.setPosition === "function") this.wasmMotion.setPosition(selected.pose, mode);
|
||||
if (selected.pose && this.xyzacProgram) {
|
||||
const synchronized = this.xyzacProgram.synchronizePosition(selected.pose);
|
||||
if (synchronized.status !== "verified") return synchronized;
|
||||
}
|
||||
return { ...selected, kinematics: this.xyzacKinematics.snapshot(), wasm_motion: this.wasmMotion.read() };
|
||||
}
|
||||
|
||||
@@ -1457,22 +1465,27 @@ export class SimSession {
|
||||
});
|
||||
}
|
||||
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
|
||||
const pausedStep = input.kind === "SIM_STEP" && this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3);
|
||||
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles) && !pausedStep
|
||||
? this.tick + input.payload.cycles : this.tick;
|
||||
try {
|
||||
this.timeline.appendEvent(input.seq, eventTick, input.kind, input.payload ?? {});
|
||||
} catch (error) {
|
||||
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
||||
return this.error(input, "event_log", String(error?.message ?? error));
|
||||
}
|
||||
const coreEventResult = appendCoreEvent(this.core, input.seq, eventTick, input.kind, input.payload ?? {});
|
||||
if (coreEventResult !== 0) {
|
||||
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
||||
return this.error(input, "event_log", "core_event_append_failed");
|
||||
// SIM_STEP may stop partway through its requested cycles. Record its
|
||||
// actual end tick, never a future tick that poisons subsequent commands.
|
||||
let eventRecorded = false;
|
||||
const recordEvent = () => {
|
||||
if (eventRecorded) return;
|
||||
this.timeline.appendEvent(input.seq, this.tick, input.kind, input.payload ?? {});
|
||||
eventRecorded = true;
|
||||
const result = appendCoreEvent(this.core, input.seq, this.tick, input.kind, input.payload ?? {});
|
||||
if (result !== 0) throw new Error("core_event_append_failed");
|
||||
};
|
||||
if (input.kind !== "SIM_STEP") {
|
||||
try { recordEvent(); }
|
||||
catch (error) {
|
||||
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
||||
return this.error(input, "event_log", String(error?.message ?? error));
|
||||
}
|
||||
}
|
||||
|
||||
const finish = (response, status = "verified") => {
|
||||
try { recordEvent(); }
|
||||
catch (error) { return this.error(input, "event_log", String(error?.message ?? error)); }
|
||||
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
|
||||
const operation = this.operations.get(input.operation_id);
|
||||
if (operation) {
|
||||
@@ -2174,8 +2187,26 @@ export class SimSession {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return finish(this.error(input, "scheduler", String(error?.message ?? error)), "failed");
|
||||
// The scheduler may have consumed a partial task cycle. Align all
|
||||
// transport clocks to that attempted tick and leave motion stopped.
|
||||
this.tick = this.scheduler.tick;
|
||||
this.task.setTick(this.tick);
|
||||
call(this.core, "sim_protocol_set_tick", this.tick);
|
||||
if (this.xyzacProgram) {
|
||||
this.wasmMotion.abort();
|
||||
this.pendingXyzacMotion = null;
|
||||
if (this.xyzacProgram.snapshot().status === 2) this.xyzacProgram.abort();
|
||||
if (this.task.state.state === "RUNNING") this.task.transition("STOPPED");
|
||||
}
|
||||
const reason = String(error?.message ?? error);
|
||||
const limit = reason === "motion_joint_limit";
|
||||
return finish(this.envelope("ERROR", input, {
|
||||
status: limit ? "blocked" : "error", code: limit ? "motion_limit" : "scheduler",
|
||||
message_key: limit ? "关节到达限位,运动已停止;可反向 Jog" : reason,
|
||||
reason, motion_stopped: Boolean(this.xyzacProgram), completed_cycles: this.scheduler.last_cycles,
|
||||
}), "failed");
|
||||
}
|
||||
recordEvent();
|
||||
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null;
|
||||
const execution_trace = program_steps
|
||||
.map((step) => ({
|
||||
@@ -2730,6 +2761,10 @@ export class SimSession {
|
||||
})();
|
||||
if (jog.status !== "verified") {
|
||||
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
|
||||
if (code === "motion_limit_range") return finish(this.envelope("ERROR", input, {
|
||||
status: "blocked", code: "motion_limit", message_key: code,
|
||||
reason: "Jog 超出行程范围;请反向移动或减小增量", command: input.kind,
|
||||
}), "failed");
|
||||
return finish(this.error(input, "motion_queue", code), "failed");
|
||||
}
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_motion: this.wasmMotion.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
||||
|
||||
78
web/worker/test_jog_limit_recovery.mjs
Normal file
78
web/worker/test_jog_limit_recovery.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import createModule from '../../build-wasm/linuxcnc-sim-core.js';
|
||||
import { XyzacSession, CONTEXT_ID } from './xyzac-session.mjs';
|
||||
import { loadSimulationCase, toWorkerConfiguration } from '../workspace/sim-case-loader.mjs';
|
||||
import { MACHINE_DEFINITIONS } from '../workspace/machine-definitions.mjs';
|
||||
|
||||
const root = new URL('../../', import.meta.url);
|
||||
const output = new URL('coverage/glb-machine/jog-limits/', root);
|
||||
await mkdir(output, { recursive: true });
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
|
||||
const report = { report_id: 'jog-limit-recovery-v1', status: 'running', machines: [] };
|
||||
try {
|
||||
for (const definition of MACHINE_DEFINITIONS) {
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
|
||||
const session = new XyzacSession(core, { sessionId: `jog-limit-${definition.id}`, planner: 'linuxcnc-tp' }); let seq = 0;
|
||||
const send = (kind, payload = {}, expected = null) => {
|
||||
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `limit-${seq}`, payload });
|
||||
if (expected) assert.equal(result.kind, expected, JSON.stringify(result.payload));
|
||||
else assert.notEqual(result.kind, 'ERROR', `${kind}: ${result.payload.message_key ?? result.payload.gate_reason}`);
|
||||
assert.equal(session.timeline.events.cursor, seq, `${kind}: event cursor`);
|
||||
assert.equal(core._sim_event_cursor(), seq, `${kind}: native event cursor`);
|
||||
return result;
|
||||
};
|
||||
send('HELLO'); send('SESSION_ATTACH');
|
||||
const bundle = await loadSimulationCase({ path: definition.entrypoint, fetchImpl });
|
||||
send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: toWorkerConfiguration(bundle) });
|
||||
send('POWER', { active: true }); send('HOME', { all: true });
|
||||
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send('SIM_STEP', { cycles: 1 });
|
||||
const tilt = definition.axes[3];
|
||||
const drain = () => { for (let i = 0; i < 2000 && session.wasmMotion.read().state.queue_depth; i++) send('SIM_STEP', { cycles: 5, response: 'motion_delta' }); assert.equal(session.wasmMotion.read().state.queue_depth, 0); };
|
||||
send('MDI_EXECUTE', { block: `G21 G90 G0 X0 Y0 Z30 ${tilt.toUpperCase()}49 C20` }); drain();
|
||||
send('JOG_START', { axis: tilt, direction: 1, continuous: true, increment: null });
|
||||
drain();
|
||||
const limit = session.wasmMotion.read().state;
|
||||
assert.ok(limit.joint_commanded[3] <= 50 + 1e-8);
|
||||
send('JOG_STOP');
|
||||
send('JOG_START', { axis: tilt, direction: -1, increment: 1 }); drain(); send('JOG_STOP');
|
||||
assert.ok(Math.abs(session.wasmMotion.read().state.joint_commanded[3] - 49) < 1e-7);
|
||||
send('JOG_START', { axis: tilt, direction: 1, increment: 5 }, 'ERROR');
|
||||
send('JOG_STOP');
|
||||
send('SNAPSHOT_REQUEST');
|
||||
const recovered = session.wasmMotion.read().state.joint_commanded;
|
||||
send('MDI_EXECUTE', { block: `G90 G0 X190 Y0 Z30 ${tilt.toUpperCase()}0 C0` }); drain();
|
||||
send('KINEMATICS_SELECT', { type: 1 });
|
||||
send('JOG_START', { axis: 'c', direction: 1, continuous: true, increment: null });
|
||||
let fault;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const r = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind: 'SIM_STEP', seq: ++seq, operation_id: `limit-${seq}`, payload: { cycles: 5, response: 'motion_delta' } });
|
||||
if (r.kind === 'ERROR') { fault = r; break; }
|
||||
}
|
||||
assert.equal(fault?.payload.code, 'motion_limit', JSON.stringify(fault?.payload));
|
||||
assert.equal(session.timeline.events.cursor, seq);
|
||||
assert.equal(core._sim_event_cursor(), seq);
|
||||
assert.equal(session.timeline.events.tick, session.tick);
|
||||
assert.equal(session.scheduler.tick, session.tick);
|
||||
const stopped = session.wasmMotion.read().state;
|
||||
assert.equal(stopped.queue_depth, 0);
|
||||
assert.equal(stopped.velocity, 0, 'limit stop must publish zero velocity');
|
||||
assert.ok(stopped.joint_commanded.every((value, i) => { const range = session.xyzacKinematics.options.limits[definition.axes[i]]; return value >= range[0] - 1e-8 && value <= range[1] + 1e-8; }));
|
||||
send('JOG_STOP'); send('SNAPSHOT_REQUEST');
|
||||
const checkpoint = session.createCheckpoint();
|
||||
const restored = session.restoreCheckpoint(checkpoint);
|
||||
assert.equal(restored.status, 'recovered');
|
||||
send('JOG_START', { axis: 'c', direction: -1, increment: 1 }); drain(); send('JOG_STOP');
|
||||
assert.ok(session.wasmMotion.read().state.position.c < stopped.position.c - .99);
|
||||
// Invalid sampling requests must also consume their event at the actual
|
||||
// clock, rather than reserving ticks that will never run.
|
||||
send('SIM_STEP', { cycles: 1000001 }, 'ERROR');
|
||||
send('SIM_STEP', { cycles: -1 }, 'ERROR');
|
||||
send('SNAPSHOT_REQUEST');
|
||||
send('SIM_STEP', { cycles: 1, response: 'motion_delta' });
|
||||
report.machines.push({ coordinates: definition.coordinates, limit: limit.joint_commanded, recovered, rtcp_limit: stopped.joint_commanded, stopped_tick: fault.simulation_tick, completed_cycles: fault.payload.completed_cycles, seq });
|
||||
}
|
||||
report.status = 'verified';
|
||||
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
|
||||
finally { await writeFile(new URL('worker.report.json', output), JSON.stringify(report, null, 2) + '\n'); }
|
||||
console.log(JSON.stringify(report));
|
||||
45
web/worker/test_rtcp_program_catalog_load.mjs
Normal file
45
web/worker/test_rtcp_program_catalog_load.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
|
||||
|
||||
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 fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(String(url).replace(/^\//, ""), root)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const machines = {
|
||||
XYZAC: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
|
||||
XYZBC: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
|
||||
};
|
||||
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
|
||||
const core = await createModule({ wasmBinary });
|
||||
const evidence = [];
|
||||
|
||||
for (const [coordinates, casePath] of Object.entries(machines)) {
|
||||
const bundle = await loadSimulationCase({ path: casePath, fetchImpl });
|
||||
assert.equal(bundle.status, "ready", `${coordinates}:case`);
|
||||
const session = new SimSession(core, { sessionId: `rtcp-catalog-${coordinates.toLowerCase()}` });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
|
||||
send("HELLO"); send("SESSION_ATTACH");
|
||||
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
|
||||
const programs = catalog.programs.filter((program) => program.role === "main" && program.coordinates.includes(coordinates));
|
||||
for (const program of programs) {
|
||||
const source = await readFile(new URL(program.path, root), "utf8");
|
||||
const loaded = send("PROGRAM_LOAD", { source, path: program.path });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT", `${program.id}:${JSON.stringify(loaded.payload)}`);
|
||||
assert.equal(loaded.payload.status, "verified", program.id);
|
||||
assert.equal(loaded.payload.program.path, program.path, program.id);
|
||||
assert.equal(loaded.payload.program.source_hash, fnv1a64(source), program.id);
|
||||
assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program", program.id);
|
||||
evidence.push({ id: program.id, coordinates, source_hash: loaded.payload.program.source_hash, preview_segments: loaded.payload.preview.segments.length });
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(evidence.length, 11);
|
||||
console.log(JSON.stringify({ schema: "rtcp-program-catalog-load.v1", status: "verified", programs: evidence }));
|
||||
@@ -20,6 +20,15 @@ assert.equal(fast.tick, 10);
|
||||
const restored = new SimScheduler(core, { budget: 100 });
|
||||
assert.equal(restored.restore(fast.export()).state_hash, fast.hash());
|
||||
|
||||
const interrupted = new SimScheduler(core, { budget: 100 });
|
||||
assert.throws(() => interrupted.step(10, { motion: (tick) => { if (tick === 3) throw new Error("injected_motion_fault"); } }), /injected_motion_fault/);
|
||||
assert.equal(interrupted.tick, 3);
|
||||
assert.equal(interrupted.last_cycles, 3);
|
||||
const afterFault = [];
|
||||
interrupted.step(2, { motion: (tick) => afterFault.push(tick) });
|
||||
assert.deepEqual(afterFault, [4, 5]);
|
||||
assert.equal(interrupted.tick, 5);
|
||||
|
||||
let lastSeq = 0;
|
||||
const session = new SimSession({
|
||||
_sim_protocol_reset() {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
@@ -137,6 +138,7 @@ for (const machine of MACHINE_DEFINITIONS) {
|
||||
assert.deepEqual(transported.session.wasmMotion.read(), beforeRewind);
|
||||
}
|
||||
const report = { report_id: "trt-checkpoint-exact-v1", status: "verified", safeguards: { corrupt_native_rejected: true, foreign_build_rejected: true, frozen_subroutines: true, heap_relocation_checked: true, receipts_nonrecursive: true, backward_transport_restore_rejected: true }, rows, state_hash: fnv1a64(rows) };
|
||||
await mkdir(new URL("coverage/recovery/", root), { recursive: true });
|
||||
await writeFile(new URL("coverage/recovery/exact-checkpoint.report.json", root), JSON.stringify(report, null, 2) + "\n");
|
||||
const output = process.env.TRT_EVIDENCE_DIR ? pathToFileURL(`${process.env.TRT_EVIDENCE_DIR.replace(/\/$/, "")}/`) : new URL("coverage/recovery/", root);
|
||||
await mkdir(output, { recursive: true });
|
||||
await writeFile(new URL("exact-checkpoint.report.json", output), JSON.stringify(report, null, 2) + "\n");
|
||||
console.log(JSON.stringify(report));
|
||||
|
||||
67
web/worker/test_trt_switch_stationary.mjs
Normal file
67
web/worker/test_trt_switch_stationary.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import createModule from '../../build-wasm/linuxcnc-sim-core.js';
|
||||
import { SimSession, CONTEXT_ID } from './sim-session.mjs';
|
||||
import { loadSimulationCase, toWorkerConfiguration } from '../workspace/sim-case-loader.mjs';
|
||||
import { writeTrtGeometry } from '../workspace/trt-geometry.mjs';
|
||||
import { MACHINE_DEFINITIONS } from '../workspace/machine-definitions.mjs';
|
||||
const root = new URL('../../', import.meta.url), output = new URL('coverage/glb-machine/switching/', root);
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
|
||||
const report = { report_id: 'trt-stationary-switch-worker-v1', status: 'running', machines: [] };
|
||||
const closeJoints = (actual, expected, label) => actual.forEach((v, i) => assert.ok(Math.abs(v - expected[i]) < 1e-8, `${label} J${i}: ${v} != ${expected[i]}`));
|
||||
try {
|
||||
for (const definition of MACHINE_DEFINITIONS) {
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
|
||||
const session = new SimSession(core, { sessionId: `switch-${definition.id}` }); let seq = 0;
|
||||
const send = (kind, payload = {}, expected = 'COMMAND_RESULT') => {
|
||||
const r = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `switch-${seq}`, payload });
|
||||
assert.equal(r.kind, expected, `${kind}: ${JSON.stringify(r.payload)}`); return r;
|
||||
};
|
||||
send('HELLO', {}, 'CAPABILITIES'); send('SESSION_ATTACH', {}, 'SESSION_READY');
|
||||
const offsets = definition.coordinates === 'XYZAC' ? { x_rot_point: 24, y_rot_point: -12, z_rot_point: 8, y_offset: 18, z_offset: -45 } : { x_rot_point: -18, y_rot_point: 14, z_rot_point: 12, x_offset: 22, z_offset: -40 };
|
||||
const bundle = writeTrtGeometry(await loadSimulationCase({ path: definition.entrypoint, fetchImpl }), offsets);
|
||||
send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: toWorkerConfiguration(bundle) });
|
||||
send('POWER', { active: true }); send('HOME', { all: true });
|
||||
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send('SIM_STEP', { cycles: 1 });
|
||||
assert.ok(session.wasmHomeLimit.read().state.homed);
|
||||
const drain = () => { for (let i = 0; i < 5000 && session.wasmMotion.read().state.queue_depth; i++) send('SIM_STEP', { cycles: 1 }); assert.equal(session.wasmMotion.read().state.queue_depth, 0); };
|
||||
const rows = [];
|
||||
for (const [x, y, z, tilt, c] of [[0, 0, 44.5858, 4.8675, 179.88], [190, -95, 110, 40, 175]]) {
|
||||
send('KINEMATICS_SELECT', { type: 0 });
|
||||
send('MDI_EXECUTE', { block: `G21 G90 G0 X${x} Y${y} Z${z} ${definition.coordinates[3]}${tilt} C${c}` }); drain();
|
||||
const baseline = session.wasmMotion.read().state.joint_commanded;
|
||||
for (const kind of ['KINEMATICS_SELECT', 'MDI_EXECUTE']) for (const type of [1, 0, 1, 1, 0, 2, 1, 0]) {
|
||||
const before = session.wasmMotion.read().state;
|
||||
const result = send(kind, kind === 'KINEMATICS_SELECT' ? { type } : { block: `M${type === 1 ? 428 : type === 0 ? 429 : 430}` });
|
||||
const current = session.wasmMotion.read().state;
|
||||
closeJoints(current.joint_commanded, baseline, `${kind} mode=${type}`);
|
||||
closeJoints(result.payload.panel_snapshot.visual.joints, baseline, 'visual');
|
||||
closeJoints(result.payload.kinematics.joints, baseline, 'kinematics');
|
||||
assert.equal(current.active_tag, type); assert.equal(current.queue_depth, 0);
|
||||
assert.deepEqual(session.xyzacProgram.synchronizedPosition, current.position, 'interpreter must share new frame');
|
||||
for (let i = 0; i < 3; i++) { send('SIM_STEP', { cycles: 1 }); closeJoints(session.wasmMotion.read().state.joint_commanded, baseline, 'idle tick'); }
|
||||
rows.push({ kind, type, baseline, joints: current.joint_commanded, previous_pose: before.position, pose: current.position });
|
||||
}
|
||||
}
|
||||
send('KINEMATICS_SELECT', { type: 0 });
|
||||
send('MDI_EXECUTE', { block: `G21 G90 G0 X10 Y5 Z20 ${definition.coordinates[3]}20 C60` }); drain();
|
||||
send('MDI_EXECUTE', { block: 'G10 L2 P1 X3 Y-2 Z4' });
|
||||
send('KINEMATICS_SELECT', { type: 1 });
|
||||
const beforeIncrement = session.wasmMotion.read().state.position;
|
||||
send('MDI_EXECUTE', { block: 'G91 G1 X1 F600' }); drain();
|
||||
const afterIncrement = session.wasmMotion.read().state.position;
|
||||
assert.ok(Math.abs(afterIncrement.x - beforeIncrement.x - 1) < 1e-8);
|
||||
for (const key of ['y', 'z', 'a', 'b', 'c']) assert.ok(Math.abs(afterIncrement[key] - beforeIncrement[key]) < 1e-8);
|
||||
send('MDI_EXECUTE', { block: 'G91 G1 X10 F60' });
|
||||
const activeBefore = session.wasmMotion.read().state;
|
||||
send('KINEMATICS_SELECT', { type: 0 }, 'ERROR');
|
||||
assert.equal(session.xyzacKinematics.activeMode, 1); closeJoints(session.wasmMotion.read().state.joint_commanded, activeBefore.joint_commanded, 'blocked switch');
|
||||
drain();
|
||||
const beforeInvalid = session.wasmMotion.read().state;
|
||||
send('KINEMATICS_SELECT', { type: 9 }, 'ERROR'); closeJoints(session.wasmMotion.read().state.joint_commanded, beforeInvalid.joint_commanded, 'invalid switch');
|
||||
report.machines.push({ coordinates: definition.coordinates, rows, beforeIncrement, afterIncrement, busy_and_invalid_rejected: true });
|
||||
}
|
||||
report.status = 'verified';
|
||||
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
|
||||
finally { await writeFile(new URL('worker.report.json', output), JSON.stringify(report, null, 2) + '\n'); }
|
||||
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, switches: report.machines.reduce((n, m) => n + m.rows.length, 0) }));
|
||||
@@ -50,20 +50,22 @@ for (const [code, expected] of [["M429", 0], ["M428", 1], ["M430", 2]]) {
|
||||
switchRows.push({ code, tick: result.simulation_tick, active_type: expected, state_hash: result.payload.panel_snapshot.state_hash });
|
||||
}
|
||||
|
||||
// Direct panel selection must preserve the live WASM TP pose at a non-zero
|
||||
// position for every pair of IDENTITY/TCP:XYZAC/USERK transitions.
|
||||
// Direct panel selection must preserve physical joints and keep the new
|
||||
// coordinate pose synchronized with TP for every mode transition.
|
||||
const move = send("MDI_EXECUTE", "seed-position", { block: "G0 X10 Y5 Z20 A30 C45" });
|
||||
assert.equal(move.kind, "COMMAND_RESULT");
|
||||
for (let cycle = 0; cycle < 1000 && session.wasmMotion.read().state.queue_depth > 0; cycle += 1) send("SIM_STEP", `seed-step-${cycle}`, { cycles: 1 });
|
||||
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
|
||||
const directSwitches = [];
|
||||
for (const expected of [0, 1, 2, 0, 1, 2, 0]) {
|
||||
const physicalJoints = session.wasmMotion.read().state.joint_commanded;
|
||||
const result = send("KINEMATICS_SELECT", `direct-${expected}-${seq}`, { type: expected });
|
||||
assert.equal(result.kind, "COMMAND_RESULT");
|
||||
assert.equal(result.payload.kinematics.active_type, expected);
|
||||
const pose = result.payload.kinematics.pose;
|
||||
const tpPose = result.payload.wasm_motion.state.position;
|
||||
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) assert.ok(Math.abs(Number(pose[axis]) - Number(tpPose[axis])) <= 1e-9, `${axis}: ${pose[axis]} != ${tpPose[axis]}`);
|
||||
result.payload.wasm_motion.state.joint_commanded.forEach((value, i) => assert.ok(Math.abs(value - physicalJoints[i]) < 1e-8, `switch must preserve physical joint ${i}`));
|
||||
directSwitches.push({ active_type: expected, state_hash: result.payload.kinematics.state_hash });
|
||||
}
|
||||
|
||||
|
||||
@@ -72,11 +72,14 @@ export class WasmLinuxcncTp {
|
||||
if (blending !== 0) throw new Error(`linuxcnc_tp_blend_${blending}`);
|
||||
}
|
||||
|
||||
setPosition(value) {
|
||||
setPosition(value, kinematicsMode = this.activeTag) {
|
||||
if (!this.available) return this.read();
|
||||
this.core.HEAPF64.set(poseArray(value), this.posePointer / 8);
|
||||
const result = call(this.core, "sim_linuxcnc_tp_set_position", "number", ["number", "number"], [this.planner, this.posePointer]);
|
||||
if (result !== 0) throw new Error(`linuxcnc_tp_set_position_${result}`);
|
||||
// Publish the pose and its coordinate interpretation together. Reading
|
||||
// between these updates would inverse-solve an old pose in a new mode.
|
||||
this.activeTag = Number(kinematicsMode);
|
||||
return this.read();
|
||||
}
|
||||
|
||||
@@ -112,7 +115,20 @@ export class WasmLinuxcncTp {
|
||||
setAxisLimit(axis, minimum, maximum) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_axis_limit", "number", ["number", "number", "number", "number"], [this.planner, Number(axis), Number(minimum), Number(maximum)]); if (result !== 0) throw new Error(`linuxcnc_tp_axis_limit_${result}`); return this.read(); }
|
||||
jog(axis, direction, increment, tag = 0) {
|
||||
if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" };
|
||||
const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]);
|
||||
const coordinate = "xyzabcuvw"[axis];
|
||||
// Identity and rotary coordinates map directly to physical joints. Apply
|
||||
// both INI constraints to the native jog target without changing normal
|
||||
// coordinate limits (RTCP XYZ has a different physical interpretation).
|
||||
const jointLimit = this.kinematics?.options?.limits?.[coordinate];
|
||||
const directJoint = this.activeTag !== 1 || axis >= 3;
|
||||
let savedLimits = null;
|
||||
if (directJoint && jointLimit) {
|
||||
savedLimits = [0, 1].map((maximum) => call(this.core, "sim_linuxcnc_tp_axis_limit", "number", ["number", "number", "number"], [this.planner, axis, maximum]));
|
||||
this.setAxisLimit(axis, Math.max(savedLimits[0], jointLimit[0]), Math.min(savedLimits[1], jointLimit[1]));
|
||||
}
|
||||
let result;
|
||||
try { result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]); }
|
||||
finally { if (savedLimits) this.setAxisLimit(axis, ...savedLimits); }
|
||||
if (result !== 0) return { status: "error", code: `linuxcnc_tp_jog_${result}` };
|
||||
// The native jog tag is a segment ID, not a kinematics mode. Replace any
|
||||
// earlier program-line mapping with this jog's current coordinate frame.
|
||||
@@ -187,9 +203,27 @@ export class WasmLinuxcncTp {
|
||||
step() {
|
||||
if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" };
|
||||
if (this.controls.paused) return { status: "error", code: "linuxcnc_tp_paused" };
|
||||
const before = this.read().state;
|
||||
const result = call(this.core, "sim_linuxcnc_tp_step", "number", ["number"], [this.planner]);
|
||||
if (result !== 0) return { status: "error", code: `linuxcnc_tp_step_${result}` };
|
||||
const state = this.read();
|
||||
let state;
|
||||
try {
|
||||
state = this.read();
|
||||
if (this.kinematics && state.status === "verified") {
|
||||
const checked = this.kinematics.forward(state.state.joint_commanded);
|
||||
if (checked.status !== "verified") throw new Error(checked.code);
|
||||
}
|
||||
} catch (error) {
|
||||
// Native IK/FK rejects a physical joint limit. Keep the last valid
|
||||
// servo position, discard the unsafe remainder, and permit retreat.
|
||||
if (!String(error?.message).endsWith("_-702")) throw error;
|
||||
call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]);
|
||||
this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null;
|
||||
this.executionState = 3;
|
||||
const stopped = this.setPosition(before.position, before.active_tag);
|
||||
this.kinematics.forward(stopped.state.joint_commanded);
|
||||
throw new Error("motion_joint_limit");
|
||||
}
|
||||
if (state.status === "verified") {
|
||||
const targetJoints = this.commandedJoints(state.state.position, state.state.active_tag);
|
||||
state.state.joint_commanded = targetJoints;
|
||||
|
||||
Reference in New Issue
Block a user