Configure single-DOF machine joints and drive them from WASM axes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,28 +1,28 @@
|
||||
import { useEffect, useMemo, 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 { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
|
||||
import { configureMachinePartScene, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
|
||||
import { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
|
||||
import { driveJoint, jointZero, defaultDof, isLinear, type JointDof, type CncAxis } from "./machine-joints";
|
||||
import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||
type Props = { snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||
type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||
type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] };
|
||||
const zero3 = (): Vec3 => [0, 0, 0];
|
||||
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
|
||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode, testPose, onTransform, onMotionState }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; mode: "translate" | "rotate"; testPose: TestPose; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void; onMotionState: (state: Record<string, MotionTransform>) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, mode: "translate" | "rotate", testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record<string, MotionTransform>) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
||||
const host = useRef<PreviewHost>(null);
|
||||
const stateRef = useRef({ parts, selectedId, selectedNodePath, mode, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, mode, testPose, coordinates };
|
||||
const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform;
|
||||
const stateRef = useRef({ parts, selectedId, selectedNodePath, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, testPose, coordinates };
|
||||
const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState;
|
||||
const [fault, setFault] = useState("");
|
||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, mode, testPose, coordinates); }, [parts, selectedId, selectedNodePath, mode, testPose, coordinates]);
|
||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]);
|
||||
useEffect(() => {
|
||||
const element = host.current; if (!element) return;
|
||||
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
|
||||
@@ -43,27 +43,21 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.up.set(0, 0, 1); camera.position.set(2.4, -2.8, 1.8);
|
||||
const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true;
|
||||
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
|
||||
let dofNodes: Array<{ object: THREE.Object3D; zero: ReturnType<typeof jointZero>; dof: JointDof }> = [];
|
||||
type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion };
|
||||
let nodes = new Map<string, THREE.Group>(), visualNodes = new Map<string, Map<string, THREE.Object3D>>(), roleNodes = new Map<MachinePartRole, RoleNode>(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedJointObject: THREE.Object3D | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0;
|
||||
let nodes = new Map<string, THREE.Group>(), visualNodes = new Map<string, Map<string, THREE.Object3D>>(), roleNodes = new Map<MachinePartRole, RoleNode>(), helpers: THREE.AxesHelper[] = [], selectedJointObject: THREE.Object3D | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0;
|
||||
const selectedJointAxes = new THREE.Group(); selectedJointAxes.name = "selected-joint-local-coordinate-system"; selectedJointAxes.visible = false;
|
||||
const jointAxis = (direction: THREE.Vector3, color: number) => selectedJointAxes.add(new THREE.ArrowHelper(direction, new THREE.Vector3(), .28, color, .07, .045));
|
||||
jointAxis(new THREE.Vector3(1, 0, 0), 0xff3030); jointAxis(new THREE.Vector3(0, 1, 0), 0x35d05b); jointAxis(new THREE.Vector3(0, 0, 1), 0x327cff); scene.add(selectedJointAxes);
|
||||
const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper());
|
||||
transform.addEventListener("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never);
|
||||
transform.addEventListener("objectChange", (() => {
|
||||
if (!selectedPartId || !selectedObject) return;
|
||||
onTransformRef.current(selectedPartId, selectedObject.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selectedObject.rotation.x), THREE.MathUtils.radToDeg(selectedObject.rotation.y), THREE.MathUtils.radToDeg(selectedObject.rotation.z)]);
|
||||
}) as never);
|
||||
const draw = () => {
|
||||
if (contextLost || !renderable) return;
|
||||
renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera);
|
||||
element.dataset.machineManagerRendered = "true"; element.dataset.machineManagerRenderState = "rendered"; dirty = false;
|
||||
};
|
||||
element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextMode, nextPose, nextCoordinates) => {
|
||||
transform.setMode(nextMode);
|
||||
element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextPose, nextCoordinates) => {
|
||||
if (nextParts !== lastParts) {
|
||||
delete element.dataset.machineManagerRendered; element.dataset.machineManagerRenderState = "syncing-scene";
|
||||
transform.detach(); selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map();
|
||||
selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; dofNodes = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map();
|
||||
for (const part of nextParts) {
|
||||
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);
|
||||
// Clone transforms/hierarchy only. Geometry, materials, textures and
|
||||
@@ -71,6 +65,9 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
||||
const { scene: visual, nodes: pathNodes } = configureMachinePartScene(part.scene.clone(true), part);
|
||||
const internalRoles = Object.entries(part.nodeRoles ?? {}).map(([path, role]) => ({ object: pathNodes.get(path) ?? null, role }));
|
||||
visual.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); node.add(visual); visualNodes.set(part.id, pathNodes);
|
||||
const rootDof = draftNodeDof(part, "scene", nextCoordinates);
|
||||
if (rootDof) dofNodes.push({ object: node, zero: jointZero(node), dof: rootDof });
|
||||
for (const [path, object] of pathNodes) { const dof = path === "scene" ? null : draftNodeDof(part, path, nextCoordinates); if (dof) dofNodes.push({ object, zero: jointZero(object), dof }); }
|
||||
const axes = new THREE.AxesHelper(.12); helpers.push(axes); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node);
|
||||
if (part.role) roleNodes.set(part.role, { object: node, position: node.position.clone(), quaternion: node.quaternion.clone() });
|
||||
for (const binding of internalRoles) if (binding.object && binding.role) roleNodes.set(binding.role, { object: binding.object, position: binding.object.position.clone(), quaternion: binding.object.quaternion.clone() });
|
||||
@@ -85,17 +82,15 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
||||
}
|
||||
lastParts = nextParts;
|
||||
}
|
||||
selectedPartId = nextSelectedId; selectedObject = nextSelectedId && nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject);
|
||||
selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = nextNodePath === "scene" ? "part-root" : "none-internal-joint"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; }
|
||||
for (const binding of roleNodes.values()) { binding.object.position.copy(binding.position); binding.object.quaternion.copy(binding.quaternion); }
|
||||
const translate = (role: MachinePartRole, axis: "x" | "y" | "z", value: number) => { const binding = roleNodes.get(role); if (binding) { const direction = new THREE.Vector3(axis === "x" ? 1 : 0, axis === "y" ? 1 : 0, axis === "z" ? 1 : 0).applyQuaternion(binding.quaternion); binding.object.position.addScaledVector(direction, value / 1000); } };
|
||||
const rotate = (role: MachinePartRole, axis: THREE.Vector3, degrees: number) => { const binding = roleNodes.get(role); if (binding) binding.object.quaternion.copy(binding.quaternion).multiply(new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(degrees))); };
|
||||
translate("x", "x", nextPose.x); translate("y", "y", nextPose.y); translate("z", "z", nextPose.z); rotate("tilt", nextCoordinates === "XYZBC" ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0), nextCoordinates === "XYZBC" ? nextPose.b : nextPose.a); rotate("c", new THREE.Vector3(0, 0, 1), nextPose.c);
|
||||
root.updateMatrixWorld(true); let objectCount = 0, meshCount = 0; root.traverse((object) => { let cursor: THREE.Object3D | null = object, isJointHelper = false; while (cursor) { if (cursor === selectedJointAxes) { isJointHelper = true; break; } cursor = cursor.parent; } if (!isJointHelper) { objectCount += 1; if ((object as THREE.Mesh).isMesh) meshCount += 1; } }); const transforms = Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }])); element.dataset.machineManagerSceneObjects = String(objectCount); element.dataset.machineManagerSceneMeshes = String(meshCount); element.dataset.machineManagerRoleTransforms = JSON.stringify(transforms); if (selectedJointObject) { element.dataset.machineManagerSelectedJointWorldOrigin = JSON.stringify(selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray()); element.dataset.machineManagerSelectedJointWorldQuaternion = JSON.stringify(selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray()); element.dataset.machineManagerSelectedJointAxesScale = selectedAxesScale.toFixed(6); } const selectedState = selectedJointObject ? { local: selectedJointObject.position.toArray(), localQuaternion: selectedJointObject.quaternion.toArray(), world: selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray() } : null;
|
||||
|
||||
selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = "single-dof-input"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; }
|
||||
for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase() as keyof TestPose]);
|
||||
root.updateMatrixWorld(true); let objectCount = 0, meshCount = 0; root.traverse((object) => { let cursor: THREE.Object3D | null = object, isJointHelper = false; while (cursor) { if (cursor === selectedJointAxes) { isJointHelper = true; break; } cursor = cursor.parent; } if (!isJointHelper) { objectCount += 1; if ((object as THREE.Mesh).isMesh) meshCount += 1; } }); const transforms = Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }])); element.dataset.machineManagerSceneObjects = String(objectCount); element.dataset.machineManagerSceneMeshes = String(meshCount); element.dataset.machineManagerRoleTransforms = JSON.stringify(transforms);
|
||||
element.dataset.machineManagerJointTransforms = JSON.stringify(Object.fromEntries(dofNodes.map(({ object, dof }) => [dof.axis, { localMm: object.position.toArray().map((v) => v * 1000), quaternion: object.quaternion.toArray(), world: object.getWorldPosition(new THREE.Vector3()).toArray() }]))); if (selectedJointObject) { element.dataset.machineManagerSelectedJointWorldOrigin = JSON.stringify(selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray()); element.dataset.machineManagerSelectedJointWorldQuaternion = JSON.stringify(selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray()); element.dataset.machineManagerSelectedJointAxesScale = selectedAxesScale.toFixed(6); } const selectedState = selectedJointObject ? { local: selectedJointObject.position.toArray(), localQuaternion: selectedJointObject.quaternion.toArray(), world: selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray() } : null;
|
||||
element.dataset.machineManagerSelectedTransform = JSON.stringify(selectedState);
|
||||
onMotionStateRef.current(selectedState ? { ...transforms, selected: selectedState } : transforms); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; draw();
|
||||
};
|
||||
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.mode, stateRef.current.testPose, stateRef.current.coordinates);
|
||||
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.testPose, stateRef.current.coordinates);
|
||||
const resize = () => {
|
||||
const width = element.clientWidth, height = element.clientHeight;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; }
|
||||
@@ -109,28 +104,28 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
||||
const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; setFault("WebGL 上下文已丢失,请关闭并重新打开三维机床管理器"); };
|
||||
renderer.domElement.addEventListener("webglcontextlost", contextLostHandler);
|
||||
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
||||
orbit.addEventListener("change", () => { dirty = true; }); transform.addEventListener("change", () => { dirty = true; });
|
||||
orbit.addEventListener("change", () => { dirty = true; });
|
||||
const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render();
|
||||
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
|
||||
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
|
||||
}, []);
|
||||
return <div className="machine-manager-preview-shell"><div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && <output className="machine-manager-webgl-fault" role="alert">{fault}</output>}</div>;
|
||||
return <div className="machine-manager-preview-shell"><div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && <output className="machine-manager-webgl-fault" role="alert">{fault}</output>}</div>;
|
||||
}
|
||||
|
||||
function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode }) {
|
||||
const key = `${part.id}:${node.path}`;
|
||||
const [expanded, setExpanded] = useState(node.path === "scene");
|
||||
const hasChildren = node.children.length > 0 || Boolean(extra);
|
||||
const binding = node.path === "scene" ? part.role : part.nodeRoles?.[node.path];
|
||||
const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]);
|
||||
return <li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} data-machine-tree-part={part.id} data-machine-tree-path={node.path} data-machine-tree-parent={node.path === "scene" ? part.parentId ?? "root" : Object.hasOwn(part.nodeParents, node.path) ? part.nodeParents[node.path] ?? "scene" : "source"}>
|
||||
<div className="machine-tree-row"><button type="button" className="machine-tree-toggle" aria-label={`${expanded ? "折叠" : "展开"} ${node.name}`} aria-expanded={expanded} disabled={!hasChildren} onClick={() => setExpanded((value) => !value)}>{hasChildren ? expanded ? "▾" : "▸" : "·"}</button><button type="button" draggable data-axis-control="machine-tree-node" className={selected === key ? "selected" : ""} onClick={() => onSelect(part.id, node.path)} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: part.id, path: node.path })); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => { event.preventDefault(); const raw = event.dataTransfer.getData("application/x-linuxcnc-machine-tree"); if (raw) onDrop(JSON.parse(raw), { partId: part.id, path: node.path }); }}><span className={node.type}>{node.name}</span>{binding && <em>{binding.toUpperCase()}</em>}<small>{node.originMm.map((value) => Number(value.toFixed(3))).join(", ")}</small></button></div>
|
||||
{hasChildren && <ul role="group" hidden={!expanded}>{node.children.map((child) => <TreeNode key={child.path} part={part} node={child} selected={selected} onSelect={onSelect} onDrop={onDrop} />)}{extra}</ul>}
|
||||
</li>;
|
||||
}
|
||||
|
||||
export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }: 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 [selectedNodePath, setSelectedNodePath] = useState("scene"); const [mode, setMode] = useState<"translate" | "rotate">("translate");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene");
|
||||
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);
|
||||
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
|
||||
@@ -143,30 +138,27 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : 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, control?: string) => <fieldset className="machine-vector" data-axis-control={control}><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 updateSelectedTransform = (field: "translationMm" | "rotationDeg", value: Vec3) => {
|
||||
if (!selected || !selectedTreeNode) return;
|
||||
if (selectedNodePath === "scene") { update(selected.id, { [field]: value }); return; }
|
||||
setParts((current) => current.map((part) => {
|
||||
if (part.id !== selected.id) return part;
|
||||
const currentTransform = part.nodeTransforms?.[selectedNodePath] ?? { translationMm: selectedTreeNode!.originMm, rotationDeg: selectedTreeNode!.rotationDeg };
|
||||
return { ...part, nodeTransforms: { ...(part.nodeTransforms ?? {}), [selectedNodePath]: { ...currentTransform, [field]: value } } };
|
||||
}));
|
||||
};
|
||||
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)));
|
||||
if (loaded.some((source) => source.restoredModel && source.restoredModel.coordinates !== coordinates)) {
|
||||
loaded.forEach((source) => disposeMachineScene(source.scene));
|
||||
throw new Error(`导入机型不同,请先切换到 ${loaded.find((source) => source.restoredModel?.coordinates !== coordinates && source.restoredModel)?.restoredModel?.coordinates}`);
|
||||
}
|
||||
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(), nodeParents: {}, nodeRoles: {}, nodeTransforms: {} });
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(`已导入 ${loaded.length} 个部件`);
|
||||
const restored = loaded.find((source) => source.restoredModel)?.restoredModel;
|
||||
if (restored) { setCOriginMm(restored.calibration.cOriginMm); setTiltOffsetMm(restored.calibration.tiltOffsetMm); setPhysicalZero(restored.calibration.physicalZero as PhysicalZero); setMessage(`已恢复 ${restored.coordinates} 单自由度配置`); }
|
||||
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已恢复 ${restored.coordinates} 关节配置和物理零点` : `已导入 ${loaded.length} 个部件`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
@@ -199,7 +191,9 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
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 exportScene = new THREE.Scene(); exportScene.name = machine.scene.name; exportScene.userData = machine.scene.userData;
|
||||
for (const child of machine.scene.children) exportScene.add(child.clone(true));
|
||||
const bytes = await new GLTFExporter().parseAsync(exportScene, { 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");
|
||||
@@ -207,23 +201,28 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
||||
};
|
||||
const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => {
|
||||
if (selectedNodePath !== "scene") { update(part.id, { nodeRoles: { ...part.nodeRoles, [selectedNodePath]: role } }); return; }
|
||||
const parentRole = role ? expectedParentRole(role) : null;
|
||||
update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null });
|
||||
};
|
||||
const selectedBinding = selected ? selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? "" : "";
|
||||
const assignTreeRole = (role: MachinePartRole | "") => {
|
||||
const selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null;
|
||||
const setDof = (dof: JointDof | null) => {
|
||||
if (!selected) return;
|
||||
setParts((current) => current.map((part) => {
|
||||
const nodeRoles = Object.fromEntries(Object.entries(part.nodeRoles ?? {}).filter(([, value]) => !role || value !== role));
|
||||
let partRole = role && part.role === role ? "" : part.role;
|
||||
if (part.id === selected.id) {
|
||||
if (selectedNodePath === "scene") partRole = role;
|
||||
else { delete nodeRoles[selectedNodePath]; if (role) nodeRoles[selectedNodePath] = role; }
|
||||
}
|
||||
return { ...part, role: partRole, nodeRoles } as MachinePartDraft;
|
||||
}));
|
||||
if (dof && parts.some((part) => Object.entries(part.nodeJoints ?? {}).some(([path, existing]) => existing.axis === dof.axis && (part.id !== selected.id || path !== selectedNodePath)))) { setMessage(`${dof.axis} 轴已绑定其他节点,请先取消该绑定`); return; }
|
||||
setParts((current) => current.map((part) => { if (part.id !== selected.id) return part; const nodeJoints = { ...(part.nodeJoints ?? {}) }; if (dof) nodeJoints[selectedNodePath] = dof; else delete nodeJoints[selectedNodePath]; return { ...part, nodeJoints }; }));
|
||||
setMessage("");
|
||||
};
|
||||
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<input data-axis-control={`machine-test-${axis}`} type="number" step="any" value={testPose[axis]} onChange={(event) => setTestPose({ ...testPose, [axis]: number(event.target.value) })} /><small>{unit}</small></label>;
|
||||
const [followLive, setFollowLive] = useState(false);
|
||||
const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]);
|
||||
const liveReady = visual.authoritative && visual.machineModel?.coordinate_system === coordinates;
|
||||
const effectivePose: TestPose = followLive && liveReady ? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - physicalZero[axis.toLowerCase() as keyof PhysicalZero]])) } : testPose;
|
||||
const validation = useMemo(() => { try { const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]);
|
||||
const exportJson = () => {
|
||||
if (!validation.model) return;
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(validation.model, null, 2)], { type: "application/json" }));
|
||||
const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<input data-axis-control={`machine-test-${axis}`} type="number" step="any" disabled={followLive || !parts.some((part) => Object.values(part.nodeJoints ?? {}).some((joint) => joint.axis.toLowerCase() === axis))} value={effectivePose[axis]} onChange={(event) => setTestPose({ ...testPose, [axis]: number(event.target.value) })} /><small>{unit}</small></label>;
|
||||
const receiveMotionState = (state: Record<string, MotionTransform>) => { const hash = JSON.stringify(state); if (hash !== motionStateHash.current) { motionStateHash.current = hash; setMotionState(state); } };
|
||||
const selectedDriven = motionState.selected ?? null;
|
||||
const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?.[selectedNodePath] ?? (selectedTreeNode ? { translationMm: selectedTreeNode.originMm, rotationDeg: selectedTreeNode.rotationDeg } : null);
|
||||
@@ -231,18 +230,21 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
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-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><span>{parts.length} 个导入模型 · 每个运动节点一个自由度</span></div>
|
||||
<div className="machine-manager-layout">
|
||||
{open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} mode={mode} testPose={testPose} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} onMotionState={receiveMotionState} />}
|
||||
{open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />}
|
||||
<div className="machine-manager-editor">
|
||||
<section className="machine-part-list"><h3>部件与层级</h3>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
|
||||
{selected && <section className="machine-part-editor"><header><h3>{selected.filename}</h3><button type="button" data-axis-control="machine-manager-delete" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div>}<label>轴绑定<select data-axis-control="machine-tree-axis-role" value={selectedBinding} onChange={(event) => assignTreeRole(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-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>{selectedNodePath === "scene" && vectorInput("导入文件装配原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm", "machine-manager-origin")}
|
||||
{selectedTransform && <><p className="machine-transform-reference">编辑对象:{selectedTreeNode?.name}。数值为相对父级的位置和 XYZ 欧拉角;旋转中心为本节点原点。</p>{vectorInput("部件平移(相对父级位置)", selectedTransform.translationMm, (value) => updateSelectedTransform("translationMm", value), "mm", "machine-manager-translation")}{vectorInput("部件旋转(相对父级姿态)", selectedTransform.rotationDeg, (value) => updateSelectedTransform("rotationDeg", value), "deg", "machine-manager-rotation")}</>}</section>}
|
||||
{selected && <section className="machine-part-editor"><header><h3>{selected.filename}</h3><button type="button" data-axis-control="machine-manager-delete" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{[..."XYZABC"].map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {"XYZ".includes(axis) ? "平移 mm" : "旋转 deg"}</option>)}</select></label>
|
||||
{selectedDof && <><label>父级运动方向<select data-axis-control="machine-joint-direction" value={selectedDof.direction} onChange={(event) => setDof({ ...selectedDof, direction: event.target.value as "x" | "y" | "z" })}>{["x", "y", "z"].map((axis) => <option key={axis} value={axis}>{axis.toUpperCase()}</option>)}</select></label><label>正负方向<select data-axis-control="machine-joint-sign" value={selectedDof.sign} onChange={(event) => setDof({ ...selectedDof, sign: Number(event.target.value) as 1 | -1 })}><option value="1">正向 +1</option><option value="-1">反向 −1</option></select></label></>}
|
||||
<label>节点结构角色<select data-axis-control="machine-manager-role" value={selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? ""} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><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><p className="machine-transform-reference">当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}</p>
|
||||
<label>{selectedDof ? `${selectedDof.axis} ${isLinear(selectedDof.axis) ? "位移 (mm)" : "转角 (deg)"}` : "关节值"}<input data-axis-control="machine-joint-value" type="number" step="any" disabled={!selectedDof || followLive} value={selectedDof ? effectivePose[selectedDof.axis.toLowerCase() as keyof TestPose] : 0} onChange={(event) => { if (selectedDof) setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: number(event.target.value) })); }} /></label>
|
||||
</section>}
|
||||
</div>
|
||||
</div>
|
||||
<section className="machine-motion-test"><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><output data-axis-control="machine-test-state">{JSON.stringify(testPose)}</output></section>
|
||||
<section className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={!liveReady} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "等待有效机床数据" : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={followLive} onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></section>
|
||||
<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>
|
||||
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || Boolean(validation.error)} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || Boolean(validation.error)} onClick={() => void exportGlb()}>导出组合 GLB</button><button type="button" data-axis-control="machine-manager-export-json" disabled={!validation.model} onClick={exportJson}>导出关节 JSON</button><output role="status">{message || validation.error || (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>;
|
||||
|
||||
@@ -26,11 +26,12 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
const managedJoints = Boolean(importedMachine?.scene.userData.linuxcncJointModel);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: !managedJoints, alpha: false, preserveDrawingBuffer: !managedJoints });
|
||||
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.enabled = !managedJoints;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
||||
element.appendChild(renderer.domElement);
|
||||
@@ -151,6 +152,11 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
const render = () => {
|
||||
const state = visualRef.current, config = settings.current;
|
||||
assembly.update(state);
|
||||
if (assembly.machine.userData.linuxcncJointState) {
|
||||
element.dataset.machineJointState = JSON.stringify(assembly.machine.userData.linuxcncJointState);
|
||||
element.dataset.machineJointTick = String(state.tick);
|
||||
element.dataset.machineJointInputs = JSON.stringify(state.joints);
|
||||
}
|
||||
const paths = (config.toolpath ?? state.toolpath) as Segment[];
|
||||
pathGroup.position.copy(assembly.workOrigin.position);
|
||||
liveGroup.position.copy(assembly.workOrigin.position);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
import { bindJointModel, validateJointModel, type JointModel, type AxisValues } from "./machine-joints";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
|
||||
@@ -18,7 +19,14 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
|
||||
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 };
|
||||
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||
if (model) {
|
||||
validateJointModel(model); if (model.coordinates !== structure.coordinates) throw new Error("关节 JSON 与 URDF 机型不一致");
|
||||
bindJointModel(gltf.scene, model);
|
||||
}
|
||||
const physicalZero = model?.calibration.physicalZero ?? gltf.scene.userData.linuxcncMachineGeometry?.physicalZero;
|
||||
if (physicalZero && Object.values(physicalZero).some((value) => typeof value !== "number" || !Number.isFinite(value))) throw new Error("物理零点无效");
|
||||
return { scene: gltf.scene, structure, hash, filename, physicalZero };
|
||||
}
|
||||
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record<string, string> }, structure: MachineStructure) {
|
||||
const config = readTrtConfiguration(bundle);
|
||||
@@ -68,11 +76,22 @@ export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: {
|
||||
export function buildImportedMachine(asset: ImportedMachine) {
|
||||
const scene = asset.scene.clone(true);
|
||||
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
|
||||
const jointModel = scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
|
||||
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
|
||||
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;
|
||||
if (jointModel) {
|
||||
const effectiveJoints: JointModel = { ...jointModel, calibration: { cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3, physicalZero: { ...physicalZero } } };
|
||||
scene.userData.linuxcncJointModel = effectiveJoints;
|
||||
scene.traverse((node) => {
|
||||
if (node.userData.linuxcncJointModel) node.userData.linuxcncJointModel = effectiveJoints;
|
||||
if (node.userData.linuxcncMachineGeometry) node.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
|
||||
@@ -98,11 +117,12 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
||||
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);
|
||||
const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(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);
|
||||
}
|
||||
if (jointDriver) jointDriver.update(Object.fromEntries([...asset.structure.coordinates].map((axis, i) => [axis, state.joints[i]])) as AxisValues, physicalZero);
|
||||
const length = state.offsets.tool_offset / 1000;
|
||||
toolTip.position.set(0, 0, -length);
|
||||
cutter.visible = Math.abs(length) > 1e-9; cutter.scale.y = Math.abs(length); cutter.position.z = -length / 2;
|
||||
@@ -110,6 +130,7 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
||||
// the master-tool difference along local C-Z, while physical pivots stay fixed.
|
||||
workOrigin.position.copy(originalWork); workOrigin.position.z -= length;
|
||||
machine.updateMatrixWorld(true);
|
||||
if (jointDriver) machine.userData.linuxcncJointState = jointDriver.state();
|
||||
};
|
||||
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
|
||||
}
|
||||
|
||||
88
web/react/src/machine-joints.ts
Normal file
88
web/react/src/machine-joints.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as THREE from "three";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
export type CncAxis = "X" | "Y" | "Z" | "A" | "B" | "C";
|
||||
export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z"; sign: 1 | -1 };
|
||||
export type AxisValues = Partial<Record<CncAxis, number>>;
|
||||
export type JointZero = { positionMm: Vec3; quaternion: [number, number, number, number]; scale: Vec3 };
|
||||
export type JointRecord = {
|
||||
id: string; parentId: string | null; name: string; role: string;
|
||||
source: { file: string; hash: string; path: string };
|
||||
zero: JointZero;
|
||||
joint: (JointDof & { dof: 1; type: "prismatic" | "revolute"; unit: "mm" | "deg"; frame: "parent" }) | null;
|
||||
};
|
||||
export type JointModel = {
|
||||
schema: "linuxcnc.joint-model/1";
|
||||
coordinates: "XYZAC" | "XYZBC";
|
||||
units: { length: "mm"; angle: "deg"; gltfLength: "m" };
|
||||
controller: { source: "linuxcnc-wasm.visual.joints"; axisOrder: CncAxis[] };
|
||||
calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: Record<string, number> };
|
||||
axisMap: Partial<Record<CncAxis, string>>;
|
||||
nodes: JointRecord[];
|
||||
};
|
||||
export const JOINT_MODEL_SCHEMA = "linuxcnc.joint-model/1";
|
||||
export const isLinear = (axis: CncAxis) => "XYZ".includes(axis);
|
||||
export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z" } as const)[axis], sign: 1 });
|
||||
export const jointZero = (node: THREE.Object3D): JointZero => ({ positionMm: node.position.toArray().map((v) => v * 1000) as Vec3, quaternion: node.quaternion.toArray(), scale: node.scale.toArray() });
|
||||
|
||||
// Absolute evaluation from a stored zero pose: no incremental accumulation.
|
||||
// Direction is expressed in the parent frame; a rotation leaves the pivot at p0.
|
||||
export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | null, value: number, physicalZero = 0) {
|
||||
if (dof && ![value, physicalZero].every(Number.isFinite)) throw new Error(`轴 ${dof.axis} 位置无效`);
|
||||
node.position.fromArray(zero.positionMm).multiplyScalar(.001);
|
||||
node.quaternion.fromArray(zero.quaternion); node.scale.fromArray(zero.scale);
|
||||
if (dof) {
|
||||
const direction = new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
|
||||
const q = dof.sign * (value - physicalZero);
|
||||
if (isLinear(dof.axis)) node.position.addScaledVector(direction, q / 1000);
|
||||
else node.quaternion.premultiply(new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q)));
|
||||
}
|
||||
node.updateMatrix();
|
||||
}
|
||||
|
||||
export function validateJointModel(model: JointModel, complete = true) {
|
||||
const fail = (message: string): never => { throw new Error(`关节配置无效:${message}`); };
|
||||
if (!model || model.schema !== JOINT_MODEL_SCHEMA || !["XYZAC", "XYZBC"].includes(model.coordinates)) fail("schema 或机型错误");
|
||||
if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints" || model.controller.axisOrder.join("") !== model.coordinates) fail("控制器来源、单位或轴顺序错误");
|
||||
if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
|
||||
const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
|
||||
if (!finite(model.calibration?.cOriginMm, 3) || !finite(model.calibration?.tiltOffsetMm, 3) || !model.calibration?.physicalZero || ![...model.coordinates].every((a) => Number.isFinite(model.calibration.physicalZero[a.toLowerCase()]))) fail("标定参数错误");
|
||||
const nodes = new Map<string, JointRecord>(), axes = new Map<CncAxis, string>();
|
||||
for (const record of model.nodes) {
|
||||
if (!record.id || nodes.has(record.id)) fail("节点 ID 缺失或重复");
|
||||
nodes.set(record.id, record);
|
||||
if (!finite(record.zero?.positionMm, 3) || !finite(record.zero?.quaternion, 4) || !finite(record.zero?.scale, 3) || record.zero.scale.some((v) => v <= 0) || Math.abs(record.zero.quaternion.reduce((s, v) => s + v * v, 0) - 1) > 1e-5) fail(`${record.name} 零姿态错误`);
|
||||
const joint = record.joint;
|
||||
if (joint) {
|
||||
if (![...model.coordinates].includes(joint.axis) || joint.dof !== 1 || !["x", "y", "z"].includes(joint.direction) || ![1, -1].includes(joint.sign) || joint.frame !== "parent" || joint.type !== (isLinear(joint.axis) ? "prismatic" : "revolute") || joint.unit !== (isLinear(joint.axis) ? "mm" : "deg")) fail(`${record.name} 单自由度错误`);
|
||||
if (axes.has(joint.axis)) fail(`${joint.axis} 轴重复绑定`);
|
||||
axes.set(joint.axis, record.id);
|
||||
}
|
||||
}
|
||||
for (const record of model.nodes) {
|
||||
const seen = new Set<string>([record.id]); let parent = record.parentId;
|
||||
while (parent !== null) { if (seen.has(parent) || !nodes.has(parent)) fail("父级不存在或层级循环"); seen.add(parent); parent = nodes.get(parent)!.parentId; }
|
||||
}
|
||||
if (!model.axisMap || Object.keys(model.axisMap).length !== axes.size) fail("轴索引错误");
|
||||
for (const [axis, id] of axes) if (model.axisMap[axis] !== id) fail(`${axis} 轴索引错误`);
|
||||
if (complete && [...model.coordinates].some((axis) => !axes.has(axis as CncAxis))) fail(`请完整绑定 ${model.coordinates} 的五个关节`);
|
||||
return model;
|
||||
}
|
||||
|
||||
export function bindJointModel(scene: THREE.Object3D, model: JointModel) {
|
||||
validateJointModel(model);
|
||||
const nodes = new Map<string, THREE.Object3D>();
|
||||
scene.traverse((node) => { const id = node.userData.linuxcncNodeId; if (id) { if (nodes.has(id)) throw new Error("关节模型节点 ID 重复"); nodes.set(id, node); } });
|
||||
const bindings = model.nodes.map((record) => {
|
||||
const node = nodes.get(record.id);
|
||||
if (!node || (node.parent?.userData.linuxcncNodeId ?? null) !== record.parentId) throw new Error(`关节节点 ${record.name} 与 JSON 层级不一致`);
|
||||
return { node, record };
|
||||
});
|
||||
return {
|
||||
update(values: AxisValues, physicalZero = model.calibration.physicalZero) {
|
||||
for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[record.joint.axis]) || !Number.isFinite(physicalZero[record.joint.axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${record.joint.axis} 轴实时位置或物理零点无效`);
|
||||
for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint, record.joint ? values[record.joint.axis]! : 0, record.joint ? Number(physicalZero[record.joint.axis.toLowerCase()] ?? 0) : 0);
|
||||
},
|
||||
state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [record.joint!.axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||
import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
|
||||
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 MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record<string, JointDof>; restoredRoles?: Record<string, MachinePartRole | "">; restoredModel?: JointModel };
|
||||
export type MachineNodeTransform = { translationMm: Vec3; rotationDeg: Vec3 };
|
||||
export type MachinePartDraft = MachinePartSource & {
|
||||
role: MachinePartRole | "";
|
||||
@@ -17,6 +18,7 @@ export type MachinePartDraft = MachinePartSource & {
|
||||
nodeParents: Record<string, string | null>;
|
||||
nodeRoles: Record<string, MachinePartRole | "">;
|
||||
nodeTransforms: Record<string, MachineNodeTransform>;
|
||||
nodeJoints?: Record<string, JointDof>;
|
||||
};
|
||||
export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; worldOriginMm: Vec3; rotationDeg: Vec3; children: MachineTreeNode[] };
|
||||
export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||
@@ -26,7 +28,7 @@ export type ManagedMachineGeometry = {
|
||||
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; nodeParents: Record<string, string | null>; nodeRoles: Record<string, MachinePartRole | "">; nodeTransforms: Record<string, MachineNodeTransform> }>;
|
||||
parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole | ""; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean; nodeParents: Record<string, string | null>; nodeRoles: Record<string, MachinePartRole | "">; nodeTransforms: Record<string, MachineNodeTransform>; nodeJoints?: Record<string, JointDof> }>;
|
||||
};
|
||||
|
||||
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
|
||||
@@ -130,6 +132,21 @@ export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Pro
|
||||
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; } });
|
||||
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||
if (model) {
|
||||
bindJointModel(gltf.scene, model);
|
||||
const assembly = gltf.scene.getObjectByProperty("name", "joint_model_assembly");
|
||||
if (!assembly?.userData.linuxcncVisualAssembly) throw new Error("缺少已保存的关节装配");
|
||||
const scene = assembly.clone(true) as THREE.Group;
|
||||
const records = new Map(model.nodes.map((record) => [record.id, record]));
|
||||
const restoredJoints: Record<string, JointDof> = {}, restoredRoles: Record<string, MachinePartRole | ""> = {};
|
||||
for (const [path, node] of scenePathMap(scene).nodes) {
|
||||
const record = records.get(node.userData.linuxcncNodeId);
|
||||
if (record?.joint) restoredJoints[path] = { axis: record.joint.axis, direction: record.joint.direction, sign: record.joint.sign };
|
||||
if (record?.role) restoredRoles[path] = record.role as MachinePartRole;
|
||||
}
|
||||
return { id: crypto.randomUUID(), filename, hash, scene, restoredJoints, restoredRoles, restoredModel: model };
|
||||
}
|
||||
return { id: crypto.randomUUID(), filename, hash, scene: gltf.scene };
|
||||
}
|
||||
|
||||
@@ -180,6 +197,7 @@ function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffset
|
||||
}
|
||||
|
||||
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise<ImportedMachine> {
|
||||
if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
|
||||
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);
|
||||
@@ -208,3 +226,69 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
|
||||
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 } };
|
||||
}
|
||||
|
||||
export function draftNodeDof(part: MachinePartDraft, path: string, coordinates: "XYZAC" | "XYZBC"): JointDof | null {
|
||||
if (part.nodeJoints !== undefined) return part.nodeJoints[path] ?? null;
|
||||
const role = path === "scene" ? part.role : part.nodeRoles?.[path];
|
||||
const axis = role === "tilt" ? coordinates[3] : role && ["x", "y", "z", "c"].includes(role) ? role.toUpperCase() : "";
|
||||
return axis ? defaultDof(axis as CncAxis) : null;
|
||||
}
|
||||
|
||||
type ManagedInput = { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||
|
||||
export function makeJointAssembly(input: ManagedInput, deepClone = false) {
|
||||
if (!input.parts.length) throw new Error("请先导入机床模型");
|
||||
const assembly = new THREE.Group(); assembly.name = "joint_model_assembly"; assembly.userData.linuxcncVisualAssembly = true;
|
||||
const placements = new Map<string, THREE.Group>(), records: JointRecord[] = [];
|
||||
for (const part of input.parts) {
|
||||
if (placements.has(part.id)) throw new Error("部件 ID 重复");
|
||||
finite3(part.translationMm, "部件位置"); finite3(part.rotationDeg, "部件姿态"); finite3(part.originMm, "装配原点");
|
||||
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);
|
||||
placements.set(part.id, placement); assembly.add(placement);
|
||||
const { scene, nodes } = configureMachinePartScene(deepClone ? cloneMachinePartScene(part.scene) : part.scene.clone(true), part);
|
||||
scene.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); placement.add(scene);
|
||||
const addRecord = (node: THREE.Object3D, path: string, role: string, dof: JointDof | null) => {
|
||||
const id = `${part.id}:${path}`; node.userData.linuxcncNodeId = id;
|
||||
const joint: JointRecord["joint"] = dof ? { ...dof, dof: 1, type: "XYZ".includes(dof.axis) ? "prismatic" : "revolute", unit: "XYZ".includes(dof.axis) ? "mm" : "deg", frame: "parent" } : null;
|
||||
node.userData.linuxcncJoint = joint;
|
||||
records.push({ id, parentId: null, name: path === "scene" ? part.filename : node.name, role, source: { file: part.filename, hash: part.hash, path }, zero: jointZero(node), joint });
|
||||
};
|
||||
addRecord(placement, "scene", part.role, draftNodeDof(part, "scene", input.coordinates));
|
||||
for (const [path, node] of nodes) addRecord(node, path === "scene" ? "source-scene" : path, part.nodeRoles?.[path] ?? "", path === "scene" ? null : draftNodeDof(part, path, input.coordinates));
|
||||
}
|
||||
for (const part of input.parts) if (part.parentId) {
|
||||
const parent = placements.get(part.parentId); if (!parent) throw new Error("父部件不存在");
|
||||
let cursor: string | null = part.parentId; const seen = new Set([part.id]);
|
||||
while (cursor) { if (seen.has(cursor)) throw new Error("部件层级循环"); seen.add(cursor); cursor = input.parts.find((p) => p.id === cursor)?.parentId ?? null; }
|
||||
parent.add(placements.get(part.id)!);
|
||||
}
|
||||
const recordMap = new Map(records.map((record) => [record.id, record]));
|
||||
assembly.traverse((node) => { const record = recordMap.get(node.userData.linuxcncNodeId); if (record) record.parentId = node.parent?.userData.linuxcncNodeId ?? null; });
|
||||
const model: JointModel = { schema: "linuxcnc.joint-model/1", coordinates: input.coordinates, units: { length: "mm", angle: "deg", gltfLength: "m" }, controller: { source: "linuxcnc-wasm.visual.joints", axisOrder: [...input.coordinates] as CncAxis[] }, calibration: { cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero } }, nodes: records, axisMap: Object.fromEntries(records.filter((r) => r.joint).map((r) => [r.joint!.axis, r.id])) };
|
||||
validateJointModel(model, false);
|
||||
return { assembly, model };
|
||||
}
|
||||
|
||||
async function buildJointManagedMachine(input: ManagedInput): Promise<ImportedMachine> {
|
||||
// Validate using shallow transform clones before allocating deep runtime resources.
|
||||
validateJointModel(makeJointAssembly(input).model);
|
||||
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm), structure = parseMachineUrdf(urdf);
|
||||
const { assembly, model } = makeJointAssembly(input, true);
|
||||
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
|
||||
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
|
||||
scene.userData.linuxcncJointModel = model;
|
||||
const geometry: ManagedMachineGeometry = { schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero }, parts: input.parts.map(({ scene: _scene, restoredJoints: _j, restoredRoles: _r, restoredModel: _m, ...part }) => JSON.parse(JSON.stringify(part))) };
|
||||
scene.userData.linuxcncMachineGeometry = geometry;
|
||||
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, assembly); 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);
|
||||
// The existing URDF frames retain the controller's tool/path reference. Only
|
||||
// the explicit JSON bindings drive the visual assembly, preventing double motion.
|
||||
links.base.userData.linuxcncJointModel = model; links.base.userData.linuxcncMachineGeometry = geometry;
|
||||
const bases = new Set(model.nodes.filter((n) => n.role === "base").map((n) => n.id));
|
||||
assembly.traverse((node) => { if (bases.has(node.userData.linuxcncNodeId)) { node.userData.linuxcncJointModel = model; node.userData.linuxcncMachineGeometry = geometry; } });
|
||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ model, geometry }))))].map((v) => v.toString(16).padStart(2, "0")).join("");
|
||||
return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user