Improve machine manager engineering workbench
This commit is contained in:
14
web/react/src/MachineManagerInputs.tsx
Normal file
14
web/react/src/MachineManagerInputs.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
|
||||
type Props = { value: number; onChange: (value: number) => void; label?: string; disabled?: boolean; control?: string; fieldId: string; onValidity: (field: string, valid: boolean) => void };
|
||||
export function MachineNumberInput({ value, onChange, label, disabled, control, fieldId, onValidity }: Props) {
|
||||
const [text, setText] = useState(String(value)), [invalid, setInvalid] = useState(false);
|
||||
useLayoutEffect(() => { setText(Number.isFinite(value) ? String(Number(value.toFixed(6))) : ""); setInvalid(false); onValidity(fieldId, true); }, [value, fieldId, onValidity]);
|
||||
useEffect(() => () => onValidity(fieldId, true), [fieldId, onValidity]);
|
||||
return <span className="machine-number-field"><input data-axis-control={control} aria-label={label} type="number" step="any" inputMode="decimal" disabled={disabled} value={text} aria-invalid={invalid} aria-describedby={invalid ? `${fieldId}-error` : undefined} onChange={(event) => {
|
||||
const raw = event.target.value, next = event.target.valueAsNumber;
|
||||
const valid = raw.trim() !== "" && Number.isFinite(next);
|
||||
setText(raw); setInvalid(!valid); onValidity(fieldId, valid);
|
||||
if (valid) onChange(next);
|
||||
}} />{invalid && <small id={`${fieldId}-error`} className="machine-field-error">请输入有效数值</small>}</span>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
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";
|
||||
@@ -8,20 +8,25 @@ 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 { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
|
||||
import { useMachineDraft, draftFingerprint } from "./machine-manager-history";
|
||||
import { MachineNumberInput } from "./MachineManagerInputs";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
type Props = { snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||
type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||
type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] };
|
||||
const zero3 = (): Vec3 => [0, 0, 0];
|
||||
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
type EditorTab = "structure" | "motion" | "calibration";
|
||||
type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number };
|
||||
const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—";
|
||||
|
||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record<string, MotionTransform>) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
||||
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 };
|
||||
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 [fault, setFault] = useState("");
|
||||
useEffect(() => { host.current?.machineManagerView?.(command.action); }, [command]);
|
||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]);
|
||||
useEffect(() => {
|
||||
const element = host.current; if (!element) return;
|
||||
@@ -49,6 +54,18 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
const selectedJointAxes = new THREE.Group(); selectedJointAxes.name = "selected-joint-local-coordinate-system"; selectedJointAxes.visible = false;
|
||||
const jointAxis = (direction: THREE.Vector3, color: number) => selectedJointAxes.add(new THREE.ArrowHelper(direction, new THREE.Vector3(), .28, color, .07, .045));
|
||||
jointAxis(new THREE.Vector3(1, 0, 0), 0xff3030); jointAxis(new THREE.Vector3(0, 1, 0), 0x35d05b); jointAxis(new THREE.Vector3(0, 0, 1), 0x327cff); scene.add(selectedJointAxes);
|
||||
element.machineManagerView = (action) => {
|
||||
root.updateMatrixWorld(true);
|
||||
const target = action === "selected" ? selectedJointObject : root;
|
||||
if (!target) return;
|
||||
const bounds = new THREE.Box3().setFromObject(target);
|
||||
const center = bounds.isEmpty() ? target.getWorldPosition(new THREE.Vector3()) : bounds.getCenter(new THREE.Vector3());
|
||||
const size = bounds.isEmpty() ? .3 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .1);
|
||||
const direction = action === "top" ? new THREE.Vector3(0, -.001, 1) : action === "front" ? new THREE.Vector3(0, -1, .001) : new THREE.Vector3(1, -.8, .8);
|
||||
const distance = size / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) * Math.max(1, 1 / camera.aspect) * 1.15;
|
||||
orbit.enableDamping = false; orbit.target.copy(center); camera.position.copy(center).addScaledVector(direction.normalize(), distance); camera.near = Math.max(.001, distance / 10000); camera.far = Math.max(100, distance * 20); camera.updateProjectionMatrix(); camera.lookAt(center); orbit.update(); orbit.enableDamping = true;
|
||||
element.dataset.machineManagerView = action; element.dataset.machineManagerCamera = JSON.stringify({ position: camera.position.toArray(), target: orbit.target.toArray(), up: camera.up.toArray() }); dirty = true;
|
||||
};
|
||||
const draw = () => {
|
||||
if (contextLost || !renderable) return;
|
||||
renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera);
|
||||
@@ -83,7 +100,7 @@ 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 = 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; }
|
||||
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]);
|
||||
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;
|
||||
@@ -96,7 +113,7 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; }
|
||||
const requested = Math.min(globalThis.devicePixelRatio || 1, 1.5);
|
||||
const scale = Math.min(1, maxDimension / (width * requested), maxDimension / (height * requested), Math.sqrt(maxPixels / (width * height * requested * requested)));
|
||||
const pixelRatio = Math.max(.5, requested * scale);
|
||||
const pixelRatio = requested * scale;
|
||||
renderer.setPixelRatio(pixelRatio); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false);
|
||||
renderable = renderer.domElement.width > 0 && renderer.domElement.height > 0 && renderer.domElement.width <= maxDimension && renderer.domElement.height <= maxDimension;
|
||||
element.dataset.machineManagerPixelRatio = pixelRatio.toFixed(3); element.dataset.machineManagerBufferSize = `${renderer.domElement.width}x${renderer.domElement.height}`; element.dataset.machineManagerRenderState = renderable ? "ready" : "suspended-limit"; dirty = true; draw();
|
||||
@@ -106,65 +123,96 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
|
||||
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
||||
orbit.addEventListener("change", () => { dirty = true; });
|
||||
const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render();
|
||||
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
|
||||
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; 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 <div className="machine-manager-preview-shell"><div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && <output className="machine-manager-webgl-fault" role="alert">{fault}</output>}</div>;
|
||||
}
|
||||
|
||||
function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode }) {
|
||||
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 }) {
|
||||
const key = `${part.id}:${node.path}`;
|
||||
const row = useRef<HTMLLIElement>(null);
|
||||
useEffect(() => { if (selected === key) { const frame = requestAnimationFrame(() => row.current?.querySelector(".machine-tree-row")?.scrollIntoView({ block: "nearest", inline: "nearest" })); return () => cancelAnimationFrame(frame); } }, [selected, key]);
|
||||
const [expanded, setExpanded] = useState(node.path === "scene");
|
||||
useEffect(() => { if (expansion.serial) setExpanded(expansion.all); }, [expansion]);
|
||||
useEffect(() => { if (selected.startsWith(`${key}/`)) setExpanded(true); }, [selected, key]);
|
||||
const matches = (item: MachineTreeNode): boolean => item.name.toLowerCase().includes(query.toLowerCase()) || Boolean(part.nodeJoints?.[item.path]?.axis.toLowerCase().includes(query.toLowerCase())) || item.children.some(matches);
|
||||
const shown = !query || matches(node) || Boolean(extra);
|
||||
const isExpanded = Boolean(query) || expanded;
|
||||
const hasChildren = node.children.length > 0 || Boolean(extra);
|
||||
const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]);
|
||||
return <li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} data-machine-tree-part={part.id} data-machine-tree-path={node.path} data-machine-tree-parent={node.path === "scene" ? part.parentId ?? "root" : Object.hasOwn(part.nodeParents, node.path) ? part.nodeParents[node.path] ?? "scene" : "source"}>
|
||||
<div className="machine-tree-row"><button type="button" className="machine-tree-toggle" aria-label={`${expanded ? "折叠" : "展开"} ${node.name}`} aria-expanded={expanded} disabled={!hasChildren} onClick={() => setExpanded((value) => !value)}>{hasChildren ? expanded ? "▾" : "▸" : "·"}</button><button type="button" draggable data-axis-control="machine-tree-node" className={selected === key ? "selected" : ""} onClick={() => onSelect(part.id, node.path)} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: part.id, path: node.path })); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => { event.preventDefault(); const raw = event.dataTransfer.getData("application/x-linuxcnc-machine-tree"); if (raw) onDrop(JSON.parse(raw), { partId: part.id, path: node.path }); }}><span className={node.type}>{node.name}</span>{binding && <em>{binding.toUpperCase()}</em>}<small>{node.originMm.map((value) => Number(value.toFixed(3))).join(", ")}</small></button></div>
|
||||
{hasChildren && <ul role="group" hidden={!expanded}>{node.children.map((child) => <TreeNode key={child.path} part={part} node={child} selected={selected} onSelect={onSelect} onDrop={onDrop} />)}{extra}</ul>}
|
||||
return <li ref={row} role="treeitem" hidden={!shown} aria-selected={selected === key} aria-expanded={hasChildren ? isExpanded : undefined} data-machine-tree-part={part.id} data-machine-tree-path={node.path} data-machine-tree-parent={node.path === "scene" ? part.parentId ?? "root" : Object.hasOwn(part.nodeParents, node.path) ? part.nodeParents[node.path] ?? "scene" : "source"}>
|
||||
<div className="machine-tree-row"><button type="button" className="machine-tree-toggle" aria-label={`${isExpanded ? "折叠" : "展开"} ${node.name}`} aria-expanded={isExpanded} disabled={!hasChildren} onClick={() => setExpanded((value) => !value)}>{hasChildren ? isExpanded ? "▾" : "▸" : "·"}</button><button type="button" draggable={!locked} title={`${node.name} · 相对父级 ${node.originMm.map(formatted).join(" / ")} mm`} data-axis-control="machine-tree-node" className={selected === key ? "selected" : ""} onClick={() => onSelect(part.id, node.path)} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: part.id, path: node.path })); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => { event.preventDefault(); const raw = event.dataTransfer.getData("application/x-linuxcnc-machine-tree"); if (raw && !locked) { try { const source = JSON.parse(raw); if (typeof source.partId === "string" && typeof source.path === "string") onDrop(source, { partId: part.id, path: node.path }); } catch { /* Ignore unrelated drag payloads. */ } } }}><span className={node.type}>{node.name}</span>{binding && <em>{binding.toUpperCase()}</em>}<small>{node.originMm.map((value) => Number(value.toFixed(3))).join(", ")}</small></button></div>
|
||||
{hasChildren && <ul role="group" hidden={!isExpanded}>{node.children.map((child) => <TreeNode key={child.path} part={part} node={child} selected={selected} onSelect={onSelect} onDrop={onDrop} query={query} expansion={expansion} locked={locked} />)}{extra}</ul>}
|
||||
</li>;
|
||||
}
|
||||
|
||||
export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }: Props) {
|
||||
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
|
||||
const [parts, setParts] = useState<MachinePartDraft[]>([]); const partsRef = useRef(parts); partsRef.current = parts;
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene");
|
||||
const [cOriginMm, setCOriginMm] = useState<Vec3>([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState<Vec3>([0, 0, 0]);
|
||||
const [physicalZero, setPhysicalZero] = useState<PhysicalZero>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef<HTMLInputElement>(null);
|
||||
const { history, draft, edit, undo, redo, clearHistory } = useMachineDraft();
|
||||
const { parts, cOriginMm, tiltOffsetMm, physicalZero } = draft;
|
||||
const setParts = (change: (current: MachinePartDraft[]) => MachinePartDraft[]) => edit((current) => ({ ...current, parts: change(current.parts) }));
|
||||
const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value }));
|
||||
const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value }));
|
||||
const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value }));
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
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 [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]), []);
|
||||
const signature = coordinates + draftFingerprint(draft);
|
||||
const retainedScenes = useRef(new Set<THREE.Object3D>());
|
||||
// Retain only assets reachable from the bounded undo/redo history.
|
||||
useEffect(() => {
|
||||
const keep = new Set([...history.past, history.current, ...history.future].flatMap((d) => d.parts.map((part) => part.scene)));
|
||||
for (const scene of retainedScenes.current) if (!keep.has(scene as THREE.Group)) disposeMachineScene(scene);
|
||||
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 [motionState, setMotionState] = useState<Record<string, MotionTransform>>({}); const motionStateHash = useRef("");
|
||||
useEffect(() => () => { for (const part of partsRef.current) disposeMachineScene(part.scene); }, []);
|
||||
useEffect(() => { setMessage(""); setCOriginMm([0, 0, 0]); setTiltOffsetMm([0, 0, 0]); setPhysicalZero({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); }, [coordinates]);
|
||||
useEffect(() => { setMessage(""); setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setFollowLive(false); clearHistory(); }, [coordinates, clearHistory]);
|
||||
useEffect(() => { if (selectedId && !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 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;
|
||||
const update = (id: string, values: Partial<MachinePartDraft>) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part));
|
||||
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string, control?: string) => <fieldset className="machine-vector" data-axis-control={control}><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<input type="number" step="any" value={value[index]} onChange={(event) => { const next = [...value] as Vec3; next[index] = number(event.target.value); set(next); }} /></label>)}</fieldset>;
|
||||
const parentAllowed = (id: string, parentId: string | null) => { let cursor = parentId; const visited = new Set([id]); while (cursor) { if (visited.has(cursor)) return false; visited.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return true; };
|
||||
const update = (id: string, values: Partial<MachinePartDraft>) => { if (pending || followLive) return; if (values.parentId && !parentAllowed(id, values.parentId)) { setMessage("不能将部件挂到自身或后代节点"); return; } setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); setMessage(""); };
|
||||
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) => <fieldset className="machine-vector"><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<MachineNumberInput fieldId={`${label}-${axis}`} value={value[index]} disabled={pending || followLive} onValidity={validity} onChange={(n) => { const next = [...value] as Vec3; next[index] = n; set(next); }} /></label>)}</fieldset>;
|
||||
const importFiles = async (list: FileList | File[] | null) => {
|
||||
if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||
if (!list?.length || pending || followLive) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||
try {
|
||||
const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name)));
|
||||
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 loaded = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure?.status === "rejected") { loaded.forEach((source) => disposeMachineScene(source.scene)); throw failure.reason; }
|
||||
if (loaded.some((source) => source.restoredModel && source.restoredModel.coordinates !== coordinates)) {
|
||||
loaded.forEach((source) => disposeMachineScene(source.scene));
|
||||
throw new Error(`导入机型不同,请先切换到 ${loaded.find((source) => source.restoredModel?.coordinates !== coordinates && source.restoredModel)?.restoredModel?.coordinates}`);
|
||||
}
|
||||
setParts((current) => {
|
||||
const next = [...current];
|
||||
const restored = loaded.find((source) => source.restoredModel)?.restoredModel;
|
||||
edit((draft) => {
|
||||
const next = [...draft.parts];
|
||||
for (const source of loaded) {
|
||||
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
|
||||
const parentRole = role ? expectedParentRole(role) : null;
|
||||
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} });
|
||||
}
|
||||
return next;
|
||||
return { ...draft, parts: next, ...(restored ? { cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) };
|
||||
});
|
||||
const restored = loaded.find((source) => source.restoredModel)?.restoredModel;
|
||||
if (restored) { setCOriginMm(restored.calibration.cOriginMm); setTiltOffsetMm(restored.calibration.tiltOffsetMm); setPhysicalZero(restored.calibration.physicalZero as PhysicalZero); setMessage(`已恢复 ${restored.coordinates} 单自由度配置`); }
|
||||
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已恢复 ${restored.coordinates} 关节配置和物理零点` : `已导入 ${loaded.length} 个部件`);
|
||||
setTab("structure"); setQuery(""); setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已恢复 ${restored.coordinates} 关节配置和物理零点` : `已导入 ${loaded.length} 个部件`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const remove = (part: MachinePartDraft) => { setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); requestAnimationFrame(() => disposeMachineScene(part.scene)); if (selectedId === part.id) { setSelectedId(null); setSelectedNodePath("scene"); } };
|
||||
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 selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); };
|
||||
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;
|
||||
if (source.path === "scene") {
|
||||
if (target.path !== "scene") { setMessage("整机/部件根节点只能拖到另一个 GLB 根节点"); return; }
|
||||
@@ -182,12 +230,13 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
};
|
||||
const apply = async () => {
|
||||
if (disabled) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
|
||||
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setMessage("三维机床已应用;请重新回零后运行程序"); }
|
||||
if (disabled || pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
|
||||
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setApplied(signature); setMessage("三维机床已应用;请重新回零后运行程序"); }
|
||||
catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const exportGlb = async () => {
|
||||
if (pending || followLive || blockingReason) return;
|
||||
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
|
||||
try {
|
||||
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
|
||||
@@ -196,7 +245,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false });
|
||||
const url = URL.createObjectURL(new Blob([bytes as ArrayBuffer], { type: "model/gltf-binary" }));
|
||||
const link = document.createElement("a"); link.href = url; link.download = machine.filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
||||
setExported(signature); setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
||||
};
|
||||
@@ -207,44 +256,62 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
};
|
||||
const selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null;
|
||||
const setDof = (dof: JointDof | null) => {
|
||||
if (!selected) return;
|
||||
if (!selected || pending || followLive) return;
|
||||
if (dof && parts.some((part) => Object.entries(part.nodeJoints ?? {}).some(([path, existing]) => existing.axis === dof.axis && (part.id !== selected.id || path !== selectedNodePath)))) { setMessage(`${dof.axis} 轴已绑定其他节点,请先取消该绑定`); return; }
|
||||
setParts((current) => current.map((part) => { if (part.id !== selected.id) return part; const nodeJoints = { ...(part.nodeJoints ?? {}) }; if (dof) nodeJoints[selectedNodePath] = dof; else delete nodeJoints[selectedNodePath]; return { ...part, nodeJoints }; }));
|
||||
setMessage("");
|
||||
};
|
||||
const [followLive, setFollowLive] = useState(false);
|
||||
const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]);
|
||||
const liveReady = visual.authoritative && visual.machineModel?.coordinate_system === coordinates;
|
||||
const effectivePose: TestPose = followLive && liveReady ? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - physicalZero[axis.toLowerCase() as keyof PhysicalZero]])) } : testPose;
|
||||
const validation = useMemo(() => { try { const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]);
|
||||
const 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;
|
||||
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 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 exportJson = () => {
|
||||
if (!validation.model) return;
|
||||
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);
|
||||
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()}<input data-axis-control={`machine-test-${axis}`} type="number" step="any" disabled={followLive || !parts.some((part) => Object.values(part.nodeJoints ?? {}).some((joint) => joint.axis.toLowerCase() === axis))} value={effectivePose[axis]} onChange={(event) => setTestPose({ ...testPose, [axis]: number(event.target.value) })} /><small>{unit}</small></label>;
|
||||
const 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 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 renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />;
|
||||
const renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} query={query} expansion={expansion} locked={pending || followLive} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />;
|
||||
return <section className="machine-manager" aria-label="三维机床管理">
|
||||
<button type="button" data-axis-control="machine-manager-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>三维机床管理</button>
|
||||
<DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" onClose={() => setOpen(false)}>
|
||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><span>{parts.length} 个导入模型 · 每个运动节点一个自由度</span></div>
|
||||
<div className="machine-manager-workbench" aria-busy={pending}>
|
||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => file.current?.click()}>导入 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 ? "新建机床" : 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>
|
||||
<div className="machine-manager-layout">
|
||||
{open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />}
|
||||
<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={() => file.current?.click()}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 32 MiB</small></div>}
|
||||
<p className="machine-preview-hint">左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系</p></section>
|
||||
<div className="machine-manager-editor">
|
||||
<section className="machine-part-list"><h3>部件与层级</h3>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
|
||||
{selected && <section className="machine-part-editor"><header><h3>{selected.filename}</h3><button type="button" data-axis-control="machine-manager-delete" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{[..."XYZABC"].map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {"XYZ".includes(axis) ? "平移 mm" : "旋转 deg"}</option>)}</select></label>
|
||||
{selectedDof && <><label>父级运动方向<select data-axis-control="machine-joint-direction" value={selectedDof.direction} onChange={(event) => setDof({ ...selectedDof, direction: event.target.value as "x" | "y" | "z" })}>{["x", "y", "z"].map((axis) => <option key={axis} value={axis}>{axis.toUpperCase()}</option>)}</select></label><label>正负方向<select data-axis-control="machine-joint-sign" value={selectedDof.sign} onChange={(event) => setDof({ ...selectedDof, sign: Number(event.target.value) as 1 | -1 })}><option value="1">正向 +1</option><option value="-1">反向 −1</option></select></label></>}
|
||||
<label>节点结构角色<select data-axis-control="machine-manager-role" value={selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? ""} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><label>模型装配父级<select data-axis-control="machine-manager-parent" value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => part.id !== selected.id).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label><div className="machine-part-flags"><label><input type="checkbox" checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div><p className="machine-transform-reference">当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}</p>
|
||||
<label>{selectedDof ? `${selectedDof.axis} ${isLinear(selectedDof.axis) ? "位移 (mm)" : "转角 (deg)"}` : "关节值"}<input data-axis-control="machine-joint-value" type="number" step="any" disabled={!selectedDof || followLive} value={selectedDof ? effectivePose[selectedDof.axis.toLowerCase() as keyof TestPose] : 0} onChange={(event) => { if (selectedDof) setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: number(event.target.value) })); }} /></label>
|
||||
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"] ] 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"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 3] : event.key === "ArrowLeft" ? ids[(index + 2) % 3] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[2] : 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>
|
||||
{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>}
|
||||
</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}` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : "手动验证(相对物理零点)"}</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>
|
||||
</div>
|
||||
</div>
|
||||
<section className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={!liveReady} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "等待有效机床数据" : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={followLive} onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></section>
|
||||
<section className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点 (XYZ mm / ABC deg)</legend>{["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => <label key={axis}>{axis.toUpperCase()}<input type="number" step="any" value={physicalZero[axis as keyof PhysicalZero]} onChange={(event) => setPhysicalZero({ ...physicalZero, [axis]: number(event.target.value) })} /></label>)}</fieldset></section>
|
||||
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || Boolean(validation.error)} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || Boolean(validation.error)} onClick={() => void exportGlb()}>导出组合 GLB</button><button type="button" data-axis-control="machine-manager-export-json" disabled={!validation.model} onClick={exportJson}>导出关节 JSON</button><output role="status">{message || validation.error || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}</output></footer>
|
||||
<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>
|
||||
<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={file} data-axis-control="machine-manager-files" hidden multiple type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
|
||||
</DraggableDialog>
|
||||
</section>;
|
||||
|
||||
25
web/react/src/machine-manager-history.ts
Normal file
25
web/react/src/machine-manager-history.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useReducer } from "react";
|
||||
import type { MachinePartDraft, PhysicalZero } from "./machine-manager";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
export type MachineDraft = { 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 = { parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 } };
|
||||
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; }
|
||||
if (action.type === "redo") { const current = state.future[0]; return current ? { current, past: [...state.past, state.current].slice(-30), future: state.future.slice(1) } : state; }
|
||||
if (action.type !== "edit") return state;
|
||||
const current = action.change(state.current);
|
||||
if (current === state.current || (draftFingerprint(current) === draftFingerprint(state.current) && current.parts.every((part, i) => part.showOrigin === state.current.parts[i]?.showOrigin))) return state;
|
||||
return { current, past: [...state.past, state.current].slice(-30), future: [] };
|
||||
}
|
||||
export function useMachineDraft() {
|
||||
const [history, dispatch] = useReducer(machineDraftHistory, { current: initial, 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" }), []) };
|
||||
}
|
||||
export function draftFingerprint(draft: MachineDraft) {
|
||||
return JSON.stringify({ ...draft, parts: draft.parts.map(({ id, filename, hash, role, parentId, visible, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints }) => ({ id, filename, hash, role, parentId, visible, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints })) });
|
||||
}
|
||||
@@ -415,3 +415,98 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.machine-manager-calibration { grid-template-columns: 1fr; }
|
||||
.machine-motion-test > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* Machine manager workbench: a stable viewport with independently scrolling panels. */
|
||||
.machine-manager-dialog { width: min(1440px, calc(100vw - 24px)); height: min(940px, calc(100dvh - 24px)); }
|
||||
.machine-manager-dialog > .machine-dialog-body { flex: 1; min-height: 0; padding: 10px; overflow: hidden; }
|
||||
.machine-manager-workbench { height: 100%; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.machine-manager-toolbar { margin: 0; flex: 0 0 auto; }
|
||||
.machine-manager-toolbar button, .machine-preview-tools button, .machine-tree-tools button, .machine-editor-tabs button, .machine-axis-overview button { border: 1px solid #9babad; background: #fff; padding: 6px 10px; border-radius: 4px; min-height: 32px; cursor: pointer; }
|
||||
.machine-manager-dialog button:disabled { opacity: .48; cursor: not-allowed; }
|
||||
.machine-manager-dialog input, .machine-manager-dialog select { background: #fff; border: 1px solid #9babad; border-radius: 3px; padding: 4px 6px; color: #203b3f; }
|
||||
.machine-manager-dialog input:focus-visible, .machine-manager-dialog select:focus-visible { outline: 2px solid #2b858a; outline-offset: 1px; }
|
||||
.machine-manager-dialog input:disabled, .machine-manager-dialog select:disabled { background: #e7edec; color: #596d6f; }
|
||||
.machine-save-state { margin-left: auto; padding: 5px 9px; border-radius: 20px; background: #dbeae7; font-size: 12px; }
|
||||
.machine-axis-overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; flex: 0 0 auto; }
|
||||
.machine-axis-overview button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 7px; text-align: left; align-items: center; }
|
||||
.machine-axis-overview button.bound { border-color: #74a7a0; background: #e4f1ed; }
|
||||
.machine-axis-overview button.missing { border-style: dashed; color: #71511e; background: #fff9ec; }
|
||||
.machine-axis-overview strong { font-size: 17px; }
|
||||
.machine-axis-overview span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
||||
.machine-axis-overview small { font: 11px ui-monospace, monospace; }
|
||||
.machine-manager-layout { flex: 1; min-height: 0; grid-template-columns: minmax(0, 1.25fr) minmax(370px, 1fr); gap: 10px; overflow: hidden; }
|
||||
.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 { 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; }
|
||||
.machine-preview-empty strong { font-size: 18px; }
|
||||
.machine-preview-empty button { pointer-events: auto; background: #e3f3ef; border: 1px solid #90bbb3; border-radius: 5px; padding: 9px 18px; color: #254c4e; }
|
||||
.machine-preview-empty p { margin: 0; line-height: 1.7; }
|
||||
.machine-manager-editor { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; gap: 8px; }
|
||||
.machine-editor-tabs { display: flex; gap: 4px; flex: 0 0 auto; }
|
||||
.machine-editor-tabs button { flex: 1; white-space: nowrap; }
|
||||
.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 > 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-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; }
|
||||
.machine-tree .machine-tree-row button { min-height: 28px; }
|
||||
.machine-tree [data-axis-control="machine-tree-node"] { grid-template-columns: minmax(90px, 1fr) auto; }
|
||||
.machine-tree [data-axis-control="machine-tree-node"] small { display: none; }
|
||||
.machine-tree [data-axis-control="machine-tree-node"]:focus-visible { outline: 2px solid #2b858a; outline-offset: -2px; }
|
||||
.machine-part-editor { border-radius: 4px; }
|
||||
.machine-part-editor > header h3 { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.machine-part-editor > header h3 small { display: block; color: #627777; font-size: 11px; font-weight: 400; margin-top: 3px; }
|
||||
.machine-part-editor > header button { white-space: nowrap; font-size: 11px; border: 1px solid #d3aba1; border-radius: 3px; padding: 4px 6px; }
|
||||
.machine-part-editor > label { grid-template-columns: 120px minmax(0, 1fr); }
|
||||
.machine-origin-details { margin: 8px 0; }
|
||||
.machine-origin-details summary { cursor: pointer; color: #36595c; padding: 4px 0; }
|
||||
.machine-parent-note { margin: 8px 0; color: #627777; font-size: 11px; }
|
||||
.machine-selection-empty { align-self: start; padding: 14px; color: #627777; }
|
||||
.machine-number-field { display: block; min-width: 0; }
|
||||
.machine-number-field input { width: 100%; min-width: 0; height: 30px; font: 12px ui-monospace, monospace; }
|
||||
.machine-manager-dialog input[aria-invalid="true"] { border: 2px solid #ad4538; background: #fff0ec; }
|
||||
.machine-field-error { display: block; color: #9c3024; font-size: 11px; }
|
||||
.machine-manager-editor > .machine-motion-test, .machine-manager-editor > .machine-manager-calibration { flex: 1; min-height: 0; overflow: auto; margin: 0; padding: 12px; border-radius: 4px; }
|
||||
.machine-motion-test > label { display: flex; align-items: center; }
|
||||
.machine-motion-test > label > input { width: auto; }
|
||||
.machine-motion-test > div { grid-template-columns: 1fr; gap: 12px; }
|
||||
.machine-motion-test > div > label { grid-template-columns: 22px minmax(80px, 1fr) 35px; }
|
||||
.machine-motion-test details { margin-top: 16px; color: #536467; font-size: 11px; }
|
||||
.machine-motion-test p { line-height: 1.7; color: #536467; }
|
||||
.machine-manager-editor > .machine-manager-calibration { display: block; }
|
||||
.machine-manager-calibration .machine-vector { border: 1px solid #b7c9c5; border-radius: 4px; padding: 10px; margin: 12px 0; }
|
||||
.machine-manager-calibration .machine-vector label { grid-template-columns: 18px minmax(60px, 1fr); flex: 1 1 105px; }
|
||||
.machine-validation { flex: 0 0 auto; padding: 7px 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; }
|
||||
.machine-validation.ready { background: #e1efe7; color: #285d42; }
|
||||
.machine-validation.needs-attention { background: #fff2d9; color: #74501e; }
|
||||
.machine-validation button { margin-left: 10px; text-decoration: underline; }
|
||||
.machine-manager-footer { flex: 0 0 auto; margin: 0; gap: 6px; }
|
||||
.machine-manager-footer > button { padding: 6px 10px; border: 1px solid #8aa3a4; border-radius: 4px; background: #fff; }
|
||||
.machine-manager-footer > button:first-child { background: #27585d; color: #fff; }
|
||||
.machine-manager-footer output { margin: 0; font-size: 11px; color: #536467; flex-basis: 280px; }
|
||||
@media (max-width: 850px) {
|
||||
.machine-manager-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(160px, .65fr) minmax(240px, 1fr); overflow: auto; }
|
||||
.machine-axis-overview button { grid-template-columns: auto 1fr; padding: 4px 6px; }
|
||||
.machine-axis-overview small { display: none; }
|
||||
.machine-axis-overview span { font-size: 10px; }
|
||||
.machine-save-state { margin-left: 0; }
|
||||
.machine-preview-tools small { display: none; }
|
||||
.machine-preview-hint { display: none; }
|
||||
}
|
||||
@media (max-height: 600px) and (min-width: 851px) {
|
||||
.machine-structure-panel { grid-template-rows: minmax(85px, .6fr) minmax(100px, 1fr); }
|
||||
.machine-manager-dialog { height: calc(100dvh - 16px); }
|
||||
.machine-manager-workbench { gap: 4px; }
|
||||
}
|
||||
.machine-joint-step { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
.machine-joint-step > label { display: flex; align-items: center; gap: 6px; }
|
||||
.machine-joint-step button { padding: 5px 9px; border: 1px solid #9babad; border-radius: 3px; background: #fff; }
|
||||
|
||||
Reference in New Issue
Block a user