Add AC/BC RTCP programs and 3D machine manager

This commit is contained in:
2026-09-20 12:31:12 +08:00
parent 1c1e5f9672
commit 8aab531b02
363 changed files with 53213 additions and 58 deletions

View 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;