@@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three" ;
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" ;
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js" ;
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js ";
import { installMachineStudio , MACHINE_STUDIO } from "./machine-studio-lighting ";
import { DraggableDialog } from "./DraggableDialog" ;
import { disposeMachineScene , type ImportedMachine } from "./imported-machine" ;
import { configureMachinePartScene , draftNodeDof , makeJointAssembly , reparentMachineNode , buildManagedMachine , expectedParentRole , loadMachinePart , machinePartTree , MACHINE_PART_ROLES , roleLabel , type MachinePartDraft , type MachinePartRole , type MachineTreeNode , type PhysicalZero } from "./machine-manager" ;
import { driveJoint , jointZero , defaultDof , isLinear , type JointDof , type CncAxis } from "./machine-joints" ;
import { CNC_AXES , driveJoint, jointZero , defaultDof , isFiveAxisCoordinates , isLinear , type JointDof , type CncAxis } from "./machine-joints" ;
import { projectXyzacVisual , type XyzacVisualSource } from "./xyzac-visual-adapter" ;
import { useMachineDraft , draftFingerprint , changeMachineType } from "./machine-manager-history" ;
import { downloadMachine , listSavedMachines , saveMachineCopy , readMachineCopy , deleteMachineCopy , type SavedMachine } from "./machine-library" ;
@@ -14,21 +14,27 @@ import { machineDocumentFilename } from "./machine-document";
import { MachineNumberInput } from "./MachineManagerInputs" ;
import { inspectMachineGlb , MAX_MACHINE_BYTES , type Vec3 } from "./machine-urdf" ;
import { FIVE_AXIS_CASES , FIVE_AXIS_STATUS_LABELS } from "../../workspace/five-axis-machine-catalog.mjs" ;
import { analyzeGlbFiveAxisScene , analysisSummary , type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer" ;
import { MANAGER_SETTINGS , managerAxes , managerProfile , validateManagerCalibration , zeroAxisValues } from "./machine-manager-config" ;
type Props = { currentMachine? : ImportedMachine | null ; snapshot? : XyzacVisualSource | null ; coordinates : "XYZAC" | "XYZBC" ; disabled : boolean ; onApply : ( machine : ImportedMachine ) = > Promise < void > } ;
type TestPose = { x : number ; y : number ; z : number ; a : number ; b : number ; c : number } ;
type Props = { currentMachine? : ImportedMachine | null ; snapshot? : XyzacVisualSource | null ; coordinates : string ; disabled : boolean ; onApply : ( machine : ImportedMachine ) = > Promise < void > } ;
type TestPose = Record < string , number > ;
type MotionTransform = { local : number [ ] ; localQuaternion : number [ ] ; world : number [ ] ; worldQuaternion : number [ ] } ;
const zero3 = ( ) : Vec3 = > [ 0 , 0 , 0 ] ;
type EditorTab = "structure" | "motion" | "calibration" | "files" ;
type PreviewCommand = { action : "fit" | "selected" | "front" | "top" ; serial : number } ;
const formatted = ( value : number ) = > Number . isFinite ( value ) ? value . toFixed ( 3 ) : "—" ;
const zeroPose = ( coordinates : string ) : TestPose = > zeroAxisValues ( coordinates ) ;
const axisUnit = ( axis : CncAxis ) = > isLinear ( axis ) ? "mm" : "deg" ;
const catalogCoordinateOptions = [ . . . new Set ( FIVE_AXIS_CASES . map ( ( item ) = > item . coordinates ) ) ] ;
const supportedCoordinateOptions = new Set ( FIVE_AXIS_CASES . filter ( ( item ) = > item . webWasm === "supported" ) . map ( ( item ) = > item . coordinates ) ) ;
function ManagerPreview ( { coordinates , parts , selectedId , selectedNodePath , testPose , onMotionState , command } : { command : PreviewCommand ; coordinates : "XYZAC" | "XYZBC" ; parts : MachinePartDraft [ ] ; selectedId : string | null ; selectedNodePath : string ; testPose : TestPose ; onMotionState : ( state : Record < string , MotionTransform > ) = > void } ) {
type PreviewHost = HTMLDivElement & { machineManagerView ? : ( action : PreviewCommand [ "action" ] ) = > void ; machineManagerSync ? : ( parts : MachinePartDraft [ ] , selectedId : string | null , selectedNodePath : string , testPose : TestPose , coordinates : "XYZAC" | "XYZBC" ) = > void } ;
function ManagerPreview ( { coordinates , parts , selectedId , selectedNodePath , testPose , onMotionState , onSelectModel , command } : { command : PreviewCommand ; coordinates : string ; parts : MachinePartDraft [ ] ; selectedId : string | null ; selectedNodePath : string ; testPose : TestPose ; onMotionState : ( state : Record < string , MotionTransform > ) = > void ; onSelectModel : ( partId : string ) = > void } ) {
type PreviewHost = HTMLDivElement & { machineManagerView ? : ( action : PreviewCommand [ "action" ] ) = > void ; machineManagerSync ? : ( parts : MachinePartDraft [ ] , selectedId : string | null , selectedNodePath : string , testPose : TestPose , coordinates : string ) = > void } ;
const host = useRef < PreviewHost > ( null ) ;
const stateRef = useRef ( { parts , selectedId , selectedNodePath , testPose , coordinates } ) ; stateRef . current = { parts , selectedId , selectedNodePath , testPose , coordinates } ;
const onMotionStateRef = useRef ( onMotionState ) ; onMotionStateRef . current = onMotionState ;
const onSelectModelRef = useRef ( onSelectModel ) ; onSelectModelRef . current = onSelectModel ;
const [ fault , setFault ] = useState ( "" ) ;
useEffect ( ( ) = > { host . current ? . machineManagerView ? . ( command . action ) ; } , [ command ] ) ;
useEffect ( ( ) = > { host . current ? . machineManagerSync ? . ( parts , selectedId , selectedNodePath , testPose , coordinates ) ; } , [ parts , selectedId , selectedNodePath , testPose , coordinates ] ) ;
@@ -37,21 +43,51 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
// limits when DevTools or display scaling changes the drawing-buffer size.
const renderer = new THREE . WebGLRenderer ( { antialias : false , alpha : false , depth : true , stencil : false , powerPreference : "high-performance" } ) ;
renderer . outputColorSpace = THREE . SRGBColorSpace ; renderer . toneMapping = THREE . ACESFilmicToneMapping ; renderer . toneMappingExposure = 1.45 ; renderer . shadowMap. enabled = false ; element . appendChild ( renderer . domElement ) ;
renderer . shadowMap . enabled = false ; element . appendChild ( renderer . domElement ) ;
const gl = renderer . getContext ( ) , viewportLimit = gl . getParameter ( gl . MAX_VIEWPORT_DIMS ) as Int32Array ;
const maxDimension = Math . max ( 1 , Math . floor ( Math . min ( Number ( gl . getParameter ( gl . MAX_RENDERBUFFER_SIZE ) || 4096 ) , Number ( gl . getParameter ( gl . MAX_TEXTURE_SIZE ) || 4096 ) , Number ( viewportLimit ? . [ 0 ] || 4096 ) , Number ( viewportLimit ? . [ 1 ] || 4096 ) ) * . 9 ) ) ;
const maxPixels = Math . min ( maxDimension * maxDimension , 4096 * 4096 ) ;
element . dataset . machineManagerWebglAntialias = String ( Boolean ( gl . getContextAttributes ( ) ? . antialias ) ) ; element . dataset . machineManagerMaxBuffer = String ( maxDimension ) ;
const scene = new THREE . Scene ( ) ; scene . background = new THREE . Color ( 0x263438 ) ;
const studio = new RoomEnvironment ( ) ; const pmrem = new THREE . PMREMGenerator ( renderer ) ; const environment = pmrem . fromScene ( studio , . 04 ) ; scene . environment = environment . texture ; scene . environmentIntensity = 1.25 ; studio . dispose ( ) ; pmrem . dispose ( ) ;
scene . add ( new THREE . HemisphereLight ( 0xffffff , 0x728083 , 2.6 ) ) ; scene . add ( new THREE . AmbientLight ( 0xffffff , . 65 ) ) ;
const light = new THREE . DirectionalLight ( 0xfff5e5 , 4.2 ) ; light . position . set ( 3 , 5 , 4 ) ; light . castShadow = true ; scene . add ( light ) ;
const fill = new THREE . DirectionalLight ( 0xdcefff , 2.4 ) ; fill . position . set ( - 4 , 2 , 3 ) ; scene . add ( fill ) ;
const rim = new THREE . DirectionalLight ( 0xffffff , 1.8 ) ; rim . position . set ( 2 , 4 , - 5 ) ; scene . add ( rim ) ;
const studio = installMachineStudio ( renderer , scene , "z" ) ;
const grid = new THREE . GridHelper ( 4 , 40 , 0x8eb0b2 , 0x466266 ) ; grid . name = "machine-xy-ground-grid-z-up" ; grid . rotation . x = Math . PI / 2 ; scene . add ( grid ) ;
const camera = new THREE . PerspectiveCamera ( 42 , 1 , . 005 , 100 ) ; camera . up . set ( 0 , 0 , 1 ) ; camera . position . set ( 2.4 , - 2.8 , 1.8 ) ;
const orbit = new OrbitControls ( camera , renderer . domElement ) ; orbit . enableDamping = true ;
const root = new THREE . Group ( ) ; root . name = "machine-manager-preview" ; scene . add ( root ) ;
const selectionBox = new THREE . Box3 ( ) ;
const selectionHighlight = new THREE . Box3Helper ( selectionBox , 0xffc857 ) ;
selectionHighlight . name = "selected-import-outline" ; selectionHighlight . visible = false ; scene . add ( selectionHighlight ) ;
// Pick only imported geometry; exclude the grid, axes and selection helpers.
const owners = new Map < THREE.Object3D , string > ( ) ;
let pickable : THREE.Object3D [ ] = [ ] ;
const raycaster = new THREE . Raycaster ( ) ;
let pointer : { id : number ; x : number ; y : number ; dragged : boolean } | null = null ;
const pointerDown = ( event : PointerEvent ) = > {
if ( pointer ) { pointer . dragged = true ; return ; }
if ( event . button === 0 && event . isPrimary ) pointer = { id : event.pointerId , x : event.clientX , y : event.clientY , dragged : false } ;
} ;
const pointerMove = ( event : PointerEvent ) = > {
if ( pointer ? . id === event . pointerId && Math . hypot ( event . clientX - pointer . x , event . clientY - pointer . y ) > 5 ) pointer . dragged = true ;
} ;
const pointerCancel = ( ) = > { pointer = null ; } ;
const pointerUp = ( event : PointerEvent ) = > {
const start = pointer ; pointer = null ;
if ( ! start || start . id !== event . pointerId || start . dragged || event . button !== 0 || Math . hypot ( event . clientX - start . x , event . clientY - start . y ) > 5 ) return ;
const rect = renderer . domElement . getBoundingClientRect ( ) ;
if ( ! rect . width || ! rect . height ) return ;
scene . updateMatrixWorld ( true ) ; camera . updateMatrixWorld ( true ) ;
raycaster . setFromCamera ( new THREE . Vector2 ( ( event . clientX - rect . left ) / rect . width * 2 - 1 , 1 - ( event . clientY - rect . top ) / rect . height * 2 ) , camera ) ;
const hit = raycaster . intersectObjects ( pickable , false ) . find ( ( { object } ) = > {
for ( let cursor : THREE.Object3D | null = object ; cursor ; cursor = cursor . parent ) if ( ! cursor . visible ) return false ;
return true ;
} ) ;
const partId = hit && owners . get ( hit . object ) ;
if ( partId ) onSelectModelRef . current ( partId ) ;
} ;
renderer . domElement . addEventListener ( "pointerdown" , pointerDown ) ;
renderer . domElement . addEventListener ( "pointermove" , pointerMove ) ;
renderer . domElement . addEventListener ( "pointerup" , pointerUp ) ;
renderer . domElement . addEventListener ( "pointercancel" , pointerCancel ) ;
let dofNodes : Array < { object : THREE . Object3D ; zero : ReturnType < typeof jointZero > ; dof : JointDof } > = [ ] ;
type RoleNode = { object : THREE . Object3D ; position : THREE.Vector3 ; quaternion : THREE.Quaternion } ;
let nodes = new Map < string , THREE.Group > ( ) , visualNodes = new Map < string , Map < string , THREE.Object3D > > ( ) , roleNodes = new Map < MachinePartRole , RoleNode > ( ) , helpers : THREE.AxesHelper [ ] = [ ] , selectedJointObject : THREE.Object3D | null = null , lastParts : MachinePartDraft [ ] | null = null , fitSignature = "" , selectedAxesScale = 1 , dirty = true , renderable = false , contextLost = false , frame = 0 ;
@@ -79,11 +115,13 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
if ( nextParts !== lastParts ) {
delete element . dataset . machineManagerRendered ; element . dataset . machineManagerRenderState = "syncing-scene" ;
selectedJointAxes . removeFromParent ( ) ; root . clear ( ) ; helpers . forEach ( ( helper ) = > helper . dispose ( ) ) ; helpers = [ ] ; dofNodes = [ ] ; nodes = new Map ( ) ; visualNodes = new Map ( ) ; roleNodes = new Map ( ) ;
owners . clear ( ) ; pickable = [ ] ;
for ( const part of nextParts ) {
const node = new THREE . Group ( ) ; node . name = ` draft_ ${ part . id } ` ; node . visible = part . visible ; node . position . fromArray ( part . translationMm ) . multiplyScalar ( . 001 ) ; node . rotation . set ( . . . part . rotationDeg . map ( THREE . MathUtils . degToRad ) as Vec3 ) ;
// Clone transforms/hierarchy only. Geometry, materials, textures and
// source-node transforms remain untouched on the imported GLB.
const { scene : visual , nodes : pathNodes } = configureMachinePartScene ( part . scene . clone ( true ) , part ) ;
visual . traverse ( ( object ) = > { if ( ( object as THREE . Mesh ) . isMesh ) { owners . set ( object , part . id ) ; pickable . push ( object ) ; } } ) ;
const internalRoles = Object . entries ( part . nodeRoles ? ? { } ) . map ( ( [ path , role ] ) = > ( { object : pathNodes . get ( path ) ? ? null , role } ) ) ;
visual . position . add ( new THREE . Vector3 ( . . . part . originMm ) . multiplyScalar ( - . 001 ) ) ; node . add ( visual ) ; visualNodes . set ( part . id , pathNodes ) ;
const rootDof = draftNodeDof ( part , "scene" , nextCoordinates ) ;
@@ -104,11 +142,37 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
lastParts = nextParts ;
}
selectedJointAxes . removeFromParent ( ) ; selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes . get ( nextSelectedId ) ? ? null : visualNodes . get ( nextSelectedId ) ? . get ( nextNodePath ) ? ? null : null ; if ( selectedJointObject ) { selectedJointObject . add ( selectedJointAxes ) ; selectedJointAxes . position . set ( 0 , 0 , 0 ) ; selectedJointAxes . quaternion . identity ( ) ; selectedJointAxes . scale . setScalar ( selectedAxesScale ) ; selectedJointAxes . visible = nextParts . find ( ( part ) = > part . id === nextSelectedId ) ? . showOrigin ? ? true ; element . dataset . machineManagerSelectedJoint = ` ${ nextSelectedId } : ${ nextNodePath } ` ; element . dataset . machineManagerSelectedJointAxes = "local-xyz" ; element . dataset . machineManagerTransformTarget = "single-dof-input" ; } else { selectedJointAxes . visible = false ; delete element . dataset . machineManagerSelectedJoint ; delete element . dataset . machineManagerSelectedJointAxes ; delete element . dataset . machineManagerTransformTarget ; }
for ( const binding of dofNodes ) driveJo int ( 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 . f ind ( ( binding ) = > binding . object === selectedJointObject ) ? ? null : null ;
if ( selectedJointObject ) {
selectedJointObject . add ( selectedJointAxes ) ; selectedJointAxes . position . set ( 0 , 0 , 0 ) ;
// JointDof.direction is defined in the parent frame. The previous
// preview always drew an unrotated local XYZ triad, so a C node whose
// imported zero pose is rotated showed a blue axis different from the
// axis actually used by driveJoint. Express the configured parent
// axis in the joint's zero-pose local frame before drawing the helper.
const parentAxis = selectedBinding ? new THREE . Vector3 ( selectedBinding . dof . direction === "x" ? 1 : 0 , selectedBinding . dof . direction === "y" ? 1 : 0 , selectedBinding . dof . direction === "z" ? 1 : 0 ) : null ;
const localAxis = parentAxis ? . applyQuaternion ( new THREE . Quaternion ( . . . ( selectedBinding ? . zero . quaternion ? ? [ 0 , 0 , 0 , 1 ] ) ) . invert ( ) ) . normalize ( ) ;
selectedJointAxes . quaternion . copy ( localAxis ? new THREE . Quaternion ( ) . setFromUnitVectors ( new THREE . Vector3 ( 0 , 0 , 1 ) , localAxis ) : new THREE . Quaternion ( ) ) ;
selectedJointAxes . scale . setScalar ( selectedAxesScale ) ; selectedJointAxes . visible = nextParts . find ( ( part ) = > part . id === nextSelectedId ) ? . showOrigin ? ? true ; element . dataset . machineManagerSelectedJoint = ` ${ nextSelectedId } : ${ nextNodePath } ` ; element . dataset . machineManagerSelectedJointAxes = selectedBinding ? ` parent- ${ selectedBinding . dof . direction } ${ selectedBinding . dof . sign < 0 ? "-" : "+" } ` : "local-xyz" ; element . dataset . machineManagerSelectedJointAxis = localAxis ? . toArray ( ) . map ( ( value ) = > Number ( value . toFixed ( 6 ) ) ) . join ( "," ) ? ? "" ; element . dataset . machineManagerTransformTarget = "single-dof-input" ;
} else { selectedJointAxes . visible = false ; delete element . dataset . machineManagerSelectedJoint ; delete element . dataset . machineManagerSelectedJointAxes ; delete element . dataset . machineManagerSelectedJointAxis ; delete element . dataset . machineManagerTransformTarget ; }
for ( const binding of dofNodes ) driveJoint ( binding . object , binding . zero , binding . dof , nextPose [ binding . dof . axis . toLowerCase ( ) ] ? ? 0 ) ;
root . updateMatrixWorld ( true ) ; let objectCount = 0 , meshCount = 0 ; root . traverse ( ( object ) = > { let cursor : THREE.Object3D | null = object , isJointHelper = false ; while ( cursor ) { if ( cursor === selectedJointAxes ) { isJointHelper = true ; break ; } cursor = cursor . parent ; } if ( ! isJointHelper ) { objectCount += 1 ; if ( ( object as THREE . Mesh ) . isMesh ) meshCount += 1 ; } } ) ; const transforms = Object . fromEntries ( [ . . . roleNodes ] . map ( ( [ role , binding ] ) = > [ role , { local : binding.object.position.toArray ( ) , localQuaternion : binding.object.quaternion.toArray ( ) , world : binding.object.getWorldPosition ( new THREE . Vector3 ( ) ) . toArray ( ) , worldQuaternion : binding.object.getWorldQuaternion ( new THREE . Quaternion ( ) ) . toArray ( ) } ] ) ) ; element . dataset . machineManagerSceneObjects = String ( objectCount ) ; element . dataset . machineManagerSceneMeshes = String ( meshCount ) ; element . dataset . machineManagerRoleTransforms = JSON . stringify ( transforms ) ;
element . dataset . machineManagerJointTransforms = JSON . stringify ( Object . fromEntries ( dofNodes . map ( ( { object , dof } ) = > [ dof . axis , { localMm : object.position.toArray ( ) . map ( ( v ) = > v * 1000 ) , quaternion : object.quaternion.toArray ( ) , world : object.getWorldPosition ( new THREE . Vector3 ( ) ) . toArray ( ) } ] ) ) ) ; if ( selectedJointObject ) { element . dataset . machineManagerSelectedJointWorldOrigin = JSON . stringify ( selectedJointObject . getWorldPosition ( new THREE . Vector3 ( ) ) . toArray ( ) ) ; element . dataset . machineManagerSelectedJointWorldQuaternion = JSON . stringify ( selectedJointObject . getWorldQuaternion ( new THREE . Quaternion ( ) ) . toArray ( ) ) ; element . dataset . machineManagerSelectedJointAxesScale = selectedAxesScale . toFixed ( 6 ) ; } const selectedState = selectedJointObject ? { local : selectedJointObject.position.toArray ( ) , localQuaternion : selectedJointObject.quaternion.toArray ( ) , world : selectedJointObject.getWorldPosition ( new THREE . Vector3 ( ) ) . toArray ( ) , worldQuaternion : selectedJointObject.getWorldQuaternion ( new THREE . Quaternion ( ) ) . toArray ( ) } : null ;
element . dataset . machineManagerSelectedTransform = JSON . stringify ( selectedState ) ;
selectionBox . makeEmpty ( ) ;
for ( const [ object , partId ] of owners ) {
if ( partId !== nextSelectedId ) continue ;
let visible = true ;
for ( let cursor : THREE.Object3D | null = object ; cursor ; cursor = cursor . parent ) if ( ! cursor . visible ) { visible = false ; break ; }
if ( ! visible ) continue ;
const geometry = ( object as THREE . Mesh ) . geometry ;
if ( ! geometry . boundingBox ) geometry . computeBoundingBox ( ) ;
if ( geometry . boundingBox ) selectionBox . union ( geometry . boundingBox . clone ( ) . applyMatrix4 ( object . matrixWorld ) ) ;
}
selectionHighlight . visible = ! selectionBox . isEmpty ( ) ;
element . dataset . machineManagerSelectedModel = nextSelectedId ? ? "" ;
element . dataset . machineManagerSelectionVisible = String ( selectionHighlight . visible ) ;
onMotionStateRef . current ( selectedState ? { . . . transforms , selected : selectedState } : transforms ) ; element . dataset . machineManagerTestPose = JSON . stringify ( nextPose ) ; element . dataset . machineManagerContextGeneration = "1" ; dirty = true ; draw ( ) ;
} ;
element . machineManagerSync ( stateRef . current . parts , stateRef . current . selectedId , stateRef . current . selectedNodePath , stateRef . current . testPose , stateRef . current . coordinates ) ;
@@ -125,11 +189,19 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test
const contextLostHandler = ( event : Event ) = > { event . preventDefault ( ) ; contextLost = true ; renderable = false ; element . dataset . machineManagerRenderState = "context-lost" ; setFault ( "WebGL 上下文已丢失,请关闭并重新打开三维机床管理器" ) ; } ;
renderer . domElement . addEventListener ( "webglcontextlost" , contextLostHandler ) ;
const observer = new ResizeObserver ( resize ) ; observer . observe ( element ) ; resize ( ) ;
// Tear down selection resources independently of the geometry shared with drafts.
const disposeSelection = ( ) = > {
renderer . domElement . removeEventListener ( "pointerdown" , pointerDown ) ;
renderer . domElement . removeEventListener ( "pointermove" , pointerMove ) ;
renderer . domElement . removeEventListener ( "pointerup" , pointerUp ) ;
renderer . domElement . removeEventListener ( "pointercancel" , pointerCancel ) ;
owners . clear ( ) ; pickable = [ ] ; selectionHighlight . dispose ( ) ;
} ;
orbit . addEventListener ( "change" , ( ) = > { dirty = true ; } ) ;
const render = ( ) = > { if ( ! contextLost && renderable && ( dirty || orbit . update ( ) ) ) draw ( ) ; frame = contextLost ? 0 : requestAnimationFrame ( render ) ; } ; render ( ) ;
return ( ) = > { if ( frame ) cancelAnimationFrame ( frame ) ; observer . disconnect ( ) ; delete element . machineManagerSync ; delete element . machineManagerView ; renderer . domElement . removeEventListener ( "webglcontextlost" , contextLostHandler ) ; orbit . dispose ( ) ; helpers . forEach ( ( helper ) = > helper . dispose ( ) ) ; grid . geometry . dispose ( ) ; for ( const material of Array . isArray ( grid . material ) ? grid . material : [ grid . material ] ) material . dispose ( ) ; selectedJointAxes . traverse ( ( object ) = > { const mesh = object as THREE . Mesh ; mesh . geometry ? . dispose ( ) ; if ( mesh . material ) for ( const material of Array . isArray ( mesh . material ) ? mesh . material : [ mesh . material ] ) material . dispose ( ) ; } ) ; environment . dispose ( ) ; renderer . dispose ( ) ; renderer . domElement . remove ( ) ; } ;
return ( ) = > { disposeSelection ( ) ; if ( frame ) cancelAnimationFrame ( frame ) ; observer . disconnect ( ) ; delete element . machineManagerSync ; delete element . machineManagerView ; renderer . domElement . removeEventListener ( "webglcontextlost" , contextLostHandler ) ; orbit . dispose ( ) ; helpers . forEach ( ( helper ) = > helper . dispose ( ) ) ; grid . geometry . dispose ( ) ; for ( const material of Array . isArray ( grid . material ) ? grid . material : [ grid . material ] ) material . dispose ( ) ; selectedJointAxes . traverse ( ( object ) = > { const mesh = object as THREE . Mesh ; mesh . geometry ? . dispose ( ) ; if ( mesh . material ) for ( const material of Array . isArray ( mesh . material ) ? mesh . material : [ mesh . material ] ) material . dispose ( ) ; } ) ; studio . dispose ( ) ; renderer . dispose ( ) ; renderer . domElement . remove ( ) ; } ;
} , [ ] ) ;
return < div className = "machine-manager-preview-shell" > < div ref = { host } className = "machine-manager-preview" data - machine - manager - preview data-machine-manager-coordinate-system = "machine-z-up" data-machine-manager-axis-map = "X:x,Y:y,Z:z" data-machine-manager-camera-up = "0,0,1" data-machine-manager-model-transform = "none-on-import" data-machine-manager-lighting = "studio-pmrem" data-machine-manager-exposure = "1.45" data-machine-manager-part-count = { parts . length } data-machine-manager-origin-count = { parts . filter ( ( part ) = > part . showOrigin ) . length } aria-label = "三维机床装配预览,机床 Z 轴朝上" / > { fault && < output className = "machine-manager-webgl-fault" role = "alert" > { fault } < / output > } < / div > ;
return < div className = "machine-manager-preview-shell" > < div ref = { host } className = "machine-manager-preview" data - machine - manager - preview data-machine-manager-coordinate-system = "machine-z-up" data-machine-manager-axis-map = "X:x,Y:y,Z:z" data-machine-manager-camera-up = "0,0,1" data-machine-manager-model-transform = "none-on-import" data-machine-manager-lighting = { MACHINE_STUDIO . lighting } data-machine-manager-tone-mapping = { MACHINE_STUDIO . toneMapping } data-machine-manager-exposure = { MACHINE_STUDIO . exposure } data-machine-manager-part-count = { parts . length } data-machine-manager-origin-count = { parts . filter ( ( part ) = > part . showOrigin ) . length } aria-label = "三维机床装配预览,机床 Z 轴朝上" / > { fault && < output className = "machine-manager-webgl-fault" role = "alert" > { fault } < / output > } < / div > ;
}
function TreeNode ( { part , node , selected , onSelect , onDrop , extra , query , expansion , locked } : { part : MachinePartDraft ; node : MachineTreeNode ; selected : string ; onSelect : ( partId : string , path : string ) = > void ; onDrop : ( source : { partId : string ; path : string } , target : { partId : string ; path : string } ) = > void ; extra? : React.ReactNode ; query : string ; expansion : { all : boolean ; serial : number } ; locked : boolean } ) {
@@ -172,13 +244,13 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const refreshLibrary = useCallback ( async ( ) = > { try { setLibrary ( await listSavedMachines ( ) ) ; setLibraryError ( "" ) ; } catch { setLibraryError ( "此浏览器无法使用本地机床库;仍可保存下载 GLB 并从磁盘打开。" ) ; } } , [ ] ) ;
useEffect ( ( ) = > { if ( open ) void refreshLibrary ( ) ; } , [ open , refreshLibrary ] ) ;
const requestOpen = ( ) = > { if ( ! pending && ! followLive ) openFile . current ? . click ( ) ; } ;
const newDocument = ( ) = > { if ( pending || followLive ) return ; edit ( ( ) = > ( { coordinates , documentInfo : { name : "" , description : "" } , parts : [ ] , cOriginMm : [ 0 , 0 , 0 ] , tiltOffsetMm : [ 0 , 0 , 0 ] , physicalZero : { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 } } ) ) ; setSelectedId ( null ) ; setQuery ( "" ) ; setTab ( "structure" ) ; setTestPose ( { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 } ) ; setSaveLocation ( "新建机床尚未保存;旧版本仍在本地机床库" ) ; setMessage ( "已新建机床,原草稿可撤销恢复" ) ; } ;
const newDocument = ( ) = > { if ( pending || followLive ) return ; edit ( ( ) = > ( { coordinates , documentInfo : { name : "" , description : "" } , parts : [ ] , cOriginMm : [ 0 , 0 , 0 ] , tiltOffsetMm : [ 0 , 0 , 0 ] , physicalZero : zeroPose ( coordinates ) } ) ) ; setSelectedId ( null ) ; setQuery ( "" ) ; setTab ( "structure" ) ; setTestPose ( zeroPose ( coordinates ) ) ; setSaveLocation ( "新建机床尚未保存;旧版本仍在本地机床库" ) ; setMessage ( "已新建机床,原草稿可撤销恢复" ) ; } ;
const disposeSources = ( sources : Awaited < ReturnType < typeof loadMachinePart > > [ ] ) = > { const scenes = new Set ( sources . flatMap ( ( source ) = > source . restoredParts ? . map ( ( part ) = > part . scene ) ? ? [ source . scene ] ) ) ; scenes . forEach ( disposeMachineScene ) ; } ;
const [ selectedId , setSelectedId ] = useState < string | null > ( null ) , [ selectedNodePath , setSelectedNodePath ] = useState ( "scene" ) ;
const [ tab , setTab ] = useState < EditorTab > ( "structure" ) , [ query , setQuery ] = useState ( "" ) ;
const [ expansion , setExpansion ] = useState ( { all : false , serial : 0 } ) ;
const [ command , setCommand ] = useState < PreviewCommand > ( { action : "fit" , serial : 0 } ) ;
const [ jogStep , setJogStep ] = useState ( 1 ) ;
const [ jogStep , setJogStep ] = useState ( MANAGER_SETTINGS . initialJogStep ) ;
const [ applied , setApplied ] = useState ( "" ) , [ exported , setExported ] = useState ( "" ) ;
const [ invalidFields , setInvalidFields ] = useState < string [ ] > ( [ ] ) ;
const validity = useCallback ( ( field : string , valid : boolean ) = > setInvalidFields ( ( current ) = > valid ? current . includes ( field ) ? current . filter ( ( id ) = > id !== field ) : current : current.includes ( field ) ? current : [ . . . current , field ] ) , [ ] ) ;
@@ -191,11 +263,12 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
retainedScenes . current = keep ;
} , [ history ] ) ;
useEffect ( ( ) = > ( ) = > { retainedScenes . current . forEach ( disposeMachineScene ) ; } , [ ] ) ;
const [ testPose , setTestPose ] = useState < TestPose > ( { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 } ) ;
const [ testPose , setTestPose ] = useState < TestPose > ( ( ) = > zeroPose ( coordinates ) ) ;
const [ motionState , setMotionState ] = useState < Record < string , MotionTransform > > ( { } ) ; const motionStateHash = useRef ( "" ) ;
useEffect ( ( ) = > { setTestPose ( { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 } ) ; setFollowLive ( false ) ; } , [ coordinates ] ) ;
useEffect ( ( ) = > { if ( selectedId && ! parts . some ( ( part ) = > part . id === selectedId ) ) { setSelectedId ( parts [ 0 ] ? . id ? ? null ) ; setSelectedNodePath ( "scene" ) ; } } , [ parts , selectedId ] ) ;
useEffect ( ( ) = > { setTestPose ( zeroPose ( coordinates ) ) ; setFollowLive ( false ) ; } , [ coordinates ] ) ;
useEffect ( ( ) = > { if ( ! parts . some ( ( part ) = > part . id === selectedId ) ) { setSelectedId ( parts [ 0 ] ? . id ? ? null ) ; setSelectedNodePath ( "scene" ) ; } } , [ parts , selectedId ] ) ;
const selected = parts . find ( ( part ) = > part . id === selectedId ) ? ? null ;
const selectedAnalysis = useMemo < GlbFiveAxisAnalysis | null > ( ( ) = > selected ? analyzeGlbFiveAxisScene ( selected . scene , selected . filename ) : null , [ selected ] ) ;
const trees = useMemo ( ( ) = > new Map ( parts . map ( ( part ) = > [ part . id , machinePartTree ( part ) ] ) ) , [ parts ] ) ;
const findTreeNode = ( node : MachineTreeNode | undefined , path : string ) : MachineTreeNode | null = > node ? node . path === path ? node : node.children.map ( ( child ) = > findTreeNode ( child , path ) ) . find ( Boolean ) ? ? null : null ;
const selectedTreeNode = selected ? findTreeNode ( trees . get ( selected . id ) , selectedNodePath ) : null ;
@@ -205,7 +278,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const importFiles = async ( list : FileList | File [ ] | null , savedOnly = false ) = > {
if ( ! list ? . length || pending || followLive ) return ; setPending ( true ) ; setMessage ( "正在读取 GLB 部件…" ) ;
try {
const results = await Promise . allSettled ( [ . . . list ] . map ( async ( item ) = > { if ( ! item . name . toLowerCase ( ) . endsWith ( ".glb" ) || item . size > 32 * 1024 * 1024 ) throw new Error ( ` ${ item . name } :请选择不超过 32 MiB 的 GLB ` ) ; try { return await loadMachinePart ( await item . arrayBuffer ( ) , item . name ) ; } catch { throw new Error ( ` ${ item . name } :模型无法读取,请检查是否为完整的自包含 GLB, 或配置是否与模型层级一致 ` ) ; } } ) ) ;
const results = await Promise . allSettled ( [ . . . list ] . map ( async ( item ) = > { if ( ! item . name . toLowerCase ( ) . endsWith ( ".glb" ) || item . size > MAX_MACHINE_BYTES ) throw new Error ( ` ${ item . name } :请选择不超过 64 MiB 的 GLB ` ) ; try { return await loadMachinePart ( await item . arrayBuffer ( ) , item . name ) ; } catch { throw new Error ( ` ${ item . name } :模型无法读取,请检查是否为完整的自包含 GLB, 或配置是否与模型层级一致 ` ) ; } } ) ) ;
const loaded = results . flatMap ( ( result ) = > result . status === "fulfilled" ? [ result . value ] : [ ] ) ;
const failure = results . find ( ( result ) = > result . status === "rejected" ) ;
if ( failure ? . status === "rejected" ) { disposeSources ( loaded ) ; throw failure . reason ; }
@@ -219,6 +292,9 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
disposeSources ( loaded ) ;
throw new Error ( "完整机床 GLB 请单独打开;普通部件可批量导入" ) ;
}
const autoAnalysis = new Map ( loaded . map ( ( source ) = > [ source . id , source . restoredModel ? null : analyzeGlbFiveAxisScene ( source . scene , source . filename ) ] ) ) ;
const detectedCoordinates = loaded . length === 1 ? autoAnalysis . get ( loaded [ 0 ] . id ) ? . suggestedCoordinates : undefined ;
const autoCoordinates = detectedCoordinates && isFiveAxisCoordinates ( detectedCoordinates ) ? detectedCoordinates : draft.coordinates ;
const nextDraft = ( ( ) = > {
const next = restored ? [ ] : [ . . . draft . parts ] ;
for ( const source of loaded ) {
@@ -226,18 +302,41 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const role = MACHINE_PART_ROLES . find ( ( candidate ) = > ! next . some ( ( part ) = > part . role === candidate ) ) ? ? "" ;
const parentRole = role ? expectedParentRole ( role ) : null ;
const parentId = parentRole ? next . find ( ( part ) = > part . role === parentRole ) ? . id ? ? null : null ;
next . push ( { . . . source , role , parentId , visible : true , showOrigin : true , originMm : zero3 ( ) , translationMm : zero3 ( ) , rotationDeg : zero3 ( ) , nodeParents : { } , nodeTransforms : { } , nodeJoints : source.restoredJoints ? ? { } , nodeRoles : source.restoredRoles ? ? { } } ) ;
const analysis = autoAnalysis . get ( source . id ) ;
const nodeJoints = source . restoredJoints ? ? ( analysis ? Object . fromEntries ( analysis . axes . filter ( ( item ) = > autoCoordinates . includes ( item . axis ) ) . map ( ( item ) = > [ item . path , defaultDof ( item . axis as CncAxis ) ] ) ) : { } ) ;
// A single complete GLB such as m1.glb keeps its native hierarchy.
// Promote the detected axis nodes to structure roles so the
// Structure & Joints tab is immediately usable, while preserving
// explicit roles restored from a managed machine document.
const detectedRoles : Record < string , MachinePartRole | "" > = analysis ? Object . fromEntries ( analysis . axes . filter ( ( item ) = > autoCoordinates . includes ( item . axis ) ) . map ( ( item ) = > [ item . path , ( item . axis === "A" || item . axis === "B" ? "tilt" : item . axis . toLowerCase ( ) ) as MachinePartRole ] ) ) : { } ;
const nodeRoles = source . restoredRoles ? ? detectedRoles ;
next . push ( { . . . source , role , parentId , visible : true , showOrigin : true , originMm : zero3 ( ) , translationMm : zero3 ( ) , rotationDeg : zero3 ( ) , nodeParents : { } , nodeTransforms : { } , nodeJoints , nodeRoles } ) ;
}
return { . . . draft , parts : next , . . . ( restored ? { coordinates : restored.coordinates , documentInfo : restoredSource?.restoredInfo ? ? { name : loaded [ 0 ] . filename . replace ( /\.glb$/i , "" ) , description : "" } , cOriginMm : restored.calibration.cOriginMm , tiltOffsetMm : restored.calibration.tiltOffsetMm , physicalZero : restored.calibration.physicalZero as PhysicalZero } : { } ) } ;
return { . . . draft , parts : next , . . . ( restored ? { coordinates : restored.coordinates , documentInfo : restoredSource?.restoredInfo ? ? { name : loaded [ 0 ] . filename . replace ( /\.glb$/i , "" ) , description : "" } , cOriginMm : restored.calibration.cOriginMm , tiltOffsetMm : restored.calibration.tiltOffsetMm , physicalZero : restored.calibration.physicalZero as PhysicalZero } : { coordinates : autoCoordinates } ) } ;
} ) ( ) ;
edit ( ( ) = > nextDraft ) ;
if ( restored ) setSavedSignature ( nextDraft . coordinates + draftFingerprint ( nextDraft ) ) ;
setTestPose ( { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 } ) ; setTab ( "structure" ) ; setQuery ( "" ) ; setSelectedId ( loaded [ 0 ] . restoredParts ? . [ 0 ] . id ? ? loaded [ 0 ] . id ) ; setSelectedNodePath ( "scene" ) ; if ( restored ) setSaveLocation ( ` 已打开文件: ${ loaded [ 0 ] . filename } ;可继续编辑并保存新版本 ` ) ; setMessage ( restored ? ` 已打开 ${ restored . coordinates } 完整机床,恢复层级、关节和标定;原草稿可撤销恢复 ` : ` 已导入 ${ loaded . length } 个部件 ` ) ;
setTestPose ( zeroPose ( restored ? . coordinates ? ? autoCoordinates ) ) ; setTab ( "structure" ) ; setQuery ( "" ) ; setSelectedId ( loaded [ 0 ] . restoredParts ? . [ 0 ] . id ? ? loaded [ 0 ] . id ) ; setSelectedNodePath ( "scene" ) ; if ( restored ) setSaveLocation ( ` 已打开文件: ${ loaded [ 0 ] . filename } ;可继续编辑并保存新版本 ` ) ; setMessage ( restored ? ` 已打开 ${ restored . coordinates } 完整机床,恢复层级、关节和标定;原草稿可撤销恢复 ` : ` 已导入 ${ loaded . length } 个部件 ${ detectedCoordinates && isFiveAxisCoordinates ( detectedCoordinates ) ? ` ;自动识别 ${ detectedCoordinates } ,已绑定 ${ autoAnalysis . get ( loaded [ 0 ] . id ) ? . axes . filter ( ( item ) = > autoCoordinates . includes ( item . axis ) ) . length ? ? 0 } / ${ [ . . . autoCoordinates ] . length } 轴 ` : "" } ` ) ;
} catch ( error ) { setMessage ( error instanceof Error ? error.message : String ( error ) ) ; }
finally { setPending ( false ) ; }
} ;
const remove = ( part : MachinePartDraft ) = > { if ( pending || followLive ) return ; setParts ( ( current ) = > current . filter ( ( item ) = > item . id !== part . id ) . map ( ( item ) = > item . parentId === part . id ? { . . . item , parentId : null } : item ) ) ; setMessage ( ` 已删除 ${ part . filename } ,可撤销恢复 ` ) ; if ( selectedId === part . id ) { setSelectedId ( null ) ; setSelectedNodePath ( "scene" ) ; } } ;
const remove = ( part : MachinePartDraft ) = > {
if ( pending || followLive || ! parts . some ( ( item ) = > item . id === part . id ) ) return ;
const index = parts . findIndex ( ( item ) = > item . id === part . id ) ;
const remaining = parts . filter ( ( item ) = > item . id !== part . id ) ;
// Remove this import only. Separately imported children remain in the draft.
setParts ( ( current ) = > current . filter ( ( item ) = > item . id !== part . id ) . map ( ( item ) = > item . parentId === part . id ? { . . . item , parentId : null } : item ) ) ;
setMessage ( ` 已删除 ${ part . filename } ,可撤销恢复 ` ) ;
if ( selectedId === part . id ) {
setSelectedId ( remaining [ Math . min ( index , remaining . length - 1 ) ] ? . id ? ? null ) ;
setSelectedNodePath ( "scene" ) ; setQuery ( "" ) ;
}
} ;
const selectTreeNode = ( partId : string , path : string ) = > { setSelectedId ( partId ) ; setSelectedNodePath ( path ) ; } ;
const selectModel = ( partId : string ) = > {
if ( pending || ! parts . some ( ( part ) = > part . id === partId ) ) return ;
selectTreeNode ( partId , "scene" ) ; setQuery ( "" ) ; setTab ( "structure" ) ;
} ;
const reparentTreeNode = ( source : { partId : string ; path : string } , target : { partId : string ; path : string } ) = > {
if ( pending || followLive ) return ;
if ( source . partId === target . partId && source . path === target . path ) return ;
@@ -270,7 +369,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const exportScene = new THREE . Scene ( ) ; exportScene . name = machine . scene . name ; exportScene . userData = machine . scene . userData ;
for ( const child of machine . scene . children ) exportScene . add ( child . clone ( true ) ) ;
const bytes = await new GLTFExporter ( ) . parseAsync ( exportScene , { binary : true , trs : true , onlyVisible : false } ) ;
if ( ! ( bytes instanceof ArrayBuffer ) || bytes . byteLength > MAX_MACHINE_BYTES ) throw new Error ( "保存文件超过控制面板支持的 32 MiB, 请减小模型后重试" ) ;
if ( ! ( bytes instanceof ArrayBuffer ) || bytes . byteLength > MAX_MACHINE_BYTES ) throw new Error ( "保存文件超过控制面板支持的 64 MiB, 请减小模型后重试" ) ;
const saved = inspectMachineGlb ( bytes ) ;
if ( saved . structure . coordinates !== coordinates ) throw new Error ( "保存模型的机型与当前配置不一致" ) ;
const filename = machineDocumentFilename ( documentInfo . name , coordinates ) ;
@@ -338,61 +437,73 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const visual = useMemo ( ( ) = > projectXyzacVisual ( snapshot ) , [ snapshot ] ) ;
const liveReady = visual . joints . every ( Number . isFinite ) && visual . authoritative && visual . machineModel ? . coordinate_system === coordinates ;
const heldLivePose = useRef < TestPose > ( testPose ) ;
const effectivePose : TestPose = followLive && liveReady ? { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 , . . . Object . fromEntries ( [ . . . coordinates ] . map ( ( axis , i ) = > [ axis . toLowerCase ( ) , visual . joints [ i ] - physicalZero [ axis . toLowerCase ( ) as keyof PhysicalZero ] ] ) ) } : followLive ? heldLivePose.current : testPose ;
const effectivePose : TestPose = followLive && liveReady ? { . . . zeroPose ( coordinates ) , . . . Object . fromEntries ( [ . . . coordinates ] . map ( ( axis , i ) = > [ axis . toLowerCase ( ) , visual . joints [ i ] - Number ( physicalZero [ axis . toLowerCase ( ) ] ? ? 0 ) ] ) ) } : followLive ? heldLivePose.current : testPose ;
if ( followLive && liveReady ) heldLivePose . current = effectivePose ;
const validation = useMemo ( ( ) = > { try { if ( Math . abs ( tiltOffsetMm [ coordinates === "XYZAC" ? 0 : 1 ] ) > 1 e - 9 ) throw new Error ( coordinates === "XYZAC" ? "AC 机床的 A→C 偏置 X 必须为 0" : "BC 机床的 B→C 偏置 Y 必须为 0" ) ; const { model } = makeJointAssembly ( { coordinates , parts , cOriginMm , tiltOffsetMm , physicalZero } ) ; const missing = [ . . . coordinates ] . filter ( ( axis ) = > ! model . axisMap [ axis as CncAxis ] ) ; return { model , error : missing.length ? ` 请绑定 ${ missing . join ( "、" ) } 轴 ` : "" } ; } catch ( error ) { return { model : null , error : error instanceof Error ? error.message : String ( error ) } ; } } , [ coordinates , parts , cOriginMm , tiltOffsetMm , physicalZero ] ) ;
const validation = useMemo ( ( ) = > { try { validateManagerCalibration ( coordinates , cOriginMm , tiltOffsetMm ) ; const { model } = makeJointAssembly ( { coordinates , parts , cOriginMm , tiltOffsetMm , physicalZero } ) ; const missing = [ . . . coordinates ] . filter ( ( axis ) = > ! model . axisMap [ axis as CncAxis ] ) ; return { model , error : missing.length ? ` 请绑定 ${ missing . join ( "、" ) } 轴 ` : "" } ; } catch ( error ) { return { model : null , error : error instanceof Error ? error.message : String ( error ) } ; } } , [ coordinates , parts , cOriginMm , tiltOffsetMm , physicalZero ] ) ;
const blockingReason = invalidFields . length ? "请修正标红的数值输入" : validation . error ;
const bindings = useMemo ( ( ) = > parts . flatMap ( ( part ) = > Object . entries ( part . nodeJoints ? ? { } ) . map ( ( [ path , dof ] ) = > ( { part , path , dof , name : findTreeNode ( trees . get ( part . id ) , path ) ? . name ? ? part . filename } ) ) ) , [ parts , trees ] ) ;
const moveToBinding = ( axis : string ) = > { const binding = bindings . find ( ( b ) = > b . dof . axis === axis ) ; setTab ( "structure" ) ; setQuery ( "" ) ; if ( binding ) selectTreeNode ( binding . part . id , binding . path ) ; else { setMessage ( ` 在层级树中选择 ${ axis } 轴关节,再设置唯一自由度 ` ) ; } } ;
const changeHistory = ( direction : "undo" | "redo" ) = > { if ( pending || followLive ) return ; if ( direction === "undo" ) undo ( ) ; else redo ( ) ; setMessage ( direction === "undo" ? "已撤销配置修改" : "已重做配置修改" ) ; } ;
const changeHistory = ( direction : "undo" | "redo" ) = > {
if ( pending || followLive ) return ;
const next = direction === "undo" ? history . past . at ( - 1 ) : history . future [ 0 ] ;
if ( ! next ) return ;
const restoredPart = next . parts . find ( ( part ) = > ! parts . some ( ( current ) = > current . id === part . id ) ) ;
const nextId = restoredPart ? . id ? ? ( next . parts . some ( ( part ) = > part . id === selectedId ) ? selectedId : next.parts [ 0 ] ? . id ? ? null ) ;
if ( nextId !== selectedId ) { setSelectedId ( nextId ) ; setSelectedNodePath ( "scene" ) ; setQuery ( "" ) ; }
if ( direction === "undo" ) undo ( ) ; else redo ( ) ;
setMessage ( direction === "undo" ? "已撤销配置修改" : "已重做配置修改" ) ;
} ;
const exportJson = ( ) = > {
if ( ! validation . model || pending || invalidFields . length ) return ;
const url = URL . createObjectURL ( new Blob ( [ JSON . stringify ( validation . model , null , 2 ) ] , { type : "application/json" } ) ) ;
const a = document . createElement ( "a" ) ; a . href = url ; a . download = ` machine-joints- ${ coordinates . toLowerCase ( ) } .json ` ; a . click ( ) ; setTimeout ( ( ) = > URL . revokeObjectURL ( url ) , 1000 ) ; setMessage ( "关节 JSON 已导出;恢复完整模型请使用“保存机床模型 GLB”" ) ;
} ;
const poseInput = ( axis : keyof TestPose , unit : string ) = > < label key = { axis } > { axis . toUpperCase ( ) }< MachineNumberInput fieldId = { ` test- ${ axis } ` } control = { ` machine-test- ${ axis } ` } onValidity = { validity } disabled = { pending || followLive || ! bindings . some ( ( b ) = > b . dof . axis . toLowerCase ( ) === axis ) } value = { effectivePose [ axis ] } onChange = { ( value ) = > setTestPose ( ( pose ) = > ( { . . . pose , [ axis ] : value } ) ) } / > < small > { unit } < / small > < / label > ;
const poseInput = ( axis : CncAxis ) = > { const key = axis . toLowerCase ( ) ; return < label key = { axis } > { axis } < MachineNumberInput fieldId = { ` test- ${ key } ` } control = { ` machine-test- ${ key } ` } onValidity = { validity } disabled = { pending || followLive || ! bindings . some ( ( b ) = > b . dof . axis === axis ) } value = { effectivePose [ key ] ? ? 0 } onChange = { ( value ) = > setTestPose ( ( pose ) = > ( { . . . pose , [ key ] : value } ) ) } / > < small > { axisUnit ( axis ) } < / small > < / label > ; } ;
const receiveMotionState = ( state : Record < string , MotionTransform > ) = > { const hash = JSON . stringify ( state ) ; if ( hash !== motionStateHash . current ) { motionStateHash . current = hash ; setMotionState ( state ) ; } } ;
const selectedDriven = motionState . selected ? ? null ;
const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?. [ selectedNodePath ] ? ? ( selectedTreeNode ? { translationMm : selectedTreeNode.originMm , rotationDeg : selectedTreeNode.rotationDeg } : null ) ;
const activeAxes = managerAxes ( coordinates ) ;
const profile = managerProfile ( coordinates ) ;
const renderPartTree = ( part : MachinePartDraft ) : React . ReactNode = > < TreeNode key = { part . id } part = { part } node = { trees . get ( part . id ) ! } selected = { ` ${ selectedId } : ${ selectedNodePath } ` } onSelect = { selectTreeNode } onDrop = { reparentTreeNode } query = { query } expansion = { expansion } locked = { pending || followLive } extra = { parts . filter ( ( child ) = > child . parentId === part . id ) . map ( renderPartTree ) } / > ;
return < section className = "machine-manager" aria-label = "三维机床管理" >
< button type = "button" data-axis-control = "machine-manager-settings" aria-haspopup = "dialog" onClick = { ( ) = > setOpen ( true ) } > 三 维 机 床 管 理 < / button >
< DraggableDialog open = { open } title = { ` 三维机床管理 · ${ coordinates } ` } className = "machine-manager-dialog" cancelGuard = { ( ) = > filePickerOpen . current } onClose = { ( ) = > setOpen ( false ) } >
< div className = "machine-manager-workbench" aria-busy = { pending } >
< div className = "machine-type-step" > < label > < strong > 1 · 机 床 类 型 < / strong > < select data-axis-control = "machine-manager-type" aria-label = "设计机床类型" value = { coordinates } disabled = { pending || followLive } onChange = { ( event ) = > {
const next = event . target . value as "XYZAC" | "XYZBC" ;
const next = event . target . value ;
edit ( ( draft ) = > changeMachineType ( draft , next ) ) ; setTab ( "structure" ) ;
setMessage ( ` 设计机型已选择 ${ next } ;保留模型层级和 XYZ/C 绑定,请重新配置 ${ next [ 3 ] } 轴并核对 标定。可撤销恢复。 ` ) ;
} } > < option value = "XYZAC" > XYZAC · A / C 五 轴 转 台 < / option > < option value = "XYZBC" > XYZBC · B / C 五 轴 转 台 < / option > < / select > < / label > < label > < strong > LinuxCNC 案 例 / 运 动 学 < / strong > < select data-axis-control = "machine-manager-case-type" aria-label = "LinuxCNC 案例与运动学" value = { FIVE_AXIS_CASES . find ( ( item ) = > item . coordinates === coordinates && item . webWasm === "supported" ) ? . id ? ? FIVE_AXIS_CASES [ 0 ] . id } disabled = { pending || followLive } onChange = { ( event ) = > {
setMessage ( ` 设计机型已选择 ${ next } ;保留模型层级,请核对 ${ next } 轴绑定和 标定。可撤销恢复。 ` ) ;
} } > { catalogCoordinateOptions . map ( ( axisSet ) = > { const caseItem = FIVE_AXIS_CASES . find ( ( item ) = > item . coordinates === axisSet ) ; return < option key = { axisSet } value = { axisSet } disabled = { ! supportedCoordinateOptions . has ( axisSet ) } > { caseItem ? . label ? ? axisSet } { supportedCoordinateOptions . has ( axisSet ) ? "" : " · 待 WASM 适配" } < / option > ; } ) } </ select > < / label > < label > < strong > LinuxCNC 案 例 / 运 动 学 < / strong > < select data-axis-control = "machine-manager-case-type" aria-label = "LinuxCNC 案例与运动学" value = { FIVE_AXIS_CASES . find ( ( item ) = > item . coordinates === coordinates && item . webWasm === "supported" ) ? . id ? ? FIVE_AXIS_CASES . find ( ( item ) = > item . coordinates === coordinates ) ? . id ? ? FIVE_AXIS_CASES [ 0 ] . id } disabled = { pending || followLive } onChange = { ( event ) = > {
const selectedCase = FIVE_AXIS_CASES . find ( ( item ) = > item . id === event . target . value ) ;
if ( ! selectedCase || ! supportedCoordinateOptions . has ( selectedCase . coordinates ) ) { setMessage ( ` ${ selectedCase ? . label ? ? event . target . value } 已登记源码案例,但当前 Web/WASM 运动学适配尚未完成 ` ) ; return ; }
edit ( ( draft ) = > changeMachineType ( draft , selectedCase . coordinates as "XYZAC" | "XYZBC" ) ) ; setTab ( "structure" ) ; setMessage ( ` 已选择案例 ${ selectedCase . label } ,设计机型同步为 ${ selectedCase . coordinates } ` ) ;
} } > { FIVE_AXIS_CASES . map ( ( item ) = > < option key = { item . id } value = { item . id } disabled = { item . webWasm !== "supported" } > { item . label } { item . webWasm === "supported" ? "" : " · 待 WASM 适配" } < / option > ) } < / select > < / label > < div > < span > 2 · 结 构 与 关 节 → 3 · 验 证 与 标 定 → 4 · 保 存 GLB < / span > < small data-axis-control = "machine-manager-type-note" > { coordinates === "XYZAC" ? "A 绕 X 倾转, C 绕 Z 回转" : "B 绕 Y 倾转, C 绕 Z 回转 " } · 控 制 面 板 当 前 { controllerCoordinates } { coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : "" } · 其 它 源 码 类 型 见 “ 文 件 与 恢 复 ” 的 案 例 库 < / small > < / div > < / div >
< div className = "machine-manager-toolbar" > < button type = "button" data-axis-control = "machine-document-new" disabled = { pending || followLive } onClick = { newDocument } > 新 建 机 床 < / button > < button type = "button" data-axis-control = "machine-document-open" disabled = { pending || followLive } onClick = { ( ) = > beginFilePicker ( openFile . current ) } > 打 开 已 保 存 机 床 < / button > < button type = "button" data-axis-control = "machine-document-from-controller" disabled = { ! currentMachine ? . scene . userData . linuxcncJointModel || pending || followLive } onClick = { ( ) = > void editControllerModel ( ) } > 编 辑 控 制 面 板 机 床 < / button > < button type = "button" data-axis-control = "machine-manager-import" disabled = { pending || followLive } onClick = { ( ) = > beginFilePicker ( file . current ) } > 导 入 GLB 部 件 < / button > < button type = "button" data-axis-control = "machine-manager-undo" disabled = { ! history . past . length || pending || followLive } onClick = { ( ) = > changeHistory ( "undo" ) } > 撤 销 < / button > < button type = "button" data-axis-control = "machine-manager-redo" disabled = { ! history . future . length || pending || followLive } onClick = { ( ) = > changeHistory ( "redo" ) } > 重 做 < / button > < span > { parts . length } 个 模 型 · { bindings . filter ( ( b ) = > coordinates . includes ( b . dof . axis ) ) . length } / 5 轴 已 绑 定 < / span > < span className = "machine-save-state" data-axis-control = "machine-manager-draft-state" > { ! parts . length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用" } < / span > < / div >
< div className = "machine-axis-overview" aria-label = "数控轴绑定总览" > { [ . . . coordinates ] . map ( ( axis ) = > { const binding = bindings . find ( ( b ) = > b . dof . axis === axis ) ; return < button type = "button" key = { axis } data-axis-control = { ` machine-axis-locate- ${ axis . toLowerCase ( ) } ` } className = { binding ? "bound" : "missing" } onClick = { ( ) = > moveToBinding ( axis ) } title = { binding ? ` ${ binding . name } · 父级 ${ binding . dof . direction . toUpperCase ( ) } ${ binding . dof . sign > 0 ? "+" : "− " } ` : ` 待绑定 ${ axis } 轴 ` } > < strong > { axis } < / strong > < span > { binding ? . name ? ? "待绑定" } < / span > < small > { binding ? ` ${ binding . dof . sign > 0 ? "+" : "− " } ${ binding . dof . direction . toUpperCase ( ) } ` : "选择节点" } < / small > < / button > ; } ) } < / div >
edit ( ( draft ) = > changeMachineType ( draft , selectedCase . coordinates ) ) ; setTab ( "structure" ) ; setMessage ( ` 已选择案例 ${ selectedCase . label } ,设计机型同步为 ${ selectedCase . coordinates } ` ) ;
} } > { FIVE_AXIS_CASES . map ( ( item ) = > < option key = { item . id } value = { item . id } disabled = { item . webWasm !== "supported" } > { item . label } { item . webWasm === "supported" ? "" : " · 待 WASM 适配" } < / option > ) } < / select > < / label > < div > < span > 2 · 结 构 与 关 节 → 3 · 验 证 与 标 定 → 4 · 保 存 GLB < / span > < small data-axis-control = "machine-manager-type-note" > { coordinates . length >= 5 ? ` 当前坐标 ${ coordinates } · ${ coordinates [ 3 ] } / ${ coordinates [ 4 ] } 旋转轴 ` : "请从案例目录选择有效坐标 "} · 控 制 面 板 当 前 { controllerCoordinates } { coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : "" } · 其 它 源 码 类 型 见 “ 文 件 与 恢 复 ” 的 案 例 库 < / small > < / div > < / div >
< div className = "machine-manager-toolbar" > < button type = "button" data-axis-control = "machine-document-new" disabled = { pending || followLive } onClick = { newDocument } > 新 建 机 床 < / button > < button type = "button" data-axis-control = "machine-document-open" disabled = { pending || followLive } onClick = { ( ) = > beginFilePicker ( openFile . current ) } > 打 开 已 保 存 机 床 < / button > < button type = "button" data-axis-control = "machine-document-from-controller" disabled = { ! currentMachine ? . scene . userData . linuxcncJointModel || pending || followLive } onClick = { ( ) = > void editControllerModel ( ) } > 编 辑 控 制 面 板 机 床 < / button > < button type = "button" data-axis-control = "machine-manager-import" disabled = { pending || followLive } onClick = { ( ) = > beginFilePicker ( file . current ) } > 导 入 GLB 部 件 < / button > < button type = "button" data-axis-control = "machine-model-delete-selected" disabled = { ! selected || pending || followLive } title = { selected ? ` 删除 ${ selected . filename } ,可撤销恢复 ` : "请先选择导入模型" } onClick = { ( ) = > { if ( selected ) remove ( selected ) ; } } > 删 除 选 中 模 型 < / button > < button type = "button" data-axis-control = "machine-manager-undo" disabled = { ! history . past . length || pending || followLive } onClick = { ( ) = > changeHistory ( "undo" ) } > 撤 销 < / button > < button type = "button" data-axis-control = "machine-manager-redo" disabled = { ! history . future . length || pending || followLive } onClick = { ( ) = > changeHistory ( "redo" ) } > 重 做 < / button > < span > { parts . length } 个 模 型 · { bindings . filter ( ( b ) = > coordinates . includes ( b . dof . axis ) ) . length } / { activeAxes . length } 轴 已 绑 定 < / span > < span className = "machine-save-state" data-axis-control = "machine-manager-draft-state" > { ! parts . length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用" } < / span > < / div >
< div className = "machine-axis-overview" aria-label = "数控轴绑定总览" > { activeAxes . map ( ( axis ) = > { const binding = bindings . find ( ( b ) = > b . dof . axis === axis ) ; return < button type = "button" key = { axis } data-axis-control = { ` machine-axis-locate- ${ axis . toLowerCase ( ) } ` } className = { binding ? "bound" : "missing" } onClick = { ( ) = > moveToBinding ( axis ) } title = { binding ? ` ${ binding . name } · 父级 ${ binding . dof . direction . toUpperCase ( ) } ${ binding . dof . sign > 0 ? "+" : "− " } ` : ` 待绑定 ${ axis } 轴 ` } > < strong > { axis } < / strong > < span > { binding ? . name ? ? "待绑定" } < / span > < small > { binding ? ` ${ binding . dof . sign > 0 ? "+" : "− " } ${ binding . dof . direction . toUpperCase ( ) } ` : "选择节点" } < / small > < / button > ; } ) } < / div >
< div className = "machine-manager-layout" >
< section className = "machine-preview-area" aria-label = "机床三维预览" > < div className = "machine-preview-tools" > { ( [ [ "fit" , "整机适配" ] , [ "selected" , "定位选中" ] , [ "front" , "正视" ] , [ "top" , "俯视" ] ] as const ) . map ( ( [ action , label ] ) = > < button key = { action } type = "button" data-axis-control = { ` machine-view- ${ action } ` } disabled = { ! parts . length || ( action === "selected" && ! selected ) } onClick = { ( ) = > setCommand ( ( current ) = > ( { action , serial : current.serial + 1 } ) ) } > { label } < / button > ) } < small > Z ↑ · X 红 / Y 绿 / Z 蓝 < / small > < / div >
{ open && < MachineManagerPanelPreview command = { command } coordinates = { coordinates } parts = { parts } selectedId = { selectedId } selectedNodePath = { selectedNodePath } testPose = { effectivePose } onMotionState = { receiveMotionState } / > }
{ ! parts . length && < div className = "machine-preview-empty" > < strong > 建 立 机 床 的 数 字 装 配 < / strong > < p > 选 择 机 床 类 型 → 导 入 GLB → 绑 定 关 节 → 保 存 机 床 模 型 < / p > < button type = "button" disabled = { pending } onClick = { ( ) = > beginFilePicker ( file . current ) } > 导 入 机 床 或 部 件 < / button > < small > 支 持 完 整 层 级 · 单 文 件 不 超 过 32 MiB < / small > { library . length > 0 && < button type = "button" onClick = { ( ) = > setTab ( "files" ) } > 恢 复 本 地 机 床 库 ( { library . length } 个 版 本 ) < / button > } < / div > }
< p className = "machine-preview-hint" > 左 键 旋 转 · 滚 轮 缩 放 · 右 键 平 移 · 选 中 关 节 显 示 局 部 坐 标 系 < / p > < / section >
{ open && < MachineManagerPanelPreview command = { command } coordinates = { coordinates } parts = { parts } selectedId = { selectedId } selectedNodePath = { selectedNodePath } testPose = { effectivePose } onMotionState = { receiveMotionState } onSelectModel = { selectModel } / > }
{ ! parts . length && < div className = "machine-preview-empty" > < strong > 建 立 机 床 的 数 字 装 配 < / strong > < p > 选 择 机 床 类 型 → 导 入 GLB → 绑 定 关 节 → 保 存 机 床 模 型 < / p > < button type = "button" disabled = { pending } onClick = { ( ) = > beginFilePicker ( file . current ) } > 导 入 机 床 或 部 件 < / button > < small > 支 持 完 整 层 级 · 单 文 件 不 超 过 64 MiB < / small > { library . length > 0 && < button type = "button" onClick = { ( ) = > setTab ( "files" ) } > 恢 复 本 地 机 床 库 ( { library . length } 个 版 本 ) < / button > } < / div > }
< p className = "machine-preview-hint" > 单 击 选 择 模 型 · 拖 动 旋 转 · 滚 轮 缩 放 · 右 键 平 移 · 黄 色 框 为 选 中 模 型 < / p > < / section >
< div className = "machine-manager-editor" >
{ parts . length > 0 && < div className = "machine-model-list" data-axis-control = "machine-model-list" aria-label = "已导入 GLB 模型" > { parts . map ( ( part ) = > < div key = { part . id } className = { ` machine-model-row ${ selectedId === part . id ? "selected" : "" } ` } data-machine-model-id = { part . id } > < button type = "button" data-axis-control = "machine-model-select" aria-pressed = { selectedId === part . id } disabled = { pending } onClick = { ( ) = > selectModel ( part . id ) } title = { part . filename } > < strong > { part . filename } < / strong > < span > { part . role ? roleLabel ( part . role , coordinates ) : "未分配角色" } · { part . nodeJoints ? Object . keys ( part . nodeJoints ) . length : 0 } 个 关 节 绑 定 < / span > < / button > < button type = "button" data-axis-control = "machine-model-delete" aria-label = { ` 删除 ${ part . filename } ` } disabled = { pending || followLive } onClick = { ( ) = > remove ( part ) } > 删 除 < / button > < / div > ) } < / div > }
< div className = "machine-editor-tabs" role = "tablist" aria-label = "机床配置步骤" > { ( [ [ "structure" , "结构与关节" ] , [ "motion" , "运动验证" ] , [ "calibration" , "机床标定" ] , [ "files" , "文件与恢复" ] ] as const ) . map ( ( [ id , label ] , index ) = > < button id = { ` machine-tab- ${ id } ` } role = "tab" aria-selected = { tab === id } aria-controls = { ` machine-panel- ${ id } ` } tabIndex = { tab === id ? 0 : - 1 } data-axis-control = { ` machine-manager-tab- ${ id } ` } key = { id } type = "button" onClick = { ( ) = > setTab ( id ) } onKeyDown = { ( event ) = > { const ids : EditorTab [ ] = [ "structure" , "motion" , "calibration" , "files" ] ; const next = event . key === "ArrowRight" ? ids [ ( index + 1 ) % 4 ] : event . key === "ArrowLeft" ? ids [ ( index + 3 ) % 4 ] : event . key === "Home" ? ids [ 0 ] : event . key === "End" ? ids [ 3 ] : null ; if ( next ) { event . preventDefault ( ) ; setTab ( next ) ; document . getElementById ( ` machine-tab- ${ next } ` ) ? . focus ( ) ; } } } > { label } < / button > ) } < / div >
< div id = "machine-panel-structure" className = "machine-structure-panel" role = "tabpanel" aria-labelledby = "machine-tab-structure" hidden = { tab !== "structure" } >
< section className = "machine-part-list" > < header > < h3 > 部 件 与 层 级 < / h3 > < span > { validation . model ? . nodes . length ? ? 0 } 节 点 < / span > < / header > < div className = "machine-tree-tools" > < input data-axis-control = "machine-tree-search" type = "search" aria-label = "搜索节点名称或轴" placeholder = "搜索节点名称 / 数控轴" value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } / > < button type = "button" data-axis-control = "machine-tree-expand" onClick = { ( ) = > setExpansion ( ( current ) = > ( { all : true , serial : current.serial + 1 } ) ) } > 展 开 < / button > < button type = "button" data-axis-control = "machine-tree-collapse" onClick = { ( ) = > { setQuery ( "" ) ; setExpansion ( ( current ) = > ( { all : false , serial : current.serial + 1 } ) ) ; } } > 折 叠 < / button > < / div > { parts . length ? < ul className = "machine-tree" role = "tree" > { parts . filter ( ( part ) = > ! part . parentId ) . map ( renderPartTree ) } < / ul > : < p > 导 入 机 床 或 部 件 后 显 示 GLB 内 部 层 级 。 < / p > } < / section >
{ selected && < section className = "machine-part-editor" > < header > < h3 title = { selected . filename } > { selectedTreeNode ? . name ? ? selected . filename } < small > { selected . filename } < / small > < / h3 > < button type = "button" data-axis-control = "machine-manager-delete" disabled = { pending || followLive } title = "删除整个导入文件,可通过撤销恢复" onClick = { ( ) = > remove ( selected ) } > 删 除 导 入 模 型 < / button > < / header > { selectedTreeNode && < details className = "machine-origin-details" > < summary > 零 姿 态 与 当 前 坐 标 < / summary > < div className = "machine-tree-origin" data-axis-control = "machine-tree-origin" > < strong > { selectedTreeNode . name } < / strong > < span > 节 点 自 身 局 部 原 点 < / span > < output > X 0.000 Y 0.000 Z 0.000 < / output > < span > 装 配 位 置 ( 相 对 父 节 点 , mm ) < / span > < output > X { selectedTransform ! . translationMm [ 0 ] . toFixed ( 3 ) } Y { selectedTransform ! . translationMm [ 1 ] . toFixed ( 3 ) } Z { selectedTransform ! . translationMm [ 2 ] . toFixed ( 3 ) } < / output > < span > 装 配 姿 态 ( 相 对 父 节 点 , deg ) < / span > < output > RX { selectedTransform ! . rotationDeg [ 0 ] . toFixed ( 3 ) } RY { selectedTransform ! . rotationDeg [ 1 ] . toFixed ( 3 ) } RZ { selectedTransform ! . rotationDeg [ 2 ] . toFixed ( 3 ) } < / output > { selectedDriven && < > < span > 当 前 世 界 原 点 ( mm ) < / span > < output > X { ( selectedDriven . world [ 0 ] * 1000 ) . toFixed ( 3 ) } Y { ( selectedDriven . world [ 1 ] * 1000 ) . toFixed ( 3 ) } Z { ( selectedDriven . world [ 2 ] * 1000 ) . toFixed ( 3 ) } < / output > < / > } < / div > < / details > } < label > 唯 一 自 由 度 < select data-axis-control = "machine-tree-axis-role" disabled = { pending || followLive } value = { selectedDof ? . axis ? ? "" } onChange = { ( event ) = > setDof ( event . target . value ? defaultDof ( event . target . value as CncAxis ) : null ) } > < option value = "" > 固 定 ( 无 自 由 度 ) < / option > { [ . . . "XYZABC" ] . map ( ( axis ) = > < option key = { axis } value = { axis } disabled = { ! coordinates . includes ( axis ) } > { axis } · { "XYZ" . includes ( axis ) ? "平移 mm" : "旋转 deg" } < / option > ) } < / select > < / label >
< section className = "machine-part-list" > < header > < h3 > 部 件 与 层 级 < / h3 > < span > { validation . model ? . nodes . length ? ? 0 } 节 点 < / span > < / header > { selectedAnalysis && < section className = "glb-five-axis-analysis" data-axis-control = "glb-five-axis-analysis" > < header > < h3 > GLB 五 轴 结 构 分 析 < / h3 > < button type = "button" data-axis-control = "glb-five-axis-export" onClick = { ( ) = > { const body = JSON . stringify ( selectedAnalysis , null , 2 ) + "\n" ; const url = URL . createObjectURL ( new Blob ( [ body ] , { type : "application/json" } ) ) ; const anchor = document . createElement ( "a" ) ; anchor . href = url ; anchor . download = ` ${ selectedAnalysis . filename ? . replace ( /\.glb$/i , "" ) || "machine" } -five-axis-analysis.json ` ; anchor . click ( ) ; setTimeout ( ( ) = > URL . revokeObjectURL ( url ) , 1000 ) ; } } > 导 出 JSON < / button > < / header > < p className = "glb-analysis-summary" data-axis-control = "glb-five-axis-summary" > { analysisSummary ( selectedAnalysis ) } < / p > < div className = "glb-analysis-grid" > < span > 节 点 / 网 格 < strong > { selectedAnalysis . nodeCount } / { selectedAnalysis . meshCount } < / strong > < / span > < span > 建 议 运 动 学 < strong > { selectedAnalysis . suggestedKinematics } < / strong > < / span > < span > RTCP < strong > { "待确认" } < / strong > < / span > < / div > < div className = "glb-analysis-axes" aria-label = "识别的机床轴" > { CNC_AXES . map ( ( axis ) = > { const item = selectedAnalysis . axes . find ( ( candidate ) = > candidate . axis === axis ) ; return < span key = { axis } data-glb-analysis-axis = { axis } className = { item ? "detected" : "missing" } > < b > { axis } < / b > { item ? < small > { item . nodeName } < br / > { item . worldOriginMm . map ( ( value ) = > value . toFixed ( 1 ) ) . join ( " / " ) } mm < / small > : < small > 未 识 别 < / small > } < / span > ; } ) } < / div > < details > < summary > 结 构 链 与 RTCP 参 数 < / summary > < p > 直 线 轴 链 : { selectedAnalysis . linearChain . join ( " → " ) || "未识别" } < / p > < p > 旋 转 轴 链 : { selectedAnalysis . rotaryChain . join ( " → " ) || "未识别" } < / p > < p > 节 点 父 级 : { selectedAnalysis . axes . map ( ( item ) = > ` JT_ ${ item . axis } ← ${ item . parentName || "scene" } ` ) . join ( "; " ) || "未识别" } < / p > < p > { selectedAnalysis . rtcp . reason } < / p > < small > 建 议 参 数 : { selectedAnalysis . rtcp . requiredParameters . join ( "、" ) } < / small > { selectedAnalysis . warnings . length > 0 && < ul > { selectedAnalysis . warnings . map ( ( warning ) = > < li key = { warning } > { warning } < / li > ) } < / ul > } < / details > < / section > } <div className = "machine-tree-tools" > < input data-axis-control = "machine-tree-search" type = "search" aria-label = "搜索节点名称或轴" placeholder = "搜索节点名称 / 数控轴" value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } / > < button type = "button" data-axis-control = "machine-tree-expand" onClick = { ( ) = > setExpansion ( ( current ) = > ( { all : true , serial : current.serial + 1 } ) ) } > 展 开 < / button > < button type = "button" data-axis-control = "machine-tree-collapse" onClick = { ( ) = > { setQuery ( "" ) ; setExpansion ( ( current ) = > ( { all : false , serial : current.serial + 1 } ) ) ; } } > 折 叠 < / button > < / div > { parts . length ? < ul className = "machine-tree" role = "tree" > { parts . filter ( ( part ) = > ! part . parentId ) . map ( renderPartTree ) } < / ul > : < p > 导 入 机 床 或 部 件 后 显 示 GLB 内 部 层 级 。 < / p > } < / section >
{ selected && < section className = "machine-part-editor" > < header > < h3 title = { selected . filename } > { selectedTreeNode ? . name ? ? selected . filename } < small > { selected . filename } < / small > < / h3 > < button type = "button" data-axis-control = "machine-manager-delete" disabled = { pending || followLive } title = "删除整个导入文件,可通过撤销恢复" onClick = { ( ) = > remove ( selected ) } > 删 除 导 入 模 型 < / button > < / header > { selectedTreeNode && < details className = "machine-origin-details" > < summary > 零 姿 态 与 当 前 坐 标 < / summary > < div className = "machine-tree-origin" data-axis-control = "machine-tree-origin" > < strong > { selectedTreeNode . name } < / strong > < span > 节 点 自 身 局 部 原 点 < / span > < output > X 0.000 Y 0.000 Z 0.000 < / output > < span > 装 配 位 置 ( 相 对 父 节 点 , mm ) < / span > < output > X { selectedTransform ! . translationMm [ 0 ] . toFixed ( 3 ) } Y { selectedTransform ! . translationMm [ 1 ] . toFixed ( 3 ) } Z { selectedTransform ! . translationMm [ 2 ] . toFixed ( 3 ) } < / output > < span > 装 配 姿 态 ( 相 对 父 节 点 , deg ) < / span > < output > RX { selectedTransform ! . rotationDeg [ 0 ] . toFixed ( 3 ) } RY { selectedTransform ! . rotationDeg [ 1 ] . toFixed ( 3 ) } RZ { selectedTransform ! . rotationDeg [ 2 ] . toFixed ( 3 ) } < / output > { selectedDriven && < > < span > 当 前 世 界 原 点 ( mm ) < / span > < output > X { ( selectedDriven . world [ 0 ] * 1000 ) . toFixed ( 3 ) } Y { ( selectedDriven . world [ 1 ] * 1000 ) . toFixed ( 3 ) } Z { ( selectedDriven . world [ 2 ] * 1000 ) . toFixed ( 3 ) } < / output > < / > } < / div > < / details > } < label > 唯 一 自 由 度 < select data-axis-control = "machine-tree-axis-role" disabled = { pending || followLive } value = { selectedDof ? . axis ? ? "" } onChange = { ( event ) = > setDof ( event . target . value ? defaultDof ( event . target . value as CncAxis ) : null ) } > < option value = "" > 固 定 ( 无 自 由 度 ) < / option > { CNC_AXES . map ( ( axis ) = > < option key = { axis } value = { axis } disabled = { ! coordinates . includes ( axis ) } > { axis } · { axisUnit ( axis ) } < / option > ) } < / select > < / label >
{ selectedDof && < > < label > 父 级 运 动 方 向 < select data-axis-control = "machine-joint-direction" disabled = { pending || followLive } value = { selectedDof . direction } onChange = { ( event ) = > setDof ( { . . . selectedDof , direction : event.target.value as "x" | "y" | "z" } ) } > { [ "x" , "y" , "z" ] . map ( ( axis ) = > < option key = { axis } value = { axis } > { axis . toUpperCase ( ) } < / option > ) } < / select > < / label > < label > 正 负 方 向 < select data-axis-control = "machine-joint-sign" disabled = { pending || followLive } value = { selectedDof . sign } onChange = { ( event ) = > setDof ( { . . . selectedDof , sign : Number ( event . target . value ) as 1 | - 1 } ) } > < option value = "1" > 正 向 + 1 < / option > < option value = "-1" > 反 向 − 1 < / option > < / select > < / label > < / > }
< label > 节 点 结 构 角 色 < select data-axis-control = "machine-manager-role" disabled = { pending || followLive } value = { selectedNodePath === "scene" ? selected.role : selected.nodeRoles?. [ selectedNodePath ] ? ? "" } onChange = { ( event ) = > roleChange ( selected , event . target . value as MachinePartRole | "" ) } > < option value = "" > 未 分 配 < / option > { MACHINE_PART_ROLES . map ( ( role ) = > < option key = { role } value = { role } > { roleLabel ( role , coordinates ) } < / option > ) } < / select > < / label > { selectedNodePath === "scene" ? < label > 模 型 装 配 父 级 < select data-axis-control = "machine-manager-parent" disabled = { pending || followLive } value = { selected . parentId ? ? "" } onChange = { ( event ) = > update ( selected . id , { parentId : event.target.value || null } ) } > < option value = "" > 无 < / option > { parts . filter ( ( part ) = > parentAllowed ( selected . id , part . id ) ) . map ( ( part ) = > < option key = { part . id } value = { part . id } > { part . role ? roleLabel ( part . role , coordinates ) : part . filename } < / option > ) } < / select > < / label > : < p className = "machine-parent-note" > 内 部 节 点 父 级 由 层 级 树 拖 放 管 理 < / p > } < div className = "machine-part-flags" > < label > < input type = "checkbox" disabled = { pending || followLive } checked = { selected . visible } onChange = { ( event ) = > update ( selected . id , { visible : event.target.checked } ) } / > 显 示 导 入 模 型 < / label > < label > < input data-axis-control = "machine-manager-show-origin" type = "checkbox" disabled = { pending || followLive } checked = { selected . showOrigin } onChange = { ( event ) = > update ( selected . id , { showOrigin : event.target.checked } ) } / > 显 示 坐 标 原 点 < / label > < / div > < p className = "machine-transform-reference" > 当 前 节 点 : { selectedTreeNode ? . name } 。 { selectedDof ? ` 仅允许 ${ selectedDof . axis } ${ isLinear ( selectedDof . axis ) ? "平移" : "旋转" } ,沿父级 ${ selectedDof . direction . toUpperCase ( ) } 方向;以导入装配姿态为零位。 ` : "固定节点随父级运动;请先配置唯一自由度。" } < / p >
< label > { selectedDof ? ` ${ selectedDof . axis } ${ isLinear ( selectedDof . axis ) ? "位移 (mm)" : "转角 (deg)" } ` : "关节值" } < MachineNumberInput fieldId = { ` joint- ${ selectedId } - ${ selectedNodePath } ` } control = "machine-joint-value" onValidity = { validity } disabled = { pending || ! selectedDof || followLive } value = { selectedDof ? effectivePose [ selectedDof . axis . toLowerCase ( ) as keyof TestPose ] : 0 } onChange = { ( value ) = > { if ( selectedDof ) setTestPose ( ( pose ) = > ( { . . . pose , [ selectedDof . axis . toLowerCase ( ) ] : value } ) ) ; } } / > < / label >
{ selectedDof && < div className = "machine-joint-step" > < label > 步 长 < select aria-label = "关节步长" disabled = { followLive || pending } value = { jogStep } onChange = { ( event ) = > setJogStep ( Number ( event . target . value ) ) } > { [ . 1 , 1 , 10 ] . map ( ( step ) = > < option key = { step } value = { step } > { step } { isLinear ( selectedDof . axis ) ? "mm" : "°" } < / option > ) } < / select > < / label > { ( [ - 1 , 1 ] as const ) . map ( ( direction ) = > < button key = { direction } type = "button" data-axis-control = { direction < 0 ? "machine-joint-step-minus" : "machine-joint-step-plus" } disabled = { followLive || pending || invalidFields . length > 0 } onClick = { ( ) = > setTestPose ( ( pose ) = > { const axis = selectedDof . axis . toLowerCase ( ) as keyof TestPose ; return { . . . pose , [ axis ] : Number ( ( pose [ axis ] + direction * jogStep ) . toFixed ( 6 ) ) } ; } ) } > { direction < 0 ? "− " : "+" } { jogStep } < / button > ) } < button type = "button" data-axis-control = "machine-joint-reset" disabled = { followLive || pending } onClick = { ( ) = > setTestPose ( ( pose ) = > ( { . . . pose , [ selectedDof . axis . toLowerCase ( ) ] : 0 } ) ) } > 本 轴 归 零 < / button > < / div > }
< label > { selectedDof ? ` ${ selectedDof . axis } ${ isLinear ( selectedDof . axis ) ? "位移 (mm)" : "转角 (deg)" } ` : "关节值" } < MachineNumberInput fieldId = { ` joint- ${ selectedId } - ${ selectedNodePath } ` } control = "machine-joint-value" onValidity = { validity } disabled = { pending || ! selectedDof || followLive } value = { selectedDof ? effectivePose [ selectedDof . axis . toLowerCase ( ) ] ? ? 0 : 0} onChange = { ( value ) = > { if ( selectedDof ) setTestPose ( ( pose ) = > ( { . . . pose , [ selectedDof . axis . toLowerCase ( ) ] : value } ) ) ; } } / > < / label >
{ selectedDof && < div className = "machine-joint-step" > < label > 步 长 < select aria-label = "关节步长" disabled = { followLive || pending } value = { jogStep } onChange = { ( event ) = > setJogStep ( Number ( event . target . value ) ) } > { MANAGER_SETTINGS . jogSteps . map ( ( step ) = > < option key = { step } value = { step } > { step } { isLinear ( selectedDof . axis ) ? "mm" : "°" } < / option > ) } < / select > < / label > { ( [ - 1 , 1 ] as const ) . map ( ( direction ) = > < button key = { direction } type = "button" data-axis-control = { direction < 0 ? "machine-joint-step-minus" : "machine-joint-step-plus" } disabled = { followLive || pending || invalidFields . length > 0 } onClick = { ( ) = > setTestPose ( ( pose ) = > { const axis = selectedDof . axis . toLowerCase ( ) ; return { . . . pose , [ axis ] : Number ( ( ( pose [ axis ] ? ? 0 ) + direction * jogStep ) . toFixed ( 6 ) ) } ; } ) } > { direction < 0 ? "− " : "+" } { jogStep } < / button > ) } < button type = "button" data-axis-control = "machine-joint-reset" disabled = { followLive || pending } onClick = { ( ) = > setTestPose ( ( pose ) = > ( { . . . pose , [ selectedDof . axis . toLowerCase ( ) ] : 0 } ) ) } > 本 轴 归 零 < / button > < / div > }
< / section > }
{ ! selected && < p className = "machine-selection-empty" > 选 择 层 级 节 点 , 配 置 其 唯 一 自 由 度 。 < / p > }
< / div >
< section id = "machine-panel-motion" role = "tabpanel" aria-labelledby = "machine-tab-motion" hidden = { tab !== "motion" } className = "machine-motion-test" > < label > < input data-axis-control = "machine-joint-follow-live" type = "checkbox" checked = { followLive } disabled = { pending || ( ! liveReady && ! followLive ) } onChange = { ( event ) = > setFollowLive ( event . target . checked ) } / > 跟 随 数 控 系 统 实 时 关 节 位 置 < / label > < output data-axis-control = "machine-joint-live-state" > { followLive ? liveReady ? ` WASM tick ${ visual . tick } ` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : coordinates !== controllerCoordinates ? ` 设计机型 ${ coordinates } 与控制面板 ${ controllerCoordinates } 不同;应用或导入后可实时跟随 ` : "手动验证(相对物理零点)" } < / output > < header > < h3 > 层 级 运 动 测 试 < / h3 > < button type = "button" data-axis-control = "machine-test-reset" disabled = { pending || followLive } onClick = { ( ) = > setTestPose ( { x : 0 , y : 0 , z : 0 , a : 0 , b : 0 , c : 0 } ) } > 复 位 < / button > < / header > < div > { ( [ "x" , "y" , "z" ] as const ) . map ( ( axis ) = > poseInput ( axis , "mm" ) ) } { poseInput ( coordinates === "XYZBC" ? "b" : "a" , "deg" ) } { poseInput ( "c" , "deg" ) } < / div > < p > 这 些 输 入 仅 验 证 模 型 运 动 , 不 向 数 控 系 统 发 送 运 动 命 令 。 < / p > < details > < summary > 诊 断 数 据 < / summary > < output data-axis-control = "machine-test-state" > { JSON . stringify ( effectivePose ) } < / output > < / details > < / section >
< section id = "machine-panel-calibration" role = "tabpanel" aria-labelledby = "machine-tab-calibration" hidden = { tab !== "calibration" } className = "machine-manager-calibration" > < h3 > 机 床 标 定 < / h3 > { vectorInput ( "C 轴原点" , cOriginMm , setCOriginMm , "mm" ) } { vectorInput ( ` ${ coordinates === "XYZBC" ? "B" : "A " } 到 C 轴偏置` , tiltOffsetMm , setTiltOffsetMm , "mm" ) } < fieldset className = "machine-vector machine-zero" > < legend > 机 床 物 理 零 点 ( XYZ mm / ABC deg ) < / legend > { [ "x" , "y" , "z" , coordinates === "XYZBC" ? "b" : "a" , "c" ] . map ( ( axis ) = > < label key = { axis } > { axis . toUpperCase ( ) }< MachineNumberInput fieldId = { ` zero- ${ axis } ` } onValidity = { validity } disabled = { pending || followLive } value = { physicalZero [ axis as keyof PhysicalZero ] } onChange = { ( value ) = > setPhysicalZero ( { . . . physicalZero , [ axis ] : value } ) } / > < / label > ) } < / fieldset > < / section >
< section id = "machine-panel-motion" role = "tabpanel" aria-labelledby = "machine-tab-motion" hidden = { tab !== "motion" } className = "machine-motion-test" > < label > < input data-axis-control = "machine-joint-follow-live" type = "checkbox" checked = { followLive } disabled = { pending || ( ! liveReady && ! followLive ) } onChange = { ( event ) = > setFollowLive ( event . target . checked ) } / > 跟 随 数 控 系 统 实 时 关 节 位 置 < / label > < output data-axis-control = "machine-joint-live-state" > { followLive ? liveReady ? ` WASM tick ${ visual . tick } ` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : coordinates !== controllerCoordinates ? ` 设计机型 ${ coordinates } 与控制面板 ${ controllerCoordinates } 不同;应用或导入后可实时跟随 ` : "手动验证(相对物理零点)" } < / output > < header > < h3 > 层 级 运 动 测 试 < / h3 > < button type = "button" data-axis-control = "machine-test-reset" disabled = { pending || followLive } onClick = { ( ) = > setTestPose ( zeroPose ( coordinates ) ) } > 复 位 < / button > < / header > < div > { activeAxes . map ( ( axis ) = > poseInput ( axis ) ) } < / div > < p > 这 些 输 入 仅 验 证 模 型 运 动 , 不 向 数 控 系 统 发 送 运 动 命 令 。 < / p > < details > < summary > 诊 断 数 据 < / summary > < output data-axis-control = "machine-test-state" > { JSON . stringify ( effectivePose ) } < / output > < / details > < / section >
< section id = "machine-panel-calibration" role = "tabpanel" aria-labelledby = "machine-tab-calibration" hidden = { tab !== "calibration" } className = "machine-manager-calibration" > < h3 > 机 床 标 定 < / h3 > { vectorInput ( ` ${ profile ? . rotaryAxis ? ? "旋转" } 轴原点` , cOriginMm , setCOriginMm , "mm" ) } { vectorInput ( ` ${ profile ? . tiltAxis ? ? coordinates [ 3 ] ? ? "旋转" } 到 ${ profile ? . rotaryAxis ? ? coordinates [ 4 ] ? ? "旋转 " } 轴偏置 ` , tiltOffsetMm , setTiltOffsetMm , "mm" ) } < fieldset className = "machine-vector machine-zero" > < legend > 机 床 物 理 零 点 ( 按 当 前 坐 标 , 直 线 mm / 旋 转 deg ) < / legend > { activeAxes . map ( ( axis ) = > { const key = axis . toLowerCase ( ) ; return < label key = { axis } > { axis } < MachineNumberInput fieldId = { ` zero- ${ key } ` } onValidity = { validity } disabled = { pending || followLive } value = { physicalZero [ key ] ? ? 0 } onChange = { ( value ) = > setPhysicalZero ( { . . . physicalZero , [ key ] : value } ) } / > < / label > ; } )} < / fieldset > < / section >
< section id = "machine-panel-files" role = "tabpanel" aria-labelledby = "machine-tab-files" hidden = { tab !== "files" } className = "machine-document-panel" >
< h3 > 机 床 文 档 < / h3 > < label > 模 型 名 称 < input data-axis-control = "machine-document-name" maxLength = { 80 } value = { documentInfo . name } disabled = { pending || followLive } placeholder = { ` ${ coordinates } 机床 ` } onChange = { ( event ) = > { const name = event . target . value ; edit ( ( draft ) = > ( { . . . draft , documentInfo : { . . . documentInfo , name } } ) ) ; } } / > < / label >
< label > 设 计 说 明 < textarea data-axis-control = "machine-document-description" maxLength = { 2000 } value = { documentInfo . description } disabled = { pending || followLive } placeholder = "用途、标定说明、版本变更…" onChange = { ( event ) = > { const description = event . target . value ; edit ( ( draft ) = > ( { . . . draft , documentInfo : { . . . documentInfo , description } } ) ) ; } } / > < / label >
< section className = "machine-case-catalog" data-axis-control = "machine-case-catalog" > < header > < h3 > LinuxCNC 五 轴 案 例 库 < / h3 > < span > { FIVE_AXIS_CASES . length } 个 结 构 / 运 动 学 案 例 < / span > < / header > < p > 案 例 来 源 于 仓 库 内 LinuxCNC 配 置 、 示 例 G 代 码 和 运 动 学 源 码 。 选 中 XYZAC / XYZBC 可 直 接 进 入 当 前 Web / WASM 工 作 流 ; 其 它 类 型 保 留 完 整 参 数 和 源 码 索 引 , 待 对 应 运 动 学 适 配 器 接 入 后 启 用 。 < / p > < ul > { FIVE_AXIS_CASES . map ( ( item ) = > < li key = { item . id } data-machine-case = { item . id } className = { item . coordinates === coordinates ? "active" : "" } > < div > < strong > { item . label } < / strong > < span > { item . kinematics } · { item . coordinates } · { item . joints } 关 节 < / span > < small > { item . description } < / small > < / div > < em className = { ` machine-case-status machine-case-status- ${ item . webWasm } ` } > { FIVE_AXIS_STATUS_LABELS [ item . webWasm ] } < / em > < details > < summary > 查 看 本 地 案 例 与 参 数 < / summary > < code > { item . localCases . join ( "\n" ) } < / code > < small > 运 动 学 源 码 : { item . kinematicsSource } < br / > 结 构 参 数 : { item . designParameters . join ( "、" ) } < / small > < / details > < / li > ) } < / ul > < small > 官 方 参 考 : < a href = "https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html" target = "_blank" rel = "noreferrer" > 5 - Axis Kinematics < / a > · < a href = "https://linuxcnc.org/docs/html/man/man9/kins.9.html" target = "_blank" rel = "noreferrer" > kins ( 9 ) < / a > · < a href = "https://linuxcnc.org/docs/html/motion/switchkins.html" target = "_blank" rel = "noreferrer" > switchkins < / a > < / small > < / section >
< section className = "machine-case-catalog" data-axis-control = "machine-case-catalog" > < header > < h3 > LinuxCNC 五 轴 案 例 库 < / h3 > < span > { FIVE_AXIS_CASES . length } 个 结 构 / 运 动 学 案 例 < / span > < / header > < p > 案 例 来 源 于 仓 库 内 LinuxCNC 配 置 、 示 例 G 代 码 和 运 动 学 源 码 。 支 持 的 坐 标 可 直 接 进 入 当 前 Web / WASM 工 作 流 ; 其 它 类 型 保 留 完 整 参 数 和 源 码 索 引 , 待 对 应 运 动 学 适 配 器 接 入 后 启 用 。 < / p > < ul > { FIVE_AXIS_CASES . map ( ( item ) = > < li key = { item . id } data-machine-case = { item . id } className = { item . coordinates === coordinates ? "active" : "" } > < div > < strong > { item . label } < / strong > < span > { item . kinematics } · { item . coordinates } · { item . joints } 关 节 < / span > < small > { item . description } < / small > < / div > < em className = { ` machine-case-status machine-case-status- ${ item . webWasm } ` } > { FIVE_AXIS_STATUS_LABELS [ item . webWasm ] } < / em > < details > < summary > 查 看 本 地 案 例 与 参 数 < / summary > < code > { item . localCases . join ( "\n" ) } < / code > < small > 运 动 学 源 码 : { item . kinematicsSource } < br / > 结 构 参 数 : { item . designParameters . join ( "、" ) } < / small > < / details > < / li > ) } < / ul > < small > 官 方 参 考 : < a href = "https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html" target = "_blank" rel = "noreferrer" > 5 - Axis Kinematics < / a > · < a href = "https://linuxcnc.org/docs/html/man/man9/kins.9.html" target = "_blank" rel = "noreferrer" > kins ( 9 ) < / a > · < a href = "https://linuxcnc.org/docs/html/motion/switchkins.html" target = "_blank" rel = "noreferrer" > switchkins < / a > < / small > < / section >
< div className = "machine-save-location" data-axis-control = "machine-document-location" > < strong > 文 件 保 存 在 哪 里 ? < / strong > < p > { saveLocation } < / p > < p > GLB 下 载 到 浏 览 器 设 置 的 目 录 , 通 常 为 “ 下 载 ” 。 请 打 开 浏 览 器 下 载 记 录 查 看 实 际 位 置 ; 网 页 无 法 读 取 磁 盘 完 整 路 径 。 < / p > < p > 保 存 时 另 存 一 份 到 本 浏 览 器 机 床 库 , 可 在 刷 新 页 面 后 重 新 打 开 。 本 地 机 床 库 仅 属 于 当 前 浏 览 器 及 网 站 地 址 , 不 会 上 传 云 服 务 器 ; 清 理 网 站 数 据 会 删 除 这 些 副 本 。 < / p > < / div >
< h3 > 本 浏 览 器 机 床 库 < small > { library . length } 个 保 存 版 本 < / small > < / h3 > < div className = "machine-library-tools" > < input data-axis-control = "machine-library-search" aria-label = "搜索已保存机床" placeholder = "搜索名称 / 机型" value = { libraryQuery } onChange = { ( event ) = > setLibraryQuery ( event . target . value ) } / > < button type = "button" onClick = { ( ) = > void refreshLibrary ( ) } > 刷 新 < / button > < / div >
{ libraryError && < p role = "alert" > { libraryError } < / p > }
@@ -401,7 +512,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
< / section >
< / div >
< / div >
< div className = { ` machine-validation ${ blockingReason ? "needs-attention" : "ready" } ` } data-axis-control = "machine-manager-validation" role = "status" > { blockingReason ? ` 配置检查: ${ blockingReason } ` : " 配置检查通过 · 五 轴绑定完整" } { disabled && ! blockingReason && " · 关机且程序空闲后可应用" } { followLive && " · 实时跟随中,配置编辑已锁定" } { invalidFields . length > 0 && < button type = "button" onClick = { ( ) = > { const id = invalidFields [ 0 ] ; setTab ( id . startsWith ( "joint-" ) ? "structure" : id . startsWith ( "test-" ) ? "motion" : "calibration" ) ; requestAnimationFrame ( ( ) = > document . querySelector < HTMLInputElement > ( '.machine-manager-dialog input[aria-invalid="true"]' ) ? . focus ( ) ) ; } } > 定 位 无 效 输 入 < / button > } < / div >
< div className = { ` machine-validation ${ blockingReason ? "needs-attention" : "ready" } ` } data-axis-control = "machine-manager-validation" role = "status" > { blockingReason ? ` 配置检查: ${ blockingReason } ` : ` 配置检查通过 · ${ activeAxes . length } 轴绑定完整` } { disabled && ! blockingReason && " · 关机且程序空闲后可应用" } { followLive && " · 实时跟随中,配置编辑已锁定" } { invalidFields . length > 0 && < button type = "button" onClick = { ( ) = > { const id = invalidFields [ 0 ] ; setTab ( id . startsWith ( "joint-" ) ? "structure" : id . startsWith ( "test-" ) ? "motion" : "calibration" ) ; requestAnimationFrame ( ( ) = > document . querySelector < HTMLInputElement > ( '.machine-manager-dialog input[aria-invalid="true"]' ) ? . focus ( ) ) ; } } > 定 位 无 效 输 入 < / button > } < / div >
< footer className = "machine-manager-footer" > < button type = "button" data-axis-control = "machine-manager-apply" disabled = { disabled || pending || followLive || Boolean ( blockingReason ) } title = { disabled ? "关机且程序空闲后可应用" : undefined } onClick = { ( ) = > void apply ( ) } > 应 用 到 Web LinuxCNC < / button > < button type = "button" data-axis-control = "machine-manager-export" disabled = { pending || followLive || Boolean ( blockingReason ) } onClick = { ( ) = > void exportGlb ( ) } > 保 存 机 床 模 型 GLB < / button > < button type = "button" data-axis-control = "machine-manager-export-json" disabled = { ! validation . model || pending || invalidFields . length > 0 } onClick = { exportJson } > 导 出 关 节 JSON < / button > < output role = "status" aria-live = "polite" > { message || "关闭对话框保留本次草稿;保存机床模型 GLB 后,可在控制面板“导入机床模型”中使用。" } < / output > { message && < button type = "button" aria-label = "清除操作消息" onClick = { ( ) = > setMessage ( "" ) } > × < / button > } < / footer >
< / div >
< input ref = { openFile } data-axis-control = "machine-document-open-file" hidden type = "file" accept = ".glb,model/gltf-binary" onChange = { ( event ) = > { const item = event . target . files ? . [ 0 ] ; event . target . value = "" ; if ( item ) void importFiles ( [ item ] , true ) ; } } / >