Make 3D machine manager axis controls configuration driven
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { installMachineStudio, MACHINE_STUDIO } from "./machine-studio-lighting";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
|
||||
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 { CNC_AXES, driveJoint, jointZero, defaultDof, isFiveAxisCoordinates, isLinear, type JointDof, type CncAxis } from "./machine-joints";
|
||||
import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
|
||||
import { useMachineDraft, draftFingerprint, changeMachineType } from "./machine-manager-history";
|
||||
import { downloadMachine, listSavedMachines, saveMachineCopy, readMachineCopy, deleteMachineCopy, type SavedMachine } from "./machine-library";
|
||||
@@ -14,21 +14,27 @@ import { machineDocumentFilename } from "./machine-document";
|
||||
import { MachineNumberInput } from "./MachineManagerInputs";
|
||||
import { inspectMachineGlb, MAX_MACHINE_BYTES, type Vec3 } from "./machine-urdf";
|
||||
import { FIVE_AXIS_CASES, FIVE_AXIS_STATUS_LABELS } from "../../workspace/five-axis-machine-catalog.mjs";
|
||||
import { analyzeGlbFiveAxisScene, analysisSummary, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
|
||||
import { MANAGER_SETTINGS, managerAxes, managerProfile, validateManagerCalibration, zeroAxisValues } from "./machine-manager-config";
|
||||
|
||||
type Props = { currentMachine?: ImportedMachine | null; 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 Props = { currentMachine?: ImportedMachine | null; snapshot?: XyzacVisualSource | null; coordinates: string; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||
type TestPose = Record<string, number>;
|
||||
type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] };
|
||||
const zero3 = (): Vec3 => [0, 0, 0];
|
||||
type EditorTab = "structure" | "motion" | "calibration" | "files";
|
||||
type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number };
|
||||
const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—";
|
||||
const zeroPose = (coordinates: string): TestPose => zeroAxisValues(coordinates);
|
||||
const axisUnit = (axis: CncAxis) => isLinear(axis) ? "mm" : "deg";
|
||||
const catalogCoordinateOptions = [...new Set(FIVE_AXIS_CASES.map((item) => item.coordinates))];
|
||||
const supportedCoordinateOptions = new Set(FIVE_AXIS_CASES.filter((item) => item.webWasm === "supported").map((item) => item.coordinates));
|
||||
|
||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, command }: { command: PreviewCommand; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record<string, MotionTransform>) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, onSelectModel, command }: { command: PreviewCommand; coordinates: string; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record<string, MotionTransform>) => void; onSelectModel: (partId: string) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: string) => void };
|
||||
const host = useRef<PreviewHost>(null);
|
||||
const stateRef = useRef({ parts, selectedId, selectedNodePath, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, testPose, coordinates };
|
||||
const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState;
|
||||
const onSelectModelRef = useRef(onSelectModel); onSelectModelRef.current = onSelectModel;
|
||||
const [fault, setFault] = useState("");
|
||||
useEffect(() => { host.current?.machineManagerView?.(command.action); }, [command]);
|
||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]);
|
||||
@@ -37,21 +43,51 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
|
||||
// limits when DevTools or display scaling changes the drawing-buffer size.
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, depth: true, stencil: false, powerPreference: "high-performance" });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = false; element.appendChild(renderer.domElement);
|
||||
renderer.shadowMap.enabled = false; element.appendChild(renderer.domElement);
|
||||
const gl = renderer.getContext(), viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array;
|
||||
const maxDimension = Math.max(1, Math.floor(Math.min(Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 4096), Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096), Number(viewportLimit?.[0] || 4096), Number(viewportLimit?.[1] || 4096)) * .9));
|
||||
const maxPixels = Math.min(maxDimension * maxDimension, 4096 * 4096);
|
||||
element.dataset.machineManagerWebglAntialias = String(Boolean(gl.getContextAttributes()?.antialias)); element.dataset.machineManagerMaxBuffer = String(maxDimension);
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x263438);
|
||||
const studio = new RoomEnvironment(); const pmrem = new THREE.PMREMGenerator(renderer); const environment = pmrem.fromScene(studio, .04); scene.environment = environment.texture; scene.environmentIntensity = 1.25; studio.dispose(); pmrem.dispose();
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x728083, 2.6)); scene.add(new THREE.AmbientLight(0xffffff, .65));
|
||||
const light = new THREE.DirectionalLight(0xfff5e5, 4.2); light.position.set(3, 5, 4); light.castShadow = true; scene.add(light);
|
||||
const fill = new THREE.DirectionalLight(0xdcefff, 2.4); fill.position.set(-4, 2, 3); scene.add(fill);
|
||||
const rim = new THREE.DirectionalLight(0xffffff, 1.8); rim.position.set(2, 4, -5); scene.add(rim);
|
||||
const studio = installMachineStudio(renderer, scene, "z");
|
||||
const grid = new THREE.GridHelper(4, 40, 0x8eb0b2, 0x466266); grid.name = "machine-xy-ground-grid-z-up"; grid.rotation.x = Math.PI / 2; scene.add(grid);
|
||||
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);
|
||||
const selectionBox = new THREE.Box3();
|
||||
const selectionHighlight = new THREE.Box3Helper(selectionBox, 0xffc857);
|
||||
selectionHighlight.name = "selected-import-outline"; selectionHighlight.visible = false; scene.add(selectionHighlight);
|
||||
// Pick only imported geometry; exclude the grid, axes and selection helpers.
|
||||
const owners = new Map<THREE.Object3D, string>();
|
||||
let pickable: THREE.Object3D[] = [];
|
||||
const raycaster = new THREE.Raycaster();
|
||||
let pointer: { id: number; x: number; y: number; dragged: boolean } | null = null;
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
if (pointer) { pointer.dragged = true; return; }
|
||||
if (event.button === 0 && event.isPrimary) pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false };
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
if (pointer?.id === event.pointerId && Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) > 5) pointer.dragged = true;
|
||||
};
|
||||
const pointerCancel = () => { pointer = null; };
|
||||
const pointerUp = (event: PointerEvent) => {
|
||||
const start = pointer; pointer = null;
|
||||
if (!start || start.id !== event.pointerId || start.dragged || event.button !== 0 || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) return;
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
scene.updateMatrixWorld(true); camera.updateMatrixWorld(true);
|
||||
raycaster.setFromCamera(new THREE.Vector2((event.clientX - rect.left) / rect.width * 2 - 1, 1 - (event.clientY - rect.top) / rect.height * 2), camera);
|
||||
const hit = raycaster.intersectObjects(pickable, false).find(({ object }) => {
|
||||
for (let cursor: THREE.Object3D | null = object; cursor; cursor = cursor.parent) if (!cursor.visible) return false;
|
||||
return true;
|
||||
});
|
||||
const partId = hit && owners.get(hit.object);
|
||||
if (partId) onSelectModelRef.current(partId);
|
||||
};
|
||||
renderer.domElement.addEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.addEventListener("pointermove", pointerMove);
|
||||
renderer.domElement.addEventListener("pointerup", pointerUp);
|
||||
renderer.domElement.addEventListener("pointercancel", pointerCancel);
|
||||
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[] = [], selectedJointObject: THREE.Object3D | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0;
|
||||
@@ -79,11 +115,13 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
if (nextParts !== lastParts) {
|
||||
delete element.dataset.machineManagerRendered; element.dataset.machineManagerRenderState = "syncing-scene";
|
||||
selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; dofNodes = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map();
|
||||
owners.clear(); pickable = [];
|
||||
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
|
||||
// source-node transforms remain untouched on the imported GLB.
|
||||
const { scene: visual, nodes: pathNodes } = configureMachinePartScene(part.scene.clone(true), part);
|
||||
visual.traverse((object) => { if ((object as THREE.Mesh).isMesh) { owners.set(object, part.id); pickable.push(object); } });
|
||||
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);
|
||||
@@ -104,11 +142,37 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
lastParts = nextParts;
|
||||
}
|
||||
|
||||
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 = nextParts.find((part) => part.id === nextSelectedId)?.showOrigin ?? 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]);
|
||||
selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null;
|
||||
const selectedBinding = selectedJointObject ? dofNodes.find((binding) => binding.object === selectedJointObject) ?? null : null;
|
||||
if (selectedJointObject) {
|
||||
selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0);
|
||||
// JointDof.direction is defined in the parent frame. The previous
|
||||
// preview always drew an unrotated local XYZ triad, so a C node whose
|
||||
// imported zero pose is rotated showed a blue axis different from the
|
||||
// axis actually used by driveJoint. Express the configured parent
|
||||
// axis in the joint's zero-pose local frame before drawing the helper.
|
||||
const parentAxis = selectedBinding ? new THREE.Vector3(selectedBinding.dof.direction === "x" ? 1 : 0, selectedBinding.dof.direction === "y" ? 1 : 0, selectedBinding.dof.direction === "z" ? 1 : 0) : null;
|
||||
const localAxis = parentAxis?.applyQuaternion(new THREE.Quaternion(...(selectedBinding?.zero.quaternion ?? [0, 0, 0, 1])).invert()).normalize();
|
||||
selectedJointAxes.quaternion.copy(localAxis ? new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), localAxis) : new THREE.Quaternion());
|
||||
selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = nextParts.find((part) => part.id === nextSelectedId)?.showOrigin ?? true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = selectedBinding ? `parent-${selectedBinding.dof.direction}${selectedBinding.dof.sign < 0 ? "-" : "+"}` : "local-xyz"; element.dataset.machineManagerSelectedJointAxis = localAxis?.toArray().map((value) => Number(value.toFixed(6))).join(",") ?? ""; element.dataset.machineManagerTransformTarget = "single-dof-input";
|
||||
} else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerSelectedJointAxis; delete element.dataset.machineManagerTransformTarget; }
|
||||
for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase()] ?? 0);
|
||||
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);
|
||||
selectionBox.makeEmpty();
|
||||
for (const [object, partId] of owners) {
|
||||
if (partId !== nextSelectedId) continue;
|
||||
let visible = true;
|
||||
for (let cursor: THREE.Object3D | null = object; cursor; cursor = cursor.parent) if (!cursor.visible) { visible = false; break; }
|
||||
if (!visible) continue;
|
||||
const geometry = (object as THREE.Mesh).geometry;
|
||||
if (!geometry.boundingBox) geometry.computeBoundingBox();
|
||||
if (geometry.boundingBox) selectionBox.union(geometry.boundingBox.clone().applyMatrix4(object.matrixWorld));
|
||||
}
|
||||
selectionHighlight.visible = !selectionBox.isEmpty();
|
||||
element.dataset.machineManagerSelectedModel = nextSelectedId ?? "";
|
||||
element.dataset.machineManagerSelectionVisible = String(selectionHighlight.visible);
|
||||
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.testPose, stateRef.current.coordinates);
|
||||
@@ -125,11 +189,19 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
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();
|
||||
// Tear down selection resources independently of the geometry shared with drafts.
|
||||
const disposeSelection = () => {
|
||||
renderer.domElement.removeEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.removeEventListener("pointermove", pointerMove);
|
||||
renderer.domElement.removeEventListener("pointerup", pointerUp);
|
||||
renderer.domElement.removeEventListener("pointercancel", pointerCancel);
|
||||
owners.clear(); pickable = []; selectionHighlight.dispose();
|
||||
};
|
||||
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; delete element.machineManagerView; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); grid.geometry.dispose(); for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) material.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 () => { disposeSelection(); if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; delete element.machineManagerView; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); grid.geometry.dispose(); for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) material.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(); }); studio.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} 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={MACHINE_STUDIO.lighting} data-machine-manager-tone-mapping={MACHINE_STUDIO.toneMapping} data-machine-manager-exposure={MACHINE_STUDIO.exposure} 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, query, expansion, locked }: { 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; query: string; expansion: { all: boolean; serial: number }; locked: boolean }) {
|
||||
@@ -172,13 +244,13 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
const refreshLibrary = useCallback(async () => { try { setLibrary(await listSavedMachines()); setLibraryError(""); } catch { setLibraryError("此浏览器无法使用本地机床库;仍可保存下载 GLB 并从磁盘打开。"); } }, []);
|
||||
useEffect(() => { if (open) void refreshLibrary(); }, [open, refreshLibrary]);
|
||||
const requestOpen = () => { if (!pending && !followLive) openFile.current?.click(); };
|
||||
const newDocument = () => { if (pending || followLive) return; edit(() => ({ coordinates, documentInfo: { name: "", description: "" }, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 } })); setSelectedId(null); setQuery(""); setTab("structure"); setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setSaveLocation("新建机床尚未保存;旧版本仍在本地机床库"); setMessage("已新建机床,原草稿可撤销恢复"); };
|
||||
const newDocument = () => { if (pending || followLive) return; edit(() => ({ coordinates, documentInfo: { name: "", description: "" }, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: zeroPose(coordinates) })); setSelectedId(null); setQuery(""); setTab("structure"); setTestPose(zeroPose(coordinates)); setSaveLocation("新建机床尚未保存;旧版本仍在本地机床库"); setMessage("已新建机床,原草稿可撤销恢复"); };
|
||||
const disposeSources = (sources: Awaited<ReturnType<typeof loadMachinePart>>[]) => { const scenes = new Set(sources.flatMap((source) => source.restoredParts?.map((part) => part.scene) ?? [source.scene])); scenes.forEach(disposeMachineScene); };
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null), [selectedNodePath, setSelectedNodePath] = useState("scene");
|
||||
const [tab, setTab] = useState<EditorTab>("structure"), [query, setQuery] = useState("");
|
||||
const [expansion, setExpansion] = useState({ all: false, serial: 0 });
|
||||
const [command, setCommand] = useState<PreviewCommand>({ action: "fit", serial: 0 });
|
||||
const [jogStep, setJogStep] = useState(1);
|
||||
const [jogStep, setJogStep] = useState(MANAGER_SETTINGS.initialJogStep);
|
||||
const [applied, setApplied] = useState(""), [exported, setExported] = useState("");
|
||||
const [invalidFields, setInvalidFields] = useState<string[]>([]);
|
||||
const validity = useCallback((field: string, valid: boolean) => setInvalidFields((current) => valid ? current.includes(field) ? current.filter((id) => id !== field) : current : current.includes(field) ? current : [...current, field]), []);
|
||||
@@ -191,11 +263,12 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
retainedScenes.current = keep;
|
||||
}, [history]);
|
||||
useEffect(() => () => { retainedScenes.current.forEach(disposeMachineScene); }, []);
|
||||
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
|
||||
const [testPose, setTestPose] = useState<TestPose>(() => zeroPose(coordinates));
|
||||
const [motionState, setMotionState] = useState<Record<string, MotionTransform>>({}); const motionStateHash = useRef("");
|
||||
useEffect(() => { setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setFollowLive(false); }, [coordinates]);
|
||||
useEffect(() => { if (selectedId && !parts.some((part) => part.id === selectedId)) { setSelectedId(parts[0]?.id ?? null); setSelectedNodePath("scene"); } }, [parts, selectedId]);
|
||||
useEffect(() => { setTestPose(zeroPose(coordinates)); setFollowLive(false); }, [coordinates]);
|
||||
useEffect(() => { if (!parts.some((part) => part.id === selectedId)) { setSelectedId(parts[0]?.id ?? null); setSelectedNodePath("scene"); } }, [parts, selectedId]);
|
||||
const selected = parts.find((part) => part.id === selectedId) ?? null;
|
||||
const selectedAnalysis = useMemo<GlbFiveAxisAnalysis | null>(() => selected ? analyzeGlbFiveAxisScene(selected.scene, selected.filename) : null, [selected]);
|
||||
const trees = useMemo(() => new Map(parts.map((part) => [part.id, machinePartTree(part)])), [parts]);
|
||||
const findTreeNode = (node: MachineTreeNode | undefined, path: string): MachineTreeNode | null => node ? node.path === path ? node : node.children.map((child) => findTreeNode(child, path)).find(Boolean) ?? null : null;
|
||||
const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null;
|
||||
@@ -205,7 +278,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
const importFiles = async (list: FileList | File[] | null, savedOnly = false) => {
|
||||
if (!list?.length || pending || followLive) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||
try {
|
||||
const results = await Promise.allSettled([...list].map(async (item) => { if (!item.name.toLowerCase().endsWith(".glb") || item.size > 32 * 1024 * 1024) throw new Error(`${item.name}:请选择不超过 32 MiB 的 GLB`); try { return await loadMachinePart(await item.arrayBuffer(), item.name); } catch { throw new Error(`${item.name}:模型无法读取,请检查是否为完整的自包含 GLB,或配置是否与模型层级一致`); } }));
|
||||
const results = await Promise.allSettled([...list].map(async (item) => { if (!item.name.toLowerCase().endsWith(".glb") || item.size > MAX_MACHINE_BYTES) throw new Error(`${item.name}:请选择不超过 64 MiB 的 GLB`); try { return await loadMachinePart(await item.arrayBuffer(), item.name); } catch { throw new Error(`${item.name}:模型无法读取,请检查是否为完整的自包含 GLB,或配置是否与模型层级一致`); } }));
|
||||
const loaded = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure?.status === "rejected") { disposeSources(loaded); throw failure.reason; }
|
||||
@@ -219,6 +292,9 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
disposeSources(loaded);
|
||||
throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入");
|
||||
}
|
||||
const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeGlbFiveAxisScene(source.scene, source.filename)]));
|
||||
const detectedCoordinates = loaded.length === 1 ? autoAnalysis.get(loaded[0].id)?.suggestedCoordinates : undefined;
|
||||
const autoCoordinates = detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? detectedCoordinates : draft.coordinates;
|
||||
const nextDraft = (() => {
|
||||
const next = restored ? [] : [...draft.parts];
|
||||
for (const source of loaded) {
|
||||
@@ -226,18 +302,41 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
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: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} });
|
||||
const analysis = autoAnalysis.get(source.id);
|
||||
const nodeJoints = source.restoredJoints ?? (analysis ? Object.fromEntries(analysis.axes.filter((item) => autoCoordinates.includes(item.axis)).map((item) => [item.path, defaultDof(item.axis as CncAxis)])) : {});
|
||||
// A single complete GLB such as m1.glb keeps its native hierarchy.
|
||||
// Promote the detected axis nodes to structure roles so the
|
||||
// Structure & Joints tab is immediately usable, while preserving
|
||||
// explicit roles restored from a managed machine document.
|
||||
const detectedRoles: Record<string, MachinePartRole | ""> = analysis ? Object.fromEntries(analysis.axes.filter((item) => autoCoordinates.includes(item.axis)).map((item) => [item.path, (item.axis === "A" || item.axis === "B" ? "tilt" : item.axis.toLowerCase()) as MachinePartRole])) : {};
|
||||
const nodeRoles = source.restoredRoles ?? detectedRoles;
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints, nodeRoles });
|
||||
}
|
||||
return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) };
|
||||
return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : { coordinates: autoCoordinates }) };
|
||||
})();
|
||||
edit(() => nextDraft);
|
||||
if (restored) setSavedSignature(nextDraft.coordinates + draftFingerprint(nextDraft));
|
||||
setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件`);
|
||||
setTestPose(zeroPose(restored?.coordinates ?? autoCoordinates)); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件${detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? `;自动识别 ${detectedCoordinates},已绑定 ${autoAnalysis.get(loaded[0].id)?.axes.filter((item) => autoCoordinates.includes(item.axis)).length ?? 0}/${[...autoCoordinates].length} 轴` : ""}`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const remove = (part: MachinePartDraft) => { if (pending || followLive) return; setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); setMessage(`已删除 ${part.filename},可撤销恢复`); if (selectedId === part.id) { setSelectedId(null); setSelectedNodePath("scene"); } };
|
||||
const remove = (part: MachinePartDraft) => {
|
||||
if (pending || followLive || !parts.some((item) => item.id === part.id)) return;
|
||||
const index = parts.findIndex((item) => item.id === part.id);
|
||||
const remaining = parts.filter((item) => item.id !== part.id);
|
||||
// Remove this import only. Separately imported children remain in the draft.
|
||||
setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item));
|
||||
setMessage(`已删除 ${part.filename},可撤销恢复`);
|
||||
if (selectedId === part.id) {
|
||||
setSelectedId(remaining[Math.min(index, remaining.length - 1)]?.id ?? null);
|
||||
setSelectedNodePath("scene"); setQuery("");
|
||||
}
|
||||
};
|
||||
const selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); };
|
||||
const selectModel = (partId: string) => {
|
||||
if (pending || !parts.some((part) => part.id === partId)) return;
|
||||
selectTreeNode(partId, "scene"); setQuery(""); setTab("structure");
|
||||
};
|
||||
const reparentTreeNode = (source: { partId: string; path: string }, target: { partId: string; path: string }) => {
|
||||
if (pending || followLive) return;
|
||||
if (source.partId === target.partId && source.path === target.path) return;
|
||||
@@ -270,7 +369,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
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 });
|
||||
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength > MAX_MACHINE_BYTES) throw new Error("保存文件超过控制面板支持的 32 MiB,请减小模型后重试");
|
||||
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength > MAX_MACHINE_BYTES) throw new Error("保存文件超过控制面板支持的 64 MiB,请减小模型后重试");
|
||||
const saved = inspectMachineGlb(bytes);
|
||||
if (saved.structure.coordinates !== coordinates) throw new Error("保存模型的机型与当前配置不一致");
|
||||
const filename = machineDocumentFilename(documentInfo.name, coordinates);
|
||||
@@ -338,61 +437,73 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]);
|
||||
const liveReady = visual.joints.every(Number.isFinite) && visual.authoritative && visual.machineModel?.coordinate_system === coordinates;
|
||||
const heldLivePose = useRef<TestPose>(testPose);
|
||||
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]])) } : followLive ? heldLivePose.current : testPose;
|
||||
const effectivePose: TestPose = followLive && liveReady ? { ...zeroPose(coordinates), ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - Number(physicalZero[axis.toLowerCase()] ?? 0)])) } : followLive ? heldLivePose.current : testPose;
|
||||
if (followLive && liveReady) heldLivePose.current = effectivePose;
|
||||
const validation = useMemo(() => { try { if (Math.abs(tiltOffsetMm[coordinates === "XYZAC" ? 0 : 1]) > 1e-9) throw new Error(coordinates === "XYZAC" ? "AC 机床的 A→C 偏置 X 必须为 0" : "BC 机床的 B→C 偏置 Y 必须为 0"); 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 validation = useMemo(() => { try { validateManagerCalibration(coordinates, cOriginMm, tiltOffsetMm); 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 blockingReason = invalidFields.length ? "请修正标红的数值输入" : validation.error;
|
||||
const bindings = useMemo(() => parts.flatMap((part) => Object.entries(part.nodeJoints ?? {}).map(([path, dof]) => ({ part, path, dof, name: findTreeNode(trees.get(part.id), path)?.name ?? part.filename }))), [parts, trees]);
|
||||
const moveToBinding = (axis: string) => { const binding = bindings.find((b) => b.dof.axis === axis); setTab("structure"); setQuery(""); if (binding) selectTreeNode(binding.part.id, binding.path); else { setMessage(`在层级树中选择 ${axis} 轴关节,再设置唯一自由度`); } };
|
||||
const changeHistory = (direction: "undo" | "redo") => { if (pending || followLive) return; if (direction === "undo") undo(); else redo(); setMessage(direction === "undo" ? "已撤销配置修改" : "已重做配置修改"); };
|
||||
const changeHistory = (direction: "undo" | "redo") => {
|
||||
if (pending || followLive) return;
|
||||
const next = direction === "undo" ? history.past.at(-1) : history.future[0];
|
||||
if (!next) return;
|
||||
const restoredPart = next.parts.find((part) => !parts.some((current) => current.id === part.id));
|
||||
const nextId = restoredPart?.id ?? (next.parts.some((part) => part.id === selectedId) ? selectedId : next.parts[0]?.id ?? null);
|
||||
if (nextId !== selectedId) { setSelectedId(nextId); setSelectedNodePath("scene"); setQuery(""); }
|
||||
if (direction === "undo") undo(); else redo();
|
||||
setMessage(direction === "undo" ? "已撤销配置修改" : "已重做配置修改");
|
||||
};
|
||||
const exportJson = () => {
|
||||
if (!validation.model || pending || invalidFields.length) 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); setMessage("关节 JSON 已导出;恢复完整模型请使用“保存机床模型 GLB”");
|
||||
};
|
||||
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<MachineNumberInput fieldId={`test-${axis}`} control={`machine-test-${axis}`} onValidity={validity} disabled={pending || followLive || !bindings.some((b) => b.dof.axis.toLowerCase() === axis)} value={effectivePose[axis]} onChange={(value) => setTestPose((pose) => ({ ...pose, [axis]: value }))} /><small>{unit}</small></label>;
|
||||
const poseInput = (axis: CncAxis) => { const key = axis.toLowerCase(); return <label key={axis}>{axis}<MachineNumberInput fieldId={`test-${key}`} control={`machine-test-${key}`} onValidity={validity} disabled={pending || followLive || !bindings.some((b) => b.dof.axis === axis)} value={effectivePose[key] ?? 0} onChange={(value) => setTestPose((pose) => ({ ...pose, [key]: value }))} /><small>{axisUnit(axis)}</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);
|
||||
const activeAxes = managerAxes(coordinates);
|
||||
const profile = managerProfile(coordinates);
|
||||
const renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} query={query} expansion={expansion} locked={pending || followLive} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />;
|
||||
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" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
|
||||
<div className="machine-manager-workbench" aria-busy={pending}>
|
||||
<div className="machine-type-step"><label><strong>1 · 机床类型</strong><select data-axis-control="machine-manager-type" aria-label="设计机床类型" value={coordinates} disabled={pending || followLive} onChange={(event) => {
|
||||
const next = event.target.value as "XYZAC" | "XYZBC";
|
||||
const next = event.target.value;
|
||||
edit((draft) => changeMachineType(draft, next)); setTab("structure");
|
||||
setMessage(`设计机型已选择 ${next};保留模型层级和 XYZ/C 绑定,请重新配置 ${next[3]} 轴并核对标定。可撤销恢复。`);
|
||||
}}><option value="XYZAC">XYZAC · A/C 五轴转台</option><option value="XYZBC">XYZBC · B/C 五轴转台</option></select></label><label><strong>LinuxCNC 案例/运动学</strong><select data-axis-control="machine-manager-case-type" aria-label="LinuxCNC 案例与运动学" value={FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates && item.webWasm === "supported")?.id ?? FIVE_AXIS_CASES[0].id} disabled={pending || followLive} onChange={(event) => {
|
||||
setMessage(`设计机型已选择 ${next};保留模型层级,请核对 ${next} 轴绑定和标定。可撤销恢复。`);
|
||||
}}>{catalogCoordinateOptions.map((axisSet) => { const caseItem = FIVE_AXIS_CASES.find((item) => item.coordinates === axisSet); return <option key={axisSet} value={axisSet} disabled={!supportedCoordinateOptions.has(axisSet)}>{caseItem?.label ?? axisSet}{supportedCoordinateOptions.has(axisSet) ? "" : " · 待 WASM 适配"}</option>; })}</select></label><label><strong>LinuxCNC 案例/运动学</strong><select data-axis-control="machine-manager-case-type" aria-label="LinuxCNC 案例与运动学" value={FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates && item.webWasm === "supported")?.id ?? FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates)?.id ?? FIVE_AXIS_CASES[0].id} disabled={pending || followLive} onChange={(event) => {
|
||||
const selectedCase = FIVE_AXIS_CASES.find((item) => item.id === event.target.value);
|
||||
if (!selectedCase || !supportedCoordinateOptions.has(selectedCase.coordinates)) { setMessage(`${selectedCase?.label ?? event.target.value} 已登记源码案例,但当前 Web/WASM 运动学适配尚未完成`); return; }
|
||||
edit((draft) => changeMachineType(draft, selectedCase.coordinates as "XYZAC" | "XYZBC")); setTab("structure"); setMessage(`已选择案例 ${selectedCase.label},设计机型同步为 ${selectedCase.coordinates}`);
|
||||
}}>{FIVE_AXIS_CASES.map((item) => <option key={item.id} value={item.id} disabled={item.webWasm !== "supported"}>{item.label}{item.webWasm === "supported" ? "" : " · 待 WASM 适配"}</option>)}</select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates === "XYZAC" ? "A 绕 X 倾转,C 绕 Z 回转" : "B 绕 Y 倾转,C 绕 Z 回转"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库</small></div></div>
|
||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={() => beginFilePicker(openFile.current)}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => beginFilePicker(file.current)}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
|
||||
<div className="machine-axis-overview" aria-label="数控轴绑定总览">{[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div>
|
||||
edit((draft) => changeMachineType(draft, selectedCase.coordinates)); setTab("structure"); setMessage(`已选择案例 ${selectedCase.label},设计机型同步为 ${selectedCase.coordinates}`);
|
||||
}}>{FIVE_AXIS_CASES.map((item) => <option key={item.id} value={item.id} disabled={item.webWasm !== "supported"}>{item.label}{item.webWasm === "supported" ? "" : " · 待 WASM 适配"}</option>)}</select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库</small></div></div>
|
||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={() => beginFilePicker(openFile.current)}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => beginFilePicker(file.current)}>导入 GLB 部件</button><button type="button" data-axis-control="machine-model-delete-selected" disabled={!selected || pending || followLive} title={selected ? `删除 ${selected.filename},可撤销恢复` : "请先选择导入模型"} onClick={() => { if (selected) remove(selected); }}>删除选中模型</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/{activeAxes.length} 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
|
||||
<div className="machine-axis-overview" aria-label="数控轴绑定总览">{activeAxes.map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div>
|
||||
<div className="machine-manager-layout">
|
||||
<section className="machine-preview-area" aria-label="机床三维预览"><div className="machine-preview-tools">{([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => <button key={action} type="button" data-axis-control={`machine-view-${action}`} disabled={!parts.length || (action === "selected" && !selected)} onClick={() => setCommand((current) => ({ action, serial: current.serial + 1 }))}>{label}</button>)}<small>Z ↑ · X 红 / Y 绿 / Z 蓝</small></div>
|
||||
{open && <MachineManagerPanelPreview command={command} coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />}
|
||||
{!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => beginFilePicker(file.current)}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 32 MiB</small>{library.length > 0 && <button type="button" onClick={() => setTab("files")}>恢复本地机床库({library.length} 个版本)</button>}</div>}
|
||||
<p className="machine-preview-hint">左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系</p></section>
|
||||
{open && <MachineManagerPanelPreview command={command} coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} onSelectModel={selectModel} />}
|
||||
{!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => beginFilePicker(file.current)}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 64 MiB</small>{library.length > 0 && <button type="button" onClick={() => setTab("files")}>恢复本地机床库({library.length} 个版本)</button>}</div>}
|
||||
<p className="machine-preview-hint">单击选择模型 · 拖动旋转 · 滚轮缩放 · 右键平移 · 黄色框为选中模型</p></section>
|
||||
<div className="machine-manager-editor">
|
||||
{parts.length > 0 && <div className="machine-model-list" data-axis-control="machine-model-list" aria-label="已导入 GLB 模型">{parts.map((part) => <div key={part.id} className={`machine-model-row ${selectedId === part.id ? "selected" : ""}`} data-machine-model-id={part.id}><button type="button" data-axis-control="machine-model-select" aria-pressed={selectedId === part.id} disabled={pending} onClick={() => selectModel(part.id)} title={part.filename}><strong>{part.filename}</strong><span>{part.role ? roleLabel(part.role, coordinates) : "未分配角色"} · {part.nodeJoints ? Object.keys(part.nodeJoints).length : 0} 个关节绑定</span></button><button type="button" data-axis-control="machine-model-delete" aria-label={`删除 ${part.filename}`} disabled={pending || followLive} onClick={() => remove(part)}>删除</button></div>)}</div>}
|
||||
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration", "files"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 4] : event.key === "ArrowLeft" ? ids[(index + 3) % 4] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[3] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div>
|
||||
<div id="machine-panel-structure" className="machine-structure-panel" role="tabpanel" aria-labelledby="machine-tab-structure" hidden={tab !== "structure"}>
|
||||
<section className="machine-part-list"><header><h3>部件与层级</h3><span>{validation.model?.nodes.length ?? 0} 节点</span></header><div className="machine-tree-tools"><input data-axis-control="machine-tree-search" type="search" aria-label="搜索节点名称或轴" placeholder="搜索节点名称 / 数控轴" value={query} onChange={(event) => setQuery(event.target.value)} /><button type="button" data-axis-control="machine-tree-expand" onClick={() => setExpansion((current) => ({ all: true, serial: current.serial + 1 }))}>展开</button><button type="button" data-axis-control="machine-tree-collapse" onClick={() => { setQuery(""); setExpansion((current) => ({ all: false, serial: current.serial + 1 })); }}>折叠</button></div>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
|
||||
{selected && <section className="machine-part-editor"><header><h3 title={selected.filename}>{selectedTreeNode?.name ?? selected.filename}<small>{selected.filename}</small></h3><button type="button" data-axis-control="machine-manager-delete" disabled={pending || followLive} title="删除整个导入文件,可通过撤销恢复" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <details className="machine-origin-details"><summary>零姿态与当前坐标</summary><div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div></details>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" disabled={pending || followLive} value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{[..."XYZABC"].map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {"XYZ".includes(axis) ? "平移 mm" : "旋转 deg"}</option>)}</select></label>
|
||||
<section className="machine-part-list"><header><h3>部件与层级</h3><span>{validation.model?.nodes.length ?? 0} 节点</span></header>{selectedAnalysis && <section className="glb-five-axis-analysis" data-axis-control="glb-five-axis-analysis"><header><h3>GLB 五轴结构分析</h3><button type="button" data-axis-control="glb-five-axis-export" onClick={() => { const body = JSON.stringify(selectedAnalysis, null, 2) + "\n"; const url = URL.createObjectURL(new Blob([body], { type: "application/json" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${selectedAnalysis.filename?.replace(/\.glb$/i, "") || "machine"}-five-axis-analysis.json`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }}>导出 JSON</button></header><p className="glb-analysis-summary" data-axis-control="glb-five-axis-summary">{analysisSummary(selectedAnalysis)}</p><div className="glb-analysis-grid"><span>节点 / 网格<strong>{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}</strong></span><span>建议运动学<strong>{selectedAnalysis.suggestedKinematics}</strong></span><span>RTCP<strong>{"待确认"}</strong></span></div><div className="glb-analysis-axes" aria-label="识别的机床轴">{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return <span key={axis} data-glb-analysis-axis={axis} className={item ? "detected" : "missing"}><b>{axis}</b>{item ? <small>{item.nodeName}<br />{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm</small> : <small>未识别</small>}</span>; })}</div><details><summary>结构链与 RTCP 参数</summary><p>直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}</p><p>旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}</p><p>节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}</p><p>{selectedAnalysis.rtcp.reason}</p><small>建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}</small>{selectedAnalysis.warnings.length > 0 && <ul>{selectedAnalysis.warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul>}</details></section>}<div className="machine-tree-tools"><input data-axis-control="machine-tree-search" type="search" aria-label="搜索节点名称或轴" placeholder="搜索节点名称 / 数控轴" value={query} onChange={(event) => setQuery(event.target.value)} /><button type="button" data-axis-control="machine-tree-expand" onClick={() => setExpansion((current) => ({ all: true, serial: current.serial + 1 }))}>展开</button><button type="button" data-axis-control="machine-tree-collapse" onClick={() => { setQuery(""); setExpansion((current) => ({ all: false, serial: current.serial + 1 })); }}>折叠</button></div>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
|
||||
{selected && <section className="machine-part-editor"><header><h3 title={selected.filename}>{selectedTreeNode?.name ?? selected.filename}<small>{selected.filename}</small></h3><button type="button" data-axis-control="machine-manager-delete" disabled={pending || followLive} title="删除整个导入文件,可通过撤销恢复" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <details className="machine-origin-details"><summary>零姿态与当前坐标</summary><div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div></details>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" disabled={pending || followLive} value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{CNC_AXES.map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {axisUnit(axis)}</option>)}</select></label>
|
||||
{selectedDof && <><label>父级运动方向<select data-axis-control="machine-joint-direction" disabled={pending || followLive} value={selectedDof.direction} onChange={(event) => setDof({ ...selectedDof, direction: event.target.value as "x" | "y" | "z" })}>{["x", "y", "z"].map((axis) => <option key={axis} value={axis}>{axis.toUpperCase()}</option>)}</select></label><label>正负方向<select data-axis-control="machine-joint-sign" disabled={pending || followLive} value={selectedDof.sign} onChange={(event) => setDof({ ...selectedDof, sign: Number(event.target.value) as 1 | -1 })}><option value="1">正向 +1</option><option value="-1">反向 −1</option></select></label></>}
|
||||
<label>节点结构角色<select data-axis-control="machine-manager-role" disabled={pending || followLive} value={selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? ""} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label>{selectedNodePath === "scene" ? <label>模型装配父级<select data-axis-control="machine-manager-parent" disabled={pending || followLive} value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => parentAllowed(selected.id, part.id)).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label> : <p className="machine-parent-note">内部节点父级由层级树拖放管理</p>}<div className="machine-part-flags"><label><input type="checkbox" disabled={pending || followLive} checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示导入模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" disabled={pending || followLive} checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div><p className="machine-transform-reference">当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}</p>
|
||||
<label>{selectedDof ? `${selectedDof.axis} ${isLinear(selectedDof.axis) ? "位移 (mm)" : "转角 (deg)"}` : "关节值"}<MachineNumberInput fieldId={`joint-${selectedId}-${selectedNodePath}`} control="machine-joint-value" onValidity={validity} disabled={pending || !selectedDof || followLive} value={selectedDof ? effectivePose[selectedDof.axis.toLowerCase() as keyof TestPose] : 0} onChange={(value) => { if (selectedDof) setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: value })); }} /></label>
|
||||
{selectedDof && <div className="machine-joint-step"><label>步长<select aria-label="关节步长" disabled={followLive || pending} value={jogStep} onChange={(event) => setJogStep(Number(event.target.value))}>{[.1, 1, 10].map((step) => <option key={step} value={step}>{step} {isLinear(selectedDof.axis) ? "mm" : "°"}</option>)}</select></label>{([-1, 1] as const).map((direction) => <button key={direction} type="button" data-axis-control={direction < 0 ? "machine-joint-step-minus" : "machine-joint-step-plus"} disabled={followLive || pending || invalidFields.length > 0} onClick={() => setTestPose((pose) => { const axis = selectedDof.axis.toLowerCase() as keyof TestPose; return { ...pose, [axis]: Number((pose[axis] + direction * jogStep).toFixed(6)) }; })}>{direction < 0 ? "−" : "+"}{jogStep}</button>)}<button type="button" data-axis-control="machine-joint-reset" disabled={followLive || pending} onClick={() => setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: 0 }))}>本轴归零</button></div>}
|
||||
<label>{selectedDof ? `${selectedDof.axis} ${isLinear(selectedDof.axis) ? "位移 (mm)" : "转角 (deg)"}` : "关节值"}<MachineNumberInput fieldId={`joint-${selectedId}-${selectedNodePath}`} control="machine-joint-value" onValidity={validity} disabled={pending || !selectedDof || followLive} value={selectedDof ? effectivePose[selectedDof.axis.toLowerCase()] ?? 0 : 0} onChange={(value) => { if (selectedDof) setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: value })); }} /></label>
|
||||
{selectedDof && <div className="machine-joint-step"><label>步长<select aria-label="关节步长" disabled={followLive || pending} value={jogStep} onChange={(event) => setJogStep(Number(event.target.value))}>{MANAGER_SETTINGS.jogSteps.map((step) => <option key={step} value={step}>{step} {isLinear(selectedDof.axis) ? "mm" : "°"}</option>)}</select></label>{([-1, 1] as const).map((direction) => <button key={direction} type="button" data-axis-control={direction < 0 ? "machine-joint-step-minus" : "machine-joint-step-plus"} disabled={followLive || pending || invalidFields.length > 0} onClick={() => setTestPose((pose) => { const axis = selectedDof.axis.toLowerCase(); return { ...pose, [axis]: Number(((pose[axis] ?? 0) + direction * jogStep).toFixed(6)) }; })}>{direction < 0 ? "−" : "+"}{jogStep}</button>)}<button type="button" data-axis-control="machine-joint-reset" disabled={followLive || pending} onClick={() => setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: 0 }))}>本轴归零</button></div>}
|
||||
</section>}
|
||||
{!selected && <p className="machine-selection-empty">选择层级节点,配置其唯一自由度。</p>}
|
||||
</div>
|
||||
<section id="machine-panel-motion" role="tabpanel" aria-labelledby="machine-tab-motion" hidden={tab !== "motion"} className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={pending || (!liveReady && !followLive)} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : coordinates !== controllerCoordinates ? `设计机型 ${coordinates} 与控制面板 ${controllerCoordinates} 不同;应用或导入后可实时跟随` : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={pending || 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><p>这些输入仅验证模型运动,不向数控系统发送运动命令。</p><details><summary>诊断数据</summary><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></details></section>
|
||||
<section id="machine-panel-calibration" role="tabpanel" aria-labelledby="machine-tab-calibration" hidden={tab !== "calibration"} 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()}<MachineNumberInput fieldId={`zero-${axis}`} onValidity={validity} disabled={pending || followLive} value={physicalZero[axis as keyof PhysicalZero]} onChange={(value) => setPhysicalZero({ ...physicalZero, [axis]: value })} /></label>)}</fieldset></section>
|
||||
<section id="machine-panel-motion" role="tabpanel" aria-labelledby="machine-tab-motion" hidden={tab !== "motion"} className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={pending || (!liveReady && !followLive)} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : coordinates !== controllerCoordinates ? `设计机型 ${coordinates} 与控制面板 ${controllerCoordinates} 不同;应用或导入后可实时跟随` : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={pending || followLive} onClick={() => setTestPose(zeroPose(coordinates))}>复位</button></header><div>{activeAxes.map((axis) => poseInput(axis))}</div><p>这些输入仅验证模型运动,不向数控系统发送运动命令。</p><details><summary>诊断数据</summary><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></details></section>
|
||||
<section id="machine-panel-calibration" role="tabpanel" aria-labelledby="machine-tab-calibration" hidden={tab !== "calibration"} className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput(`${profile?.rotaryAxis ?? "旋转"} 轴原点`, cOriginMm, setCOriginMm, "mm")}{vectorInput(`${profile?.tiltAxis ?? coordinates[3] ?? "旋转"} 到 ${profile?.rotaryAxis ?? coordinates[4] ?? "旋转"} 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点(按当前坐标,直线 mm / 旋转 deg)</legend>{activeAxes.map((axis) => { const key = axis.toLowerCase(); return <label key={axis}>{axis}<MachineNumberInput fieldId={`zero-${key}`} onValidity={validity} disabled={pending || followLive} value={physicalZero[key] ?? 0} onChange={(value) => setPhysicalZero({ ...physicalZero, [key]: value })} /></label>; })}</fieldset></section>
|
||||
<section id="machine-panel-files" role="tabpanel" aria-labelledby="machine-tab-files" hidden={tab !== "files"} className="machine-document-panel">
|
||||
<h3>机床文档</h3><label>模型名称<input data-axis-control="machine-document-name" maxLength={80} value={documentInfo.name} disabled={pending || followLive} placeholder={`${coordinates} 机床`} onChange={(event) => { const name = event.target.value; edit((draft) => ({ ...draft, documentInfo: { ...documentInfo, name } })); }} /></label>
|
||||
<label>设计说明<textarea data-axis-control="machine-document-description" maxLength={2000} value={documentInfo.description} disabled={pending || followLive} placeholder="用途、标定说明、版本变更…" onChange={(event) => { const description = event.target.value; edit((draft) => ({ ...draft, documentInfo: { ...documentInfo, description } })); }} /></label>
|
||||
<section className="machine-case-catalog" data-axis-control="machine-case-catalog"><header><h3>LinuxCNC 五轴案例库</h3><span>{FIVE_AXIS_CASES.length} 个结构/运动学案例</span></header><p>案例来源于仓库内 LinuxCNC 配置、示例 G 代码和运动学源码。选中 XYZAC/XYZBC 可直接进入当前 Web/WASM 工作流;其它类型保留完整参数和源码索引,待对应运动学适配器接入后启用。</p><ul>{FIVE_AXIS_CASES.map((item) => <li key={item.id} data-machine-case={item.id} className={item.coordinates === coordinates ? "active" : ""}><div><strong>{item.label}</strong><span>{item.kinematics} · {item.coordinates} · {item.joints} 关节</span><small>{item.description}</small></div><em className={`machine-case-status machine-case-status-${item.webWasm}`}>{FIVE_AXIS_STATUS_LABELS[item.webWasm]}</em><details><summary>查看本地案例与参数</summary><code>{item.localCases.join("\n")}</code><small>运动学源码:{item.kinematicsSource}<br />结构参数:{item.designParameters.join("、")}</small></details></li>)}</ul><small>官方参考:<a href="https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html" target="_blank" rel="noreferrer">5-Axis Kinematics</a> · <a href="https://linuxcnc.org/docs/html/man/man9/kins.9.html" target="_blank" rel="noreferrer">kins(9)</a> · <a href="https://linuxcnc.org/docs/html/motion/switchkins.html" target="_blank" rel="noreferrer">switchkins</a></small></section>
|
||||
<section className="machine-case-catalog" data-axis-control="machine-case-catalog"><header><h3>LinuxCNC 五轴案例库</h3><span>{FIVE_AXIS_CASES.length} 个结构/运动学案例</span></header><p>案例来源于仓库内 LinuxCNC 配置、示例 G 代码和运动学源码。支持的坐标可直接进入当前 Web/WASM 工作流;其它类型保留完整参数和源码索引,待对应运动学适配器接入后启用。</p><ul>{FIVE_AXIS_CASES.map((item) => <li key={item.id} data-machine-case={item.id} className={item.coordinates === coordinates ? "active" : ""}><div><strong>{item.label}</strong><span>{item.kinematics} · {item.coordinates} · {item.joints} 关节</span><small>{item.description}</small></div><em className={`machine-case-status machine-case-status-${item.webWasm}`}>{FIVE_AXIS_STATUS_LABELS[item.webWasm]}</em><details><summary>查看本地案例与参数</summary><code>{item.localCases.join("\n")}</code><small>运动学源码:{item.kinematicsSource}<br />结构参数:{item.designParameters.join("、")}</small></details></li>)}</ul><small>官方参考:<a href="https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html" target="_blank" rel="noreferrer">5-Axis Kinematics</a> · <a href="https://linuxcnc.org/docs/html/man/man9/kins.9.html" target="_blank" rel="noreferrer">kins(9)</a> · <a href="https://linuxcnc.org/docs/html/motion/switchkins.html" target="_blank" rel="noreferrer">switchkins</a></small></section>
|
||||
<div className="machine-save-location" data-axis-control="machine-document-location"><strong>文件保存在哪里?</strong><p>{saveLocation}</p><p>GLB 下载到浏览器设置的目录,通常为“下载”。请打开浏览器下载记录查看实际位置;网页无法读取磁盘完整路径。</p><p>保存时另存一份到本浏览器机床库,可在刷新页面后重新打开。本地机床库仅属于当前浏览器及网站地址,不会上传云服务器;清理网站数据会删除这些副本。</p></div>
|
||||
<h3>本浏览器机床库 <small>{library.length} 个保存版本</small></h3><div className="machine-library-tools"><input data-axis-control="machine-library-search" aria-label="搜索已保存机床" placeholder="搜索名称 / 机型" value={libraryQuery} onChange={(event) => setLibraryQuery(event.target.value)} /><button type="button" onClick={() => void refreshLibrary()}>刷新</button></div>
|
||||
{libraryError && <p role="alert">{libraryError}</p>}
|
||||
@@ -401,7 +512,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`machine-validation ${blockingReason ? "needs-attention" : "ready"}`} data-axis-control="machine-manager-validation" role="status">{blockingReason ? `配置检查:${blockingReason}` : "配置检查通过 · 五轴绑定完整"}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && <button type="button" onClick={() => { const id = invalidFields[0]; setTab(id.startsWith("joint-") ? "structure" : id.startsWith("test-") ? "motion" : "calibration"); requestAnimationFrame(() => document.querySelector<HTMLInputElement>('.machine-manager-dialog input[aria-invalid="true"]')?.focus()); }}>定位无效输入</button>}</div>
|
||||
<div className={`machine-validation ${blockingReason ? "needs-attention" : "ready"}`} data-axis-control="machine-manager-validation" role="status">{blockingReason ? `配置检查:${blockingReason}` : `配置检查通过 · ${activeAxes.length} 轴绑定完整`}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && <button type="button" onClick={() => { const id = invalidFields[0]; setTab(id.startsWith("joint-") ? "structure" : id.startsWith("test-") ? "motion" : "calibration"); requestAnimationFrame(() => document.querySelector<HTMLInputElement>('.machine-manager-dialog input[aria-invalid="true"]')?.focus()); }}>定位无效输入</button>}</div>
|
||||
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || followLive || Boolean(blockingReason)} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || followLive || Boolean(blockingReason)} onClick={() => void exportGlb()}>保存机床模型 GLB</button><button type="button" data-axis-control="machine-manager-export-json" disabled={!validation.model || pending || invalidFields.length > 0} onClick={exportJson}>导出关节 JSON</button><output role="status" aria-live="polite">{message || "关闭对话框保留本次草稿;保存机床模型 GLB 后,可在控制面板“导入机床模型”中使用。"}</output>{message && <button type="button" aria-label="清除操作消息" onClick={() => setMessage("")}>×</button>}</footer>
|
||||
</div>
|
||||
<input ref={openFile} data-axis-control="machine-document-open-file" hidden type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const item = event.target.files?.[0]; event.target.value = ""; if (item) void importFiles([item], true); }} />
|
||||
|
||||
@@ -4,7 +4,7 @@ const VIEWER = WEB_SETTINGS.viewer;
|
||||
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 { installMachineStudio } from "./machine-studio-lighting";
|
||||
import { buildImportedMachine, type ImportedMachine } from "./imported-machine";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model";
|
||||
@@ -29,25 +29,16 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
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 = !managedJoints;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
||||
element.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(VIEWER.colors.background);
|
||||
const studio = new RoomEnvironment();
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
const environment = pmrem.fromScene(studio, .04);
|
||||
scene.environment = environment.texture; scene.environmentIntensity = .65;
|
||||
studio.dispose(); pmrem.dispose();
|
||||
scene.add(new THREE.HemisphereLight(0xf4f8ff, 0x4b5450, 1.2));
|
||||
const key = new THREE.DirectionalLight(0xfff4df, 2.7); key.position.set(-4, 9, -6);
|
||||
const studio = installMachineStudio(renderer, scene, "y");
|
||||
const key = studio.key;
|
||||
key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -.0002; key.shadow.normalBias = .012;
|
||||
Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 }); scene.add(key);
|
||||
const rim = new THREE.DirectionalLight(0xd5f0ff, 1.7); rim.position.set(5, 5, 5); scene.add(rim);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, .7); fill.position.set(4, 2, -7); scene.add(fill);
|
||||
Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 });
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: .24 }));
|
||||
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor);
|
||||
|
||||
@@ -195,7 +186,7 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
clearLines(pathGroup); clearLines(liveGroup); pathMaterials.forEach((material) => material.dispose());
|
||||
for (const helper of [limits, offsets, extents]) helper.dispose();
|
||||
assembly.dispose(); nameplateTexture.dispose(); nameplate.geometry.dispose(); nameplate.material.dispose();
|
||||
floor.geometry.dispose(); floor.material.dispose(); key.shadow.map?.dispose(); environment.dispose();
|
||||
floor.geometry.dispose(); floor.material.dispose(); studio.dispose();
|
||||
renderer.dispose(); renderer.domElement.remove();
|
||||
};
|
||||
}, [importedMachine]);
|
||||
@@ -204,7 +195,7 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
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-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`}>
|
||||
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 ? importedMachine.rawAxisBindings ? "glb-axis-analysis/linuxcnc-wasm" : "glb-urdf/linuxcnc-wasm" : "linuxcnc-wasm"} data-xyzac-axis-bindings={importedMachine?.rawAxisBindings ? JSON.stringify(importedMachine.rawAxisBindings.map(({ axis, path }) => ({ axis, path }))) : ""} 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 ? "GLB" : "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")}
|
||||
|
||||
134
web/react/src/glb-five-axis-analyzer.ts
Normal file
134
web/react/src/glb-five-axis-analyzer.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as THREE from "three";
|
||||
import { CNC_AXES, defaultDof, isFiveAxisCoordinates, isLinear, validJointDof, type CncAxis, type JointDof } from "./machine-joints";
|
||||
|
||||
/** A single axis candidate found in a GLB node hierarchy. */
|
||||
export type GlbAxisEvidence = {
|
||||
axis: CncAxis;
|
||||
nodeName: string;
|
||||
path: string;
|
||||
parentName: string;
|
||||
parentPath: string;
|
||||
depth: number;
|
||||
originMm: [number, number, number];
|
||||
worldOriginMm: [number, number, number];
|
||||
expectedKind: "linear" | "rotary";
|
||||
confidence: "high" | "medium" | "low";
|
||||
reason: string;
|
||||
joint: JointDof;
|
||||
autoBind: boolean;
|
||||
};
|
||||
|
||||
export type GlbFiveAxisAnalysis = {
|
||||
schema: "linuxcnc.glb-five-axis-analysis/1";
|
||||
filename?: string;
|
||||
nodeCount: number;
|
||||
meshCount: number;
|
||||
boundsMm: { min: [number, number, number]; max: [number, number, number]; size: [number, number, number] };
|
||||
axes: GlbAxisEvidence[];
|
||||
missingAxes: string[];
|
||||
linearChain: string[];
|
||||
rotaryChain: string[];
|
||||
chains: string[][];
|
||||
coordinateCandidates: string[];
|
||||
suggestedCoordinates: string | "UNKNOWN";
|
||||
suggestedKinematics: "generic-five-axis" | "unknown";
|
||||
rtcp: { mode: "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const AXES = CNC_AXES;
|
||||
const axisSet = new Set<string>(CNC_AXES);
|
||||
const round = (value: number) => Number(value.toFixed(6));
|
||||
const mm = (v: THREE.Vector3): [number, number, number] => [round(v.x * 1000), round(v.y * 1000), round(v.z * 1000)];
|
||||
|
||||
function axisFromName(name: string): { axis: CncAxis; exact: boolean } | null {
|
||||
const normalized = name.trim().toUpperCase();
|
||||
const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
|
||||
if (exact && axisSet.has(exact[1])) return { axis: exact[1] as CncAxis, exact: true };
|
||||
const token = normalized.match(/(?:^|[_\-\s])(?:JT|JOINT|AXIS|LINEAR|ROTARY)[_-]?([XYZABCUVW])(?:$|[_\-\s])/)
|
||||
?? normalized.match(/(?:^|[_\-\s])([XYZABCUVW])[_-]?(?:AXIS|JOINT|LINEAR|ROTARY)(?:$|[_\-\s])/);
|
||||
return token && axisSet.has(token[1]) ? { axis: token[1] as CncAxis, exact: false } : null;
|
||||
}
|
||||
|
||||
function objectPath(node: THREE.Object3D): string {
|
||||
const parts: string[] = [];
|
||||
let current: THREE.Object3D | null = node;
|
||||
while (current?.parent) { const index = current.parent.children.indexOf(current); parts.unshift(String(index)); current = current.parent; }
|
||||
return `scene${parts.length ? `/${parts.join("/")}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an already parsed Three.js GLB scene. The heuristic intentionally
|
||||
* prioritizes explicit joint metadata and uses names only as hints. It never mutates
|
||||
* geometry and can therefore be reused for previews, import validation and
|
||||
* offline model review.
|
||||
*/
|
||||
export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string): GlbFiveAxisAnalysis {
|
||||
scene.updateMatrixWorld(true);
|
||||
const axes: GlbAxisEvidence[] = [];
|
||||
const warnings: string[] = [];
|
||||
const duplicateAxes = new Set<string>();
|
||||
const bounds = new THREE.Box3();
|
||||
let nodeCount = 0, meshCount = 0;
|
||||
scene.traverse((node) => {
|
||||
nodeCount += 1;
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (mesh.isMesh) { meshCount += 1; bounds.expandByObject(mesh); }
|
||||
const named = axisFromName(node.name || "");
|
||||
const data = node.userData;
|
||||
const explicit = Object.hasOwn(data, "linuxcncJoint") || Object.hasOwn(data, "linuxcnc.axis");
|
||||
if (Object.hasOwn(data, "linuxcncJoint") && data.linuxcncJoint === null) return;
|
||||
const metadata = data.linuxcncJoint ?? (Object.hasOwn(data, "linuxcnc.axis") ? { axis: data["linuxcnc.axis"], direction: data["linuxcnc.axis_direction"], sign: data["linuxcnc.axis_sign"], frame: data["linuxcnc.axis_frame"] } : undefined);
|
||||
const axis = explicit ? metadata?.axis : named?.axis;
|
||||
if (!axisSet.has(axis)) { if (explicit) warnings.push(`${node.name}:轴元数据无效,请手动绑定`); return; }
|
||||
const match = { axis: axis as CncAxis, exact: explicit };
|
||||
const direction = metadata?.direction ?? defaultDof(match.axis).direction;
|
||||
const joint: JointDof = { axis: match.axis, direction: Array.isArray(direction) ? "custom" : direction, sign: metadata?.sign ?? 1,
|
||||
...(metadata?.frame !== undefined ? { frame: metadata.frame } : {}),
|
||||
...(Array.isArray(direction) ? { vector: direction } : metadata?.vector ? { vector: metadata.vector } : {}) };
|
||||
if (!validJointDof(joint) || (metadata?.type && metadata.type !== (isLinear(match.axis) ? "prismatic" : "revolute"))) { warnings.push(`${node.name}:关节方向、参考系或类型无效,请手动配置`); return; }
|
||||
if (axes.some((item) => item.axis === match.axis)) duplicateAxes.add(match.axis);
|
||||
const parent = node.parent ?? scene;
|
||||
const evidence: GlbAxisEvidence = {
|
||||
axis: match.axis, nodeName: node.name || `(unnamed ${match.axis})`, path: objectPath(node), parentName: parent.name || "scene", parentPath: objectPath(parent), depth: objectPath(node).split("/").length - 1,
|
||||
originMm: mm(node.position), worldOriginMm: mm(node.getWorldPosition(new THREE.Vector3())), expectedKind: isLinear(match.axis) ? "linear" : "rotary",
|
||||
confidence: match.exact ? "high" : "medium", reason: explicit ? "GLB 关节元数据" : "节点名称提示;默认轴向需运动验证", joint, autoBind: validJointDof(joint),
|
||||
};
|
||||
axes.push(evidence);
|
||||
});
|
||||
const axisList = axes.sort((a, b) => AXES.indexOf(a.axis) - AXES.indexOf(b.axis));
|
||||
for (const item of axisList) if (duplicateAxes.has(item.axis)) item.autoBind = false;
|
||||
const available = new Set(axisList.filter((item) => item.autoBind).map((item) => item.axis));
|
||||
const declared = scene.userData.linuxcncJointModel?.coordinates ?? scene.userData.linuxcncMachine?.coordinates;
|
||||
const declaredValid = typeof declared === "string" && isFiveAxisCoordinates(declared) && [...declared].every((axis) => available.has(axis as CncAxis));
|
||||
const detected = AXES.filter((axis) => available.has(axis)).join("");
|
||||
const suggestedCoordinates = declaredValid ? declared : isFiveAxisCoordinates(detected) ? detected : "UNKNOWN";
|
||||
const coordinateCandidates = suggestedCoordinates === "UNKNOWN" ? [] : [suggestedCoordinates];
|
||||
const missingAxes = typeof declared === "string" && isFiveAxisCoordinates(declared) ? [...declared].filter((axis) => !available.has(axis as CncAxis)) : [];
|
||||
const sorted = [...axisList].sort((a, b) => a.depth - b.depth);
|
||||
const nearestAxis = (item: GlbAxisEvidence) => sorted.filter((other) => item.path.startsWith(`${other.path}/`)).at(-1);
|
||||
const chains = sorted.filter((item) => !sorted.some((other) => nearestAxis(other) === item)).map((leaf) => {
|
||||
const chain = [leaf.nodeName]; let node = nearestAxis(leaf);
|
||||
while (node) { chain.unshift(node.nodeName); node = nearestAxis(node); }
|
||||
return chain;
|
||||
});
|
||||
const linearChain = sorted.filter((item) => isLinear(item.axis)).map((item) => item.nodeName);
|
||||
const rotaryChain = sorted.filter((item) => !isLinear(item.axis)).map((item) => item.nodeName);
|
||||
const suggestedKinematics = coordinateCandidates.length ? "generic-five-axis" : "unknown";
|
||||
if (declared !== undefined && !declaredValid) warnings.push("声明的轴顺序与可用关节不一致,未采用该顺序");
|
||||
if (available.size !== 5) warnings.push(`检测到 ${available.size} 个唯一轴;请配置五个轴及其绑定,不会丢弃多余候选`);
|
||||
if (axisList.some((item) => item.confidence !== "high")) warnings.push("名称仅用于提示轴绑定;请确认轴向、旋转中心和父子关系");
|
||||
if (duplicateAxes.size) warnings.push(`重复轴 ${[...duplicateAxes].join("、")}:保留所有候选,未自动绑定;请手动选择唯一节点`);
|
||||
if (bounds.isEmpty()) warnings.push("模型没有可计算包围盒的网格");
|
||||
const rtcp = { mode: coordinateCandidates.length ? "unverified" as const : "insufficient" as const,
|
||||
reason: "轴名和层级不能证明 RTCP 运动学;请确认刀具/工件链、轴心及匹配的运行适配器",
|
||||
requiredParameters: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] };
|
||||
const boxMin = bounds.isEmpty() ? new THREE.Vector3() : bounds.min;
|
||||
const boxMax = bounds.isEmpty() ? new THREE.Vector3() : bounds.max;
|
||||
return { schema: "linuxcnc.glb-five-axis-analysis/1", filename, nodeCount, meshCount, boundsMm: { min: mm(boxMin), max: mm(boxMax), size: mm(boxMax.clone().sub(boxMin)) }, axes: axisList, missingAxes, linearChain, rotaryChain, chains, coordinateCandidates, suggestedCoordinates, suggestedKinematics, rtcp, warnings };
|
||||
}
|
||||
|
||||
export function analysisSummary(analysis: GlbFiveAxisAnalysis): string {
|
||||
const axes = analysis.axes.map((item) => item.nodeName).join("、") || "未识别关节,可手动绑定";
|
||||
return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · RTCP 待确认`;
|
||||
}
|
||||
@@ -1,25 +1,41 @@
|
||||
import * as THREE from "three";
|
||||
import { bindJointModel, validateJointModel, type JointModel, type AxisValues } from "./machine-joints";
|
||||
import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type AxisValues, type CncAxis, type JointZero } from "./machine-joints";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
|
||||
import { readTrtConfiguration } from "../../workspace/trt-configuration.mjs";
|
||||
import { readTrtGeometry, writeTrtGeometry } from "../../workspace/trt-geometry.mjs";
|
||||
import type { XyzacVisualState } from "./xyzac-visual-adapter";
|
||||
export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial<Record<"x" | "y" | "z" | "a" | "b" | "c", number>> };
|
||||
import { analyzeGlbFiveAxisScene, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
|
||||
export type RawAxisBinding = { axis: CncAxis; path: string; zero: JointZero };
|
||||
export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial<Record<Lowercase<CncAxis>, number>>; glbAnalysis?: GlbFiveAxisAnalysis; rawAxisBindings?: RawAxisBinding[] };
|
||||
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);
|
||||
let inspected: ReturnType<typeof inspectMachineGlb> | null = null;
|
||||
try { inspected = inspectMachineGlb(bytes); } catch { /* Raw five-axis GLBs are analyzed below. */ }
|
||||
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; } });
|
||||
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||
if (!inspected) {
|
||||
const analysis = analyzeGlbFiveAxisScene(gltf.scene, filename);
|
||||
if (analysis.suggestedCoordinates === "UNKNOWN" || analysis.suggestedCoordinates.length !== 5) throw new Error("GLB 未形成可用的五轴结构;请提供三个直线轴和两个旋转轴节点(支持 JT_X、AXIS_X、X_AXIS 等命名)");
|
||||
const rawAxisBindings: RawAxisBinding[] = analysis.axes.filter((item) => analysis.suggestedCoordinates.includes(item.axis)).map((item) => {
|
||||
const node = findScenePathNode(gltf.scene, item.path);
|
||||
if (!node) throw new Error(`GLB 轴节点路径不存在:${item.path}`);
|
||||
return { axis: item.axis as CncAxis, path: item.path, zero: jointZero(node) };
|
||||
});
|
||||
const structure = rawStructure(analysis, gltf.scene.name || filename);
|
||||
const physicalZero = Object.fromEntries([...analysis.suggestedCoordinates].map((axis) => [axis.toLowerCase(), 0])) as ImportedMachine["physicalZero"];
|
||||
return { scene: gltf.scene, structure, hash, filename, physicalZero, glbAnalysis: analysis, rawAxisBindings };
|
||||
}
|
||||
const structure = inspected.structure;
|
||||
if (model) {
|
||||
validateJointModel(model); if (model.coordinates !== structure.coordinates) throw new Error("关节 JSON 与 URDF 机型不一致");
|
||||
bindJointModel(gltf.scene, model);
|
||||
@@ -28,6 +44,19 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
|
||||
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 findScenePathNode(scene: THREE.Object3D, path: string) {
|
||||
const parts = path.split("/").slice(1).map(Number);
|
||||
let node: THREE.Object3D = scene;
|
||||
for (const index of parts) { if (!Number.isInteger(index) || !node.children[index]) return null; node = node.children[index]; }
|
||||
return node;
|
||||
}
|
||||
|
||||
function rawStructure(analysis: GlbFiveAxisAnalysis, name: string): MachineStructure {
|
||||
const coordinates = analysis.suggestedCoordinates;
|
||||
const joints = [...coordinates].map((axis) => ({ name: `raw_${axis.toLowerCase()}_joint`, type: "XYZ".includes(axis) ? "prismatic" : "revolute", parent: "raw_machine", child: `raw_${axis.toLowerCase()}`, origin: [0, 0, 0] as Vec3, axis: defaultDof(axis as CncAxis).direction === "x" ? [1, 0, 0] as Vec3 : defaultDof(axis as CncAxis).direction === "y" ? [0, 1, 0] as Vec3 : [0, 0, 1] as Vec3, coordinate: axis, limit: "XYZ".includes(axis) ? { lower: -1, upper: 1, velocity: 1, effort: 1 } : { lower: -Math.PI * 200, upper: Math.PI * 200, velocity: 100, effort: 1 } }));
|
||||
return { name, profile: "generic-five-axis/1", coordinates: coordinates as any, base: "raw_machine", tool: "raw_tool", work: "raw_work", links: ["raw_machine", "raw_tool", "raw_work"], joints, offsets: { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 0, z_offset: 0 }, urdf: `raw-glb://${coordinates}` };
|
||||
}
|
||||
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 与运行配置机型不同");
|
||||
@@ -53,6 +82,7 @@ export function configureImportedMachine<T extends { entrypoint: string; files:
|
||||
export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: { entrypoint: string; files: Record<string, string> }): ImportedMachine {
|
||||
validateImportedConfiguration(bundle, asset.structure);
|
||||
const { offsets } = readTrtGeometry(bundle);
|
||||
if (asset.rawAxisBindings) return { ...asset, structure: { ...asset.structure, offsets } };
|
||||
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;
|
||||
@@ -80,6 +110,24 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
||||
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
|
||||
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
|
||||
const managed = scene.userData.linuxcncMachineGeometry;
|
||||
if (asset.rawAxisBindings) {
|
||||
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 bindings = asset.rawAxisBindings.map((binding) => ({ ...binding, node: findScenePathNode(scene, binding.path)! }));
|
||||
const byAxis = new Map(bindings.map((binding) => [binding.axis, binding]));
|
||||
const toolTip = new THREE.Group(); toolTip.name = "raw-glb-tool-tip"; scene.add(toolTip);
|
||||
const workOrigin = new THREE.Group(); workOrigin.name = "raw-glb-work-origin"; scene.add(workOrigin);
|
||||
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
|
||||
for (const [index, axis] of [...asset.structure.coordinates].entries()) {
|
||||
const binding = byAxis.get(axis as CncAxis); if (!binding) continue;
|
||||
driveJoint(binding.node, binding.zero, defaultDof(axis as CncAxis), state.joints[index], Number(physicalZero?.[axis.toLowerCase() as keyof NonNullable<ImportedMachine["physicalZero"]>] ?? 0));
|
||||
}
|
||||
machine.updateMatrixWorld(true);
|
||||
machine.userData.linuxcncJointState = Object.fromEntries(bindings.map((binding) => [binding.axis, { path: binding.path, world: binding.node.getWorldPosition(new THREE.Vector3()).toArray() }]));
|
||||
};
|
||||
return { machine, toolTip, workOrigin, joints: { rotate: machine }, update, dispose: () => undefined };
|
||||
}
|
||||
if (managed?.schema === "linuxcnc.machine-manager/1") {
|
||||
const clean = (value: number) => Object.is(value, -0) ? 0 : value;
|
||||
const effective = { ...managed, cOriginMm: [asset.structure.offsets.x_rot_point, asset.structure.offsets.y_rot_point, asset.structure.offsets.z_rot_point].map(clean), tiltOffsetMm: [asset.structure.offsets.x_offset, asset.structure.offsets.y_offset, asset.structure.offsets.z_offset].map(clean), physicalZero: { ...(asset.physicalZero ?? managed.physicalZero) } };
|
||||
@@ -116,7 +164,7 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
||||
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 coordinate = joint.coordinate.toLowerCase() as Lowercase<CncAxis>;
|
||||
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);
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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 const CNC_AXES = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"] as const;
|
||||
export type CncAxis = typeof CNC_AXES[number];
|
||||
export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z" | "custom"; sign: 1 | -1; vector?: Vec3; frame?: "parent" | "local" };
|
||||
export const isFiveAxisCoordinates = (value: string) => typeof value === "string" && value.length === 5 && new Set(value).size === 5 && [...value].every((axis) => CNC_AXES.includes(axis as CncAxis));
|
||||
export const isTrtCoordinates = (value: string): value is "XYZAC" | "XYZBC" => value === "XYZAC" || value === "XYZBC";
|
||||
export type AxisValues = Partial<Record<CncAxis, number>>;
|
||||
export type JointZero = { positionMm: Vec3; quaternion: [number, number, number, number]; scale: Vec3 };
|
||||
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;
|
||||
joint: (JointDof & { dof: 1; type: "prismatic" | "revolute"; unit: "mm" | "deg"; frame: "parent" | "local" }) | null;
|
||||
};
|
||||
export type JointModel = {
|
||||
schema: "linuxcnc.joint-model/1";
|
||||
coordinates: "XYZAC" | "XYZBC";
|
||||
coordinates: string;
|
||||
units: { length: "mm"; angle: "deg"; gltfLength: "m" };
|
||||
controller: { source: "linuxcnc-wasm.visual.joints"; axisOrder: CncAxis[] };
|
||||
calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: Record<string, number> };
|
||||
@@ -21,28 +24,34 @@ export type JointModel = {
|
||||
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 isLinear = (axis: CncAxis) => "XYZUVW".includes(axis);
|
||||
export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z", U: "x", V: "y", W: "z" } as const)[axis], sign: 1 });
|
||||
export const jointFrame = (dof: JointDof) => dof.frame ?? "parent";
|
||||
export const validJointDof = (dof: JointDof) => Boolean(dof && CNC_AXES.includes(dof.axis) && ["x", "y", "z", "custom"].includes(dof.direction) && [1, -1].includes(dof.sign) && ["parent", "local"].includes(jointFrame(dof)) && (dof.direction !== "custom" || (Array.isArray(dof.vector) && dof.vector.length === 3 && dof.vector.every(Number.isFinite) && Math.hypot(...dof.vector) > 1e-9)));
|
||||
export const jointDirection = (dof: JointDof) => dof.direction === "custom" ? new THREE.Vector3(...dof.vector!).normalize() : new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
|
||||
export const 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.
|
||||
// Parent-frame motion remains the default; explicit GLB metadata may use local axes.
|
||||
export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | null, value: number, physicalZero = 0) {
|
||||
if (dof && ![value, physicalZero].every(Number.isFinite)) throw new Error(`轴 ${dof.axis} 位置无效`);
|
||||
if (dof && (!validJointDof(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 direction = jointDirection(dof);
|
||||
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)));
|
||||
if (isLinear(dof.axis)) node.position.addScaledVector(jointFrame(dof) === "local" ? direction.applyQuaternion(node.quaternion) : direction, q / 1000);
|
||||
else {
|
||||
const rotation = new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q));
|
||||
if (jointFrame(dof) === "local") node.quaternion.multiply(rotation); else node.quaternion.premultiply(rotation);
|
||||
}
|
||||
}
|
||||
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 || model.schema !== JOINT_MODEL_SCHEMA || !isFiveAxisCoordinates(model.coordinates)) fail("需要五个不重复的数控轴");
|
||||
if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints" || model.controller.axisOrder.join("") !== model.coordinates) fail("控制器来源、单位或轴顺序错误");
|
||||
if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
|
||||
const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
|
||||
@@ -54,7 +63,7 @@ export function validateJointModel(model: JointModel, complete = true) {
|
||||
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 (![...model.coordinates].includes(joint.axis) || joint.dof !== 1 || !validJointDof(joint) || !["parent", "local"].includes(joint.frame) || 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);
|
||||
}
|
||||
|
||||
32
web/react/src/machine-manager-config.ts
Normal file
32
web/react/src/machine-manager-config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import settings from "../../workspace/machine-manager-settings.json";
|
||||
import { isFiveAxisCoordinates, type CncAxis } from "./machine-joints";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
// These profiles describe the managed-GLB runtime adapters, independently of
|
||||
// the wider catalog of LinuxCNC program/kinematics examples.
|
||||
export const MANAGER_SETTINGS = settings;
|
||||
export const DEFAULT_MANAGER_COORDINATES = settings.profiles[0]?.coordinates ?? "";
|
||||
export const managerProfile = (coordinates: string) => settings.profiles.find((profile) => profile.coordinates === coordinates);
|
||||
export const managerAxes = (coordinates: string) => [...new Set(coordinates)] as CncAxis[];
|
||||
export const zeroAxisValues = (coordinates: string, previous: Record<string, number> = {}) =>
|
||||
Object.fromEntries(managerAxes(coordinates).map((axis) => [axis.toLowerCase(), previous[axis.toLowerCase()] ?? 0]));
|
||||
export const roleAxis = (role: string, coordinates: string): CncAxis | undefined => {
|
||||
const profile = managerProfile(coordinates);
|
||||
const axis = role === "tilt" ? profile?.tiltAxis : role === "c" ? profile?.rotaryAxis : settings.roles.find((item) => item.id === role)?.axis;
|
||||
return axis && coordinates.includes(axis) ? axis as CncAxis : undefined;
|
||||
};
|
||||
export function validateManagerCalibration(coordinates: string, origin: Vec3, offset: Vec3) {
|
||||
if (![origin, offset].every((v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite))) throw new Error("标定需要三个有限数值");
|
||||
const profile = managerProfile(coordinates);
|
||||
if (!profile) return;
|
||||
const index = ["x", "y", "z"].indexOf(profile.forbiddenOffsetDirection);
|
||||
if (Math.abs(offset[index]) > 1e-9) throw new Error(`${profile.tiltAxis}→${profile.rotaryAxis} 偏置 ${profile.forbiddenOffsetDirection.toUpperCase()} 必须为 0(当前运动学约束)`);
|
||||
}
|
||||
|
||||
for (const profile of settings.profiles) {
|
||||
if (!isFiveAxisCoordinates(profile.coordinates) || !profile.coordinates.includes(profile.tiltAxis) || !profile.coordinates.includes(profile.rotaryAxis) || !["x", "y", "z"].includes(profile.forbiddenOffsetDirection) || !Array.isArray(profile.tiltDirection) || profile.tiltDirection.length !== 3 || !profile.tiltDirection.every(Number.isFinite) || !Array.isArray(profile.rotaryDirection) || profile.rotaryDirection.length !== 3 || !profile.rotaryDirection.every(Number.isFinite)) throw new Error("管理器机型配置无效");
|
||||
for (const axis of managerAxes(profile.coordinates)) {
|
||||
const limit = profile.limits[axis as keyof typeof profile.limits];
|
||||
if (!limit || limit.length !== 3 || !limit.every(Number.isFinite) || limit[0] >= limit[1] || limit[2] <= 0) throw new Error(`管理器 ${axis} 行程配置无效`);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useReducer } from "react";
|
||||
import type { MachinePartDraft, PhysicalZero } from "./machine-manager";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
import { DEFAULT_MANAGER_COORDINATES, zeroAxisValues } from "./machine-manager-config";
|
||||
|
||||
export type MachineDraft = { documentInfo?: { name: string; description: string }; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||
export type MachineDraft = { documentInfo?: { name: string; description: string }; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||
type History = { current: MachineDraft; past: MachineDraft[]; future: MachineDraft[] };
|
||||
type Action = { type: "edit"; change: (draft: MachineDraft) => MachineDraft } | { type: "undo" | "redo" | "clear" };
|
||||
const initial: MachineDraft = { documentInfo: { name: "", description: "" }, coordinates: "XYZAC", parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 } };
|
||||
const zeroFor = (coordinates: string, previous: PhysicalZero = {}) => zeroAxisValues(coordinates, previous);
|
||||
const initial: MachineDraft = { documentInfo: { name: "", description: "" }, coordinates: DEFAULT_MANAGER_COORDINATES, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: zeroFor(DEFAULT_MANAGER_COORDINATES) };
|
||||
export function machineDraftHistory(state: History, action: Action): History {
|
||||
if (action.type === "clear") return { current: state.current, past: [], future: [] };
|
||||
if (action.type === "undo") { const current = state.past.at(-1); return current ? { current, past: state.past.slice(0, -1), future: [state.current, ...state.future] } : state; }
|
||||
@@ -15,7 +17,7 @@ export function machineDraftHistory(state: History, action: Action): History {
|
||||
if (current === state.current || (draftFingerprint(current) === draftFingerprint(state.current) && current.parts.every((part, i) => part.showOrigin === state.current.parts[i]?.showOrigin && part.scene === state.current.parts[i]?.scene))) return state;
|
||||
return { current, past: [...state.past, state.current].slice(-30), future: [] };
|
||||
}
|
||||
export function useMachineDraft(coordinates: MachineDraft["coordinates"] = "XYZAC") {
|
||||
export function useMachineDraft(coordinates: MachineDraft["coordinates"] = DEFAULT_MANAGER_COORDINATES) {
|
||||
const [history, dispatch] = useReducer(machineDraftHistory, { current: { ...initial, coordinates }, past: [], future: [] });
|
||||
const edit = useCallback((change: (draft: MachineDraft) => MachineDraft) => dispatch({ type: "edit", change }), []);
|
||||
return { history, draft: history.current, edit, undo: () => dispatch({ type: "undo" }), redo: () => dispatch({ type: "redo" }), clearHistory: useCallback(() => dispatch({ type: "clear" }), []) };
|
||||
@@ -30,5 +32,5 @@ export function changeMachineType(draft: MachineDraft, coordinates: MachineDraft
|
||||
if (draft.coordinates === coordinates) return draft;
|
||||
return { ...draft, coordinates, parts: draft.parts.map((part) => ({ ...part,
|
||||
nodeJoints: Object.fromEntries(Object.entries(part.nodeJoints ?? {}).filter(([, joint]) => coordinates.includes(joint.axis))),
|
||||
})) };
|
||||
})), physicalZero: zeroFor(coordinates, draft.physicalZero) };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-ur
|
||||
import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
|
||||
import { decodeEditorDocument, encodeEditorDocument, partSettings, type DocumentInfo } from "./machine-document";
|
||||
import type { ImportedMachine } from "./imported-machine";
|
||||
import { MANAGER_SETTINGS, managerProfile, roleAxis } from "./machine-manager-config";
|
||||
|
||||
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
|
||||
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record<string, JointDof>; restoredRoles?: Record<string, MachinePartRole | "">; restoredModel?: JointModel; restoredParts?: MachinePartDraft[]; restoredInfo?: DocumentInfo };
|
||||
@@ -22,19 +23,24 @@ export type MachinePartDraft = MachinePartSource & {
|
||||
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 };
|
||||
/** Per-coordinate machine zero, keyed by the active LinuxCNC axis letters. */
|
||||
export type PhysicalZero = Record<string, number>;
|
||||
export type ManagedMachineGeometry = {
|
||||
schema: "linuxcnc.machine-manager/1";
|
||||
coordinates: "XYZAC" | "XYZBC";
|
||||
coordinates: string;
|
||||
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>; nodeJoints?: Record<string, JointDof> }>;
|
||||
};
|
||||
|
||||
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);
|
||||
export const MACHINE_PART_ROLES: MachinePartRole[] = MANAGER_SETTINGS.roles.map((role) => role.id as MachinePartRole);
|
||||
export const roleLabel = (role: MachinePartRole, coordinates: string) => {
|
||||
const configured = MANAGER_SETTINGS.roles.find((item) => item.id === role)?.label;
|
||||
const axis = roleAxis(role, coordinates);
|
||||
return role === "tilt" || role === "c" ? `${axis ?? "旋转"} 轴部件` : configured ?? role;
|
||||
};
|
||||
export const expectedParentRole = (role: MachinePartRole): MachinePartRole | null => MANAGER_SETTINGS.roles.find((item) => item.id === role)?.parent as MachinePartRole | null ?? null;
|
||||
|
||||
|
||||
const finite3 = (value: Vec3, label: string) => {
|
||||
@@ -164,8 +170,8 @@ export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Pro
|
||||
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("应用前需要正好八个机床角色部件");
|
||||
export function validateMachineAssembly(coordinates: string, parts: MachinePartDraft[]) {
|
||||
if (parts.length !== MACHINE_PART_ROLES.length) throw new Error(`应用前需要 ${MACHINE_PART_ROLES.length} 个机床角色部件`);
|
||||
const byRole = new Map<MachinePartRole, MachinePartDraft>();
|
||||
const ids = new Set(parts.map((part) => part.id));
|
||||
if (ids.size !== parts.length) throw new Error("部件 ID 重复");
|
||||
@@ -186,32 +192,39 @@ export function validateMachineAssembly(coordinates: "XYZAC" | "XYZBC", parts: M
|
||||
return byRole;
|
||||
}
|
||||
|
||||
function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffsetMm: Vec3) {
|
||||
function machineUrdf(coordinates: string, cOriginMm: Vec3, tiltOffsetMm: Vec3) {
|
||||
const profile = managerProfile(coordinates);
|
||||
if (!profile) throw new Error(`未找到 ${coordinates} 的机床管理器配置`);
|
||||
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 offsetDirection = profile.forbiddenOffsetDirection;
|
||||
const offsetIndex = ["x", "y", "z"].indexOf(offsetDirection);
|
||||
if (Math.abs(tiltOffsetMm[offsetIndex]) > 1e-9) throw new Error(`${profile.tiltAxis}→${profile.rotaryAxis} 偏置 ${offsetDirection.toUpperCase()} 必须为 0`);
|
||||
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 tilt = profile.tiltAxis;
|
||||
const tiltAxis = profile.tiltDirection.join(" ");
|
||||
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]),
|
||||
const limits = (axis: string) => profile.limits[axis as keyof typeof profile.limits] ?? (() => { throw new Error(`未配置 ${axis} 轴限位`); })();
|
||||
const linearLimits = (axis: string) => limits(axis).map((value) => value / 1000);
|
||||
const rotaryLimits = (axis: string) => limits(axis).map((value) => radians(value));
|
||||
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="${profile.effort}"/>` : ""}\n </joint>`;
|
||||
return `<?xml version="1.0"?>\n<robot name="managed_${coordinates.toLowerCase()}" xmlns:cnc="urn:linuxcnc:web:machine:1">\n <cnc:machine profile="${profile.urdfProfile}" 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", linearLimits("X")),
|
||||
joint("y_joint", "prismatic", "x", "y", [0, 0, 0], "0 1 0", "Y", linearLimits("Y")),
|
||||
joint("z_joint", "prismatic", "y", "z", [0, 0, 0], "0 0 1", "Z", linearLimits("Z")),
|
||||
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("tilt_joint", "revolute", "base", "tilt", t, tiltAxis, tilt, rotaryLimits(tilt)),
|
||||
joint("c_joint", "revolute", "tilt", "c", u, profile.rotaryDirection.join(" "), profile.rotaryAxis, rotaryLimits(profile.rotaryAxis)),
|
||||
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; documentInfo?: DocumentInfo }): Promise<ImportedMachine> {
|
||||
export async function buildManagedMachine(input: { coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise<ImportedMachine> {
|
||||
if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
|
||||
if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
|
||||
const byRole = validateMachineAssembly(input.coordinates, input.parts);
|
||||
for (const [axis, value] of Object.entries(input.physicalZero)) if (!Number.isFinite(value)) throw new Error(`${axis.toUpperCase()} 物理零点无效`);
|
||||
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm);
|
||||
@@ -241,14 +254,14 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
|
||||
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 {
|
||||
export function draftNodeDof(part: MachinePartDraft, path: string, coordinates: string): 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() : "";
|
||||
const axis = role === "tilt" ? coordinates[3] : role === "c" ? coordinates[4] : role && ["x", "y", "z"].includes(role) ? role.toUpperCase() : "";
|
||||
return axis ? defaultDof(axis as CncAxis) : null;
|
||||
}
|
||||
|
||||
type ManagedInput = { documentInfo?: DocumentInfo; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||
type ManagedInput = { documentInfo?: DocumentInfo; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||
|
||||
export function makeJointAssembly(input: ManagedInput, deepClone = false) {
|
||||
if (!input.parts.length) throw new Error("请先导入机床模型");
|
||||
@@ -287,6 +300,7 @@ export function makeJointAssembly(input: ManagedInput, deepClone = false) {
|
||||
async function buildJointManagedMachine(input: ManagedInput): Promise<ImportedMachine> {
|
||||
// Validate using shallow transform clones before allocating deep runtime resources.
|
||||
validateJointModel(makeJointAssembly(input).model);
|
||||
if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
|
||||
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm), structure = parseMachineUrdf(urdf);
|
||||
const { assembly, model } = makeJointAssembly(input, true);
|
||||
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
|
||||
|
||||
55
web/react/src/machine-studio-lighting.ts
Normal file
55
web/react/src/machine-studio-lighting.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as THREE from "three";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
|
||||
// One display pipeline for the GLB editor and controller. Keep these settings
|
||||
// outside model data: they must never change exported paint, metal or textures.
|
||||
export const MACHINE_STUDIO = Object.freeze({
|
||||
lighting: "neutral-studio-pmrem",
|
||||
toneMapping: "AgX",
|
||||
exposure: 1,
|
||||
environmentIntensity: .65,
|
||||
hemisphereIntensity: .2,
|
||||
keyIntensity: 2.4,
|
||||
fillIntensity: .55,
|
||||
rimIntensity: .65,
|
||||
});
|
||||
|
||||
export function installMachineStudio(renderer: THREE.WebGLRenderer, scene: THREE.Scene, up: "y" | "z") {
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.AgXToneMapping;
|
||||
renderer.toneMappingExposure = MACHINE_STUDIO.exposure;
|
||||
renderer.domElement.dataset.machineToneMapping = MACHINE_STUDIO.toneMapping;
|
||||
renderer.domElement.dataset.machineExposure = String(MACHINE_STUDIO.exposure);
|
||||
renderer.domElement.dataset.machineLighting = MACHINE_STUDIO.lighting;
|
||||
|
||||
const room = new RoomEnvironment();
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
const environment = pmrem.fromScene(room, .04);
|
||||
room.dispose(); pmrem.dispose();
|
||||
scene.environment = environment.texture;
|
||||
scene.environmentIntensity = MACHINE_STUDIO.environmentIntensity;
|
||||
// RoomEnvironment is Y-up; the editor's assembly is Z-up.
|
||||
scene.environmentRotation.set(up === "z" ? Math.PI / 2 : 0, 0, 0);
|
||||
|
||||
const rig = new THREE.Group(); rig.name = "neutral-machine-studio";
|
||||
const position = (object: THREE.Object3D, x: number, y: number, z: number) => {
|
||||
if (up === "z") object.position.set(x, y, z);
|
||||
else object.position.set(x, z, -y);
|
||||
};
|
||||
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x505050, MACHINE_STUDIO.hemisphereIntensity);
|
||||
position(hemisphere, 0, 0, 1); rig.add(hemisphere);
|
||||
const key = new THREE.DirectionalLight(0xffffff, MACHINE_STUDIO.keyIntensity);
|
||||
position(key, 3, -4, 6); rig.add(key, key.target);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, MACHINE_STUDIO.fillIntensity);
|
||||
position(fill, -4, -1, 3); rig.add(fill, fill.target);
|
||||
const rim = new THREE.DirectionalLight(0xffffff, MACHINE_STUDIO.rimIntensity);
|
||||
position(rim, 1, 4, 4); rig.add(rim, rim.target);
|
||||
scene.add(rig);
|
||||
|
||||
return { key, dispose() {
|
||||
rig.removeFromParent();
|
||||
key.shadow.map?.dispose();
|
||||
if (scene.environment === environment.texture) scene.environment = null;
|
||||
environment.dispose();
|
||||
} };
|
||||
}
|
||||
@@ -3,10 +3,12 @@ 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;
|
||||
// Large, detailed industrial machine assemblies can exceed 32 MiB before
|
||||
// compression. Keep one shared limit for import, validation and export.
|
||||
export const MAX_MACHINE_BYTES = 64 * 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 };
|
||||
export type MachineStructure = { name: string; profile: string; coordinates: string; 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("名称缺失或含不支持的字符");
|
||||
@@ -85,7 +87,7 @@ export function parseMachineUrdf(urdf: string): MachineStructure {
|
||||
}
|
||||
|
||||
export function inspectMachineGlb(bytes: ArrayBuffer): { json: any; structure: MachineStructure } {
|
||||
if (bytes.byteLength < 20 || bytes.byteLength > MAX_MACHINE_BYTES) fail("GLB 大小必须在 20 字节至 32 MiB 之间");
|
||||
if (bytes.byteLength < 20 || bytes.byteLength > MAX_MACHINE_BYTES) fail("GLB 大小必须在 20 字节至 64 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;
|
||||
|
||||
@@ -438,7 +438,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.machine-preview-area { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; border: 1px solid #52666a; border-radius: 5px; overflow: hidden; background: #263438; }
|
||||
.machine-preview-tools { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; padding: 6px; background: #e3eceb; flex-wrap: wrap; }
|
||||
.machine-preview-tools small { margin-left: auto; font-size: 11px; }
|
||||
.machine-manager-preview-shell { flex: 1; min-height: 0; }
|
||||
.machine-manager-preview-shell { position: relative; flex: 1; min-height: 0; }
|
||||
.machine-manager-preview { position: absolute; inset: 0; min-height: 0; height: 100%; border: 0; }
|
||||
.machine-preview-hint { flex: 0 0 auto; color: #d1e2df; font-size: 11px; margin: 0; padding: 6px 10px; }
|
||||
.machine-preview-empty { position: absolute; inset: 48px 16px 40px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; color: #e3eeeb; pointer-events: none; }
|
||||
@@ -451,10 +451,37 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.machine-editor-tabs [aria-selected="true"] { background: #27585d; color: #fff; border-color: #27585d; }
|
||||
.machine-manager-dialog [hidden] { display: none !important; }
|
||||
.machine-structure-panel { display: grid; grid-template-rows: minmax(125px, .9fr) minmax(190px, 1.1fr); gap: 8px; min-height: 0; flex: 1; }
|
||||
.machine-part-list { display: flex; flex-direction: column; overflow: hidden; border-radius: 4px; }
|
||||
.machine-part-list { display: flex; flex-direction: column; overflow: auto; border-radius: 4px; }
|
||||
.machine-part-list > header { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.machine-part-list > header span { color: #617576; font-size: 11px; }
|
||||
.machine-part-list > .machine-tree { flex: 1; min-height: 0; overflow: auto; }
|
||||
.machine-part-list > .machine-tree { flex: 1 0 90px; min-height: 90px; overflow: auto; }
|
||||
.machine-part-list > header, .machine-part-list > .machine-tree-tools, .machine-part-list > .glb-five-axis-analysis { flex-shrink: 0; }
|
||||
.machine-model-list { display: grid; flex: 0 0 auto; gap: 4px; margin: 5px 0 7px; max-height: 116px; overflow: auto; }
|
||||
.machine-model-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; align-items: stretch; border: 1px solid #c5d7d3; border-radius: 4px; background: #fff; }
|
||||
.machine-model-row.selected { border-color: #338187; box-shadow: inset 3px 0 #338187; background: #edf7f4; }
|
||||
.machine-model-row button:focus-visible { outline: 2px solid #2b858a; outline-offset: -2px; }
|
||||
.machine-model-row > button:first-child { min-width: 0; padding: 5px 7px; text-align: left; background: transparent; }
|
||||
.machine-model-row > button:first-child strong, .machine-model-row > button:first-child span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-model-row > button:first-child strong { color: #245b5e; font-size: 11px; }
|
||||
.machine-model-row > button:first-child span { margin-top: 2px; color: #627777; font-size: 10px; }
|
||||
.machine-model-row > button:last-child { align-self: center; margin: 4px; padding: 3px 6px; border: 1px solid #d3aba1; border-radius: 3px; color: #8b2d24; background: #fff; font-size: 10px; }
|
||||
.glb-five-axis-analysis { margin: 6px 0 8px; padding: 7px; border: 1px solid #9fc3bd; border-radius: 4px; background: #edf6f3; color: #315d5f; font-size: 11px; }
|
||||
.glb-five-axis-analysis > header { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.glb-five-axis-analysis h3 { margin: 0; color: #245e60; font-size: 12px; }
|
||||
.glb-five-axis-analysis button { padding: 3px 6px; border: 1px solid #9abcb5; border-radius: 3px; background: #fff; color: #245e60; font-size: 11px; }
|
||||
.glb-analysis-summary { margin: 5px 0; font-weight: 600; }
|
||||
.glb-analysis-grid, .glb-analysis-axes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
|
||||
.glb-analysis-grid span { padding: 4px; background: #fff; border: 1px solid #c5dcd7; border-radius: 3px; }
|
||||
.glb-analysis-grid strong { display: block; margin-top: 2px; color: #163f43; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.glb-analysis-axes { grid-template-columns: repeat(6, minmax(0, 1fr)); margin-top: 5px; }
|
||||
.glb-analysis-axes > span { min-width: 0; padding: 4px 2px; text-align: center; border-radius: 3px; background: #fff; border: 1px solid #c5dcd7; }
|
||||
.glb-analysis-axes > span.detected { border-color: #4a9990; background: #e1f2ed; }
|
||||
.glb-analysis-axes > span.missing { opacity: .6; }
|
||||
.glb-analysis-axes b { display: block; color: #1b5659; font-size: 13px; }
|
||||
.glb-analysis-axes small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 9px; }
|
||||
.glb-five-axis-analysis details { margin-top: 5px; }
|
||||
.glb-five-axis-analysis details p { margin: 4px 0; }
|
||||
.glb-five-axis-analysis details ul { margin: 4px 0 0; padding-left: 16px; }
|
||||
.machine-tree-tools { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||
.machine-tree-tools input { width: 100%; min-width: 0; }
|
||||
.machine-tree-tools button { white-space: nowrap; padding: 3px 7px; }
|
||||
|
||||
153
web/workspace/machine-manager-settings.json
Normal file
153
web/workspace/machine-manager-settings.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"roles": [
|
||||
{
|
||||
"id": "base",
|
||||
"label": "床身底座",
|
||||
"parent": null,
|
||||
"axis": null
|
||||
},
|
||||
{
|
||||
"id": "x",
|
||||
"label": "X 轴部件",
|
||||
"parent": "base",
|
||||
"axis": "X"
|
||||
},
|
||||
{
|
||||
"id": "y",
|
||||
"label": "Y 轴部件",
|
||||
"parent": "x",
|
||||
"axis": "Y"
|
||||
},
|
||||
{
|
||||
"id": "z",
|
||||
"label": "Z 轴部件",
|
||||
"parent": "y",
|
||||
"axis": "Z"
|
||||
},
|
||||
{
|
||||
"id": "tool",
|
||||
"label": "主轴/刀具",
|
||||
"parent": "z",
|
||||
"axis": null
|
||||
},
|
||||
{
|
||||
"id": "tilt",
|
||||
"label": "倾转部件",
|
||||
"parent": "base",
|
||||
"axis": null
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"label": "回转部件",
|
||||
"parent": "tilt",
|
||||
"axis": null
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"label": "工件/工作台",
|
||||
"parent": "c",
|
||||
"axis": null
|
||||
}
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"caseId": "xyzac-trt",
|
||||
"coordinates": "XYZAC",
|
||||
"urdfProfile": "trt-xyz-head/1",
|
||||
"tiltAxis": "A",
|
||||
"rotaryAxis": "C",
|
||||
"forbiddenOffsetDirection": "x",
|
||||
"limits": {
|
||||
"X": [
|
||||
-200,
|
||||
200,
|
||||
20
|
||||
],
|
||||
"Y": [
|
||||
-100,
|
||||
100,
|
||||
20
|
||||
],
|
||||
"Z": [
|
||||
-120,
|
||||
120,
|
||||
20
|
||||
],
|
||||
"A": [
|
||||
-100,
|
||||
50,
|
||||
30
|
||||
],
|
||||
"C": [
|
||||
-36000,
|
||||
36000,
|
||||
30
|
||||
]
|
||||
},
|
||||
"effort": 1000,
|
||||
"tiltDirection": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotaryDirection": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"caseId": "xyzbc-trt",
|
||||
"coordinates": "XYZBC",
|
||||
"urdfProfile": "trt-xyz-head/1",
|
||||
"tiltAxis": "B",
|
||||
"rotaryAxis": "C",
|
||||
"forbiddenOffsetDirection": "y",
|
||||
"limits": {
|
||||
"X": [
|
||||
-200,
|
||||
200,
|
||||
20
|
||||
],
|
||||
"Y": [
|
||||
-100,
|
||||
100,
|
||||
20
|
||||
],
|
||||
"Z": [
|
||||
-120,
|
||||
120,
|
||||
20
|
||||
],
|
||||
"B": [
|
||||
-36000,
|
||||
36000,
|
||||
30
|
||||
],
|
||||
"C": [
|
||||
-36000,
|
||||
36000,
|
||||
30
|
||||
]
|
||||
},
|
||||
"effort": 1000,
|
||||
"tiltDirection": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"rotaryDirection": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"historyLimit": 30,
|
||||
"jogSteps": [
|
||||
0.1,
|
||||
1,
|
||||
10
|
||||
],
|
||||
"initialJogStep": 1
|
||||
}
|
||||
Reference in New Issue
Block a user