Compare commits

...

2 Commits

15 changed files with 711 additions and 115 deletions

View File

@@ -11,6 +11,11 @@
五轴机床 INI 参数化设计(XYZAC / XYZBC)的当前工作入口:
[`项目文档、任务矩阵与验收证据`](docs/trt-parametric/README.md)。本专项按该索引接续。
LinuxCNC 五轴/多自由度机床结构案例、运动学源码、示例 G 代码和 Web/WASM 适配状态:
[`LinuxCNC 五轴机床案例目录`](docs/linuxcnc-five-axis-case-catalog.md)。界面使用同一份
[`five-axis-machine-catalog.json`](web/workspace/five-axis-machine-catalog.json);本地案例路径可用
`node scripts/validate_five_axis_catalog.mjs` 复核。
本仓库的目标是将 LinuxCNC `2.10.0~pre0` 的仿真行为移植到浏览器。当前已完成 posemath/TP、rs274ngc interpreter 和 fixed-tick motion queue 核心切片;这仍不是 LinuxCNC 全功能完成声明。
完整仿真功能范围、任务分解和 M0-M8 里程碑见 [`完整仿真移植实施计划`](docs/linuxcnc-web-wasm-complete-simulation-plan.md);技术实施接续入口为 [`nextTask1.md`](nextTask1.md)。

View File

@@ -0,0 +1,11 @@
# 三维机床管理器配置入口
机床角色、默认父级、管理器支持的 TRT profile、导出限位、轴向、偏置约束及试动步长集中在 `web/workspace/machine-manager-settings.json`。`web/react/src/machine-manager-config.ts` 读取并检查该配置。
对话框按草稿坐标生成零点、运动测试和轴绑定总览,机型列表来自五轴案例目录。切换坐标后清除不兼容的绑定并初始化新轴零点,撤销可恢复原草稿。底层数控轴类型包含 XYZABCUVW。
边界:管理器生成完整 GLB 并应用的运行适配器目前仍为 XYZAC、XYZBC TRT;案例列表存在某坐标不代表管理器已支持该案例的结构及 RTCP。任意五个唯一轴的 JSON 校验也不代表重复轴、附加关节、机器人或并联机构已经完成三维适配。配置文件不能替代运动学适配代码。
2026-09-30 保存验证:使用暂存文件生成独立源码快照,TypeScript 检查和 Vite 生产构建通过。构建使用现有 node_modules、LinuxCNC 资源和本地 WASM 二进制;保留大 chunk 提示。本轮不把既有浏览器报告作为新验收证据。
脚本目录迁移、通用 WASM 运行时开发、原始大型 GLB 以及 LinuxCNC 子模块本地修改不包含在本次管理器配置提交中,仍留在工作区。

File diff suppressed because one or more lines are too long

View File

@@ -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,22 +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]);
@@ -38,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;
@@ -80,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);
@@ -105,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);
@@ -126,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 }) {
@@ -173,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]), []);
@@ -192,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;
@@ -206,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; }
@@ -220,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) {
@@ -227,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;
@@ -271,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);
@@ -339,22 +437,33 @@ 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>
@@ -362,35 +471,39 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
<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;
if (!supportedCoordinateOptions.has(next)) { setMessage(`${next} 已登记源码案例,但当前 Web/WASM 运动学适配尚未完成`); return; }
edit((draft) => changeMachineType(draft, next as "XYZAC" | "XYZBC")); setTab("structure");
setMessage(`设计机型已选择 ${next};保留模型层级和 XYZ/C 绑定,请重新配置 ${next[3]} 轴并核对标定。可撤销恢复。`);
}}>{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><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, next)); setTab("structure");
setMessage(`设计机型已选择 ${next};保留模型层级,请核对 ${next} 轴绑定和标定。可撤销恢复。`);
}}>{catalogCoordinateOptions.map((axisSet) => { const caseItem = FIVE_AXIS_CASES.find((item) => item.coordinates === axisSet); return <option key={axisSet} value={axisSet} disabled={!supportedCoordinateOptions.has(axisSet)}>{caseItem?.label ?? axisSet}{supportedCoordinateOptions.has(axisSet) ? "" : " · 待 WASM 适配"}</option>; })}</select></label><label><strong>LinuxCNC 案例/运动学</strong><select data-axis-control="machine-manager-case-type" aria-label="LinuxCNC 案例与运动学" value={FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates && item.webWasm === "supported")?.id ?? FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates)?.id ?? FIVE_AXIS_CASES[0].id} disabled={pending || followLive} onChange={(event) => {
const selectedCase = FIVE_AXIS_CASES.find((item) => item.id === event.target.value);
if (!selectedCase || !supportedCoordinateOptions.has(selectedCase.coordinates)) { setMessage(`${selectedCase?.label ?? event.target.value} 已登记源码案例,但当前 Web/WASM 运动学适配尚未完成`); return; }
edit((draft) => changeMachineType(draft, selectedCase.coordinates)); setTab("structure"); setMessage(`已选择案例 ${selectedCase.label},设计机型同步为 ${selectedCase.coordinates}`);
}}>{FIVE_AXIS_CASES.map((item) => <option key={item.id} value={item.id} disabled={item.webWasm !== "supported"}>{item.label}{item.webWasm === "supported" ? "" : " · 待 WASM 适配"}</option>)}</select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库</small></div></div>
<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>}
@@ -399,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); }} />

View File

@@ -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")}

View 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 待确认`;
}

View File

@@ -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);

View File

@@ -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);
}

View 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} 行程配置无效`);
}
}

View File

@@ -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) };
}

View File

@@ -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}`;

View 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();
} };
}

View File

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

View File

@@ -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; }

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