Configure single-DOF machine joints and drive them from WASM axes
This commit is contained in:
@@ -4,10 +4,8 @@
|
|||||||
"mode": "production-preview",
|
"mode": "production-preview",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"checks": [
|
"checks": [
|
||||||
"eight GLB parts rendered with studio PMREM lighting and calibrated exposure",
|
"eight GLB parts rendered with studio PMREM lighting",
|
||||||
"dragging GLB root tree nodes updates component parent hierarchy",
|
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
|
||||||
"origin visibility, numeric translation/rotation and TransformControls mode update",
|
|
||||||
"internal mesh parent-relative position and rotation survive GLB export without moving its parent",
|
|
||||||
"validated assembly exports a self-contained managed GLB",
|
"validated assembly exports a self-contained managed GLB",
|
||||||
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
||||||
"same component assembly switches to BC hierarchy and applies BC geometry",
|
"same component assembly switches to BC hierarchy and applies BC geometry",
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
"mode": "development-source",
|
"mode": "development-source",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"checks": [
|
"checks": [
|
||||||
"eight GLB parts rendered with studio PMREM lighting and calibrated exposure",
|
"eight GLB parts rendered with studio PMREM lighting",
|
||||||
"dragging GLB root tree nodes updates component parent hierarchy",
|
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
|
||||||
"origin visibility, numeric translation/rotation and TransformControls mode update",
|
|
||||||
"internal mesh parent-relative position and rotation survive GLB export without moving its parent",
|
|
||||||
"validated assembly exports a self-contained managed GLB",
|
"validated assembly exports a self-contained managed GLB",
|
||||||
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
||||||
"same component assembly switches to BC hierarchy and applies BC geometry",
|
"same component assembly switches to BC hierarchy and applies BC geometry",
|
||||||
|
|||||||
2816
coverage/glb-machine-manager/joint-dof/browser.report.json
Normal file
2816
coverage/glb-machine-manager/joint-dof/browser.report.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{"schema":"axis-control-inventory.v1","status":"verified","documented_controls":115,"duplicate_command_entries_removed":["machine_menu","view_menu","program_pane_load","tool_probe_commands"],"contract":"linuxcnc-axis-web-control-panel-source-logic.md"}
|
||||||
1
coverage/glb-machine-manager/joint-dof/math.report.json
Normal file
1
coverage/glb-machine-manager/joint-dof/math.report.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"report_id":"GLB-MGR-E13-MATH","status":"verified","checks":["parent-frame motion with nonidentity zero orientation","unit/sign/physical-zero conversion","100 updates without drift","child inheritance","rotation about own origin","malformed JSON rejected","missing live axis rejected atomically"]}
|
||||||
BIN
coverage/glb-machine-manager/joint-dof/xyzac-live.png
Normal file
BIN
coverage/glb-machine-manager/joint-dof/xyzac-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
4108
coverage/glb-machine-manager/joint-dof/xyzac.json
Normal file
4108
coverage/glb-machine-manager/joint-dof/xyzac.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
coverage/glb-machine-manager/joint-dof/xyzbc-live.png
Normal file
BIN
coverage/glb-machine-manager/joint-dof/xyzbc-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
4108
coverage/glb-machine-manager/joint-dof/xyzbc.json
Normal file
4108
coverage/glb-machine-manager/joint-dof/xyzbc.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 91 KiB |
@@ -8,12 +8,12 @@
|
|||||||
|
|
||||||
1. 对话框包含独立的 Three.js 三维预览,实时显示正在配置的机床。
|
1. 对话框包含独立的 Three.js 三维预览,实时显示正在配置的机床。
|
||||||
2. 支持逐个导入自包含 `.glb` 机床部件,不允许依赖外部纹理或网格文件。
|
2. 支持逐个导入自包含 `.glb` 机床部件,不允许依赖外部纹理或网格文件。
|
||||||
3. 每个部件可绑定 `底座、X、Y、Z、刀具、A/B、C、工件` 角色,并编辑父级关系。可运行结构必须满足:
|
3. 每个部件可绑定 `底座、X、Y、Z、刀具、A/B、C、工件` 结构角色,并编辑父级关系。MGR-17 以独立的 XYZABC 自由度绑定驱动视觉层级,允许单 GLB 多关节。数控求解器仍按 TRT 的控制参考链运行:
|
||||||
- 刀具链:底座 → X → Y → Z → 刀具。
|
- 刀具链:底座 → X → Y → Z → 刀具。
|
||||||
- 转台链:底座 → A/B → C → 工件。
|
- 转台链:底座 → A/B → C → 工件。
|
||||||
4. 每个部件可定义局部坐标原点。模型网格相对该原点偏移,运动关节围绕该原点运动。
|
4. 每个部件可定义局部坐标原点。模型网格相对该原点偏移,运动关节围绕该原点运动。
|
||||||
5. 每个部件可独立显示或隐藏原点坐标轴辅助线。
|
5. 每个部件可独立显示或隐藏原点坐标轴辅助线。
|
||||||
6. 每个部件可用数值和 Three.js 操纵器平移、旋转;变换只修改装配配置,不修改原 GLB 字节。Three.js 管理器通过 `camera.up.set(0, 0, 1)` 使用机床 Z-up 场景,机床 XY 为地面平面;导入 GLB 的节点、几何、材质、内部层级和原始变换保持不变。
|
6. 每个运动节点只允许通过绑定的唯一自由度修改位移或转角,固定节点锁定;旧六分量自由编辑和操纵器由单值关节输入替代。变换只修改预览配置,不修改原 GLB 字节。Three.js 管理器通过 `camera.up.set(0, 0, 1)` 使用机床 Z-up 场景,机床 XY 为地面平面;导入 GLB 的节点、几何、材质、内部层级和原始变换保持不变。
|
||||||
7. 可定义 C 轴原点位置和 A/B 到 C 的运动学偏置。应用时写入底座场景节点及整机场景的 `userData.linuxcncMachineGeometry`,同时生成 URDF:
|
7. 可定义 C 轴原点位置和 A/B 到 C 的运动学偏置。应用时写入底座场景节点及整机场景的 `userData.linuxcncMachineGeometry`,同时生成 URDF:
|
||||||
- `C 原点 P = (x-rot-point, y-rot-point, z-rot-point)`。
|
- `C 原点 P = (x-rot-point, y-rot-point, z-rot-point)`。
|
||||||
- `A/B 偏置 D = (x-offset, y-offset, z-offset)`。
|
- `A/B 偏置 D = (x-offset, y-offset, z-offset)`。
|
||||||
@@ -50,3 +50,5 @@
|
|||||||
- 非法层级、重复角色、缺失部件、错误机型和外部资源 GLB 明确拒绝,已生效模型不改变。
|
- 非法层级、重复角色、缺失部件、错误机型和外部资源 GLB 明确拒绝,已生效模型不改变。
|
||||||
|
|
||||||
15. 树中每个节点独立编辑相对父级的位置(mm)和 XYZ 欧拉姿态(deg)。数值输入回显节点当前局部值,旋转以自身原点为中心;子节点随动,父级和兄弟节点不变。源 GLB 保留,节点编辑保存在草稿并进入组合导出。
|
15. 树中每个节点独立编辑相对父级的位置(mm)和 XYZ 欧拉姿态(deg)。数值输入回显节点当前局部值,旋转以自身原点为中心;子节点随动,父级和兄弟节点不变。源 GLB 保留,节点编辑保存在草稿并进入组合导出。
|
||||||
|
|
||||||
|
16. MGR-17:每个节点可配置一个 XYZABC 自由度、父坐标轴方向和正负向。配置以 `linuxcnc.joint-model/1` JSON 写入原 userData,包含层级、零姿态、轴索引、物理零点和 RTCP 标定。管理器实时跟随与正式机床共用驱动逻辑;详见 [07-单自由度JSON与实时驱动](07-单自由度JSON与实时驱动.md)。
|
||||||
|
|||||||
@@ -15,3 +15,5 @@
|
|||||||
13. `MGR-16`:将根节点部件变换和内部节点 `nodeTransforms[path]` 分开保存;编辑当前节点相对父级的位置和姿态,验证子节点继承、父兄节点不受影响、拖放换父级后保持世界姿态,并验证组合 GLB 与 AC/BC 应用结果。
|
13. `MGR-16`:将根节点部件变换和内部节点 `nodeTransforms[path]` 分开保存;编辑当前节点相对父级的位置和姿态,验证子节点继承、父兄节点不受影响、拖放换父级后保持世界姿态,并验证组合 GLB 与 AC/BC 应用结果。
|
||||||
|
|
||||||
每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。
|
每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。
|
||||||
|
|
||||||
|
14. `MGR-17`:定义单自由度 JSON/Schema;收敛 UI 输入;增加单文件层级应用和 GLB 恢复;接入实际 WASM 关节位置;验证每轴、反向、物理零点、导出往返和锁定行为。
|
||||||
|
|||||||
@@ -82,3 +82,13 @@
|
|||||||
- MILL700 选择 `MILL700-A-主轴-JT3`,输入 X=120 mm、RZ=25° 后,节点局部位置和四元数分别变为 `0.120 m` 和 `25°`;8 部件 AC/BC 生产回归、导出和应用继续通过。
|
- MILL700 选择 `MILL700-A-主轴-JT3`,输入 X=120 mm、RZ=25° 后,节点局部位置和四元数分别变为 `0.120 m` 和 `25°`;8 部件 AC/BC 生产回归、导出和应用继续通过。
|
||||||
- MILL700 补充断言:JT3 局部 Z 减少 44 mm 时 JT2 与兄弟滑轨不动、JT4 同步移动;RZ=25° 时 JT3 原点不动、JT4 绕该原点运动;父链预先旋转 20°,位移仍按父坐标系计算。切换选择和重开对话框保留输入;带编辑姿态的门节点换父级后世界姿态不变且能继续编辑。
|
- MILL700 补充断言:JT3 局部 Z 减少 44 mm 时 JT2 与兄弟滑轨不动、JT4 同步移动;RZ=25° 时 JT3 原点不动、JT4 绕该原点运动;父链预先旋转 20°,位移仍按父坐标系计算。切换选择和重开对话框保留输入;带编辑姿态的门节点换父级后世界姿态不变且能继续编辑。
|
||||||
- MGR-16 verified;验收证据 E12。
|
- MGR-16 verified;验收证据 E12。
|
||||||
|
|
||||||
|
## R11 / 2026-09-21 / 单自由度关节与控制器映射
|
||||||
|
|
||||||
|
- 每个节点固定或只绑定一个 XYZABC 轴;父级 x/y/z 方向和 ±1 可配,平移/旋转类型及单位由数控轴确定。旧自由位置/姿态编辑改成单值关节输入。
|
||||||
|
- 新增 `machine-joints.ts`:版本化节点与控制器映射、严格校验、零姿态驱动;预览与运行共用。
|
||||||
|
- `makeJointAssembly` 保留已有模型层级,支持 MILL700 单文件配置完整五轴后应用;新配置不再要求八个独立 GLB。
|
||||||
|
- 实际床身节点、控制底座和场景写入关节 JSON,兼容原 URDF 与机床几何 userData。导出显式 Scene extras;GLB 重导入恢复轴绑定、方向、物理零点和标定。
|
||||||
|
- App 快照接入对话框实时跟随;运行模型通过实际 WASM 关节名映射驱动,并发布同 tick 的轴输入/模型变换诊断。
|
||||||
|
- 大型关节模型运行关闭 MSAA 和实时阴影,减轻软件 WebGL 绘制开销。
|
||||||
|
- 数学/JSON 校验、八部件 AC/BC 回归与完整 MILL700 AC/BC 实时/导出往返验证均通过;每机型 8 个不同 tick,五个轴均变化,浏览器 warning/error=0。MGR-17 verified;证据 E13。
|
||||||
|
|||||||
@@ -18,3 +18,5 @@
|
|||||||
|MGR-14 层级运动验证|verified|MGR-12,MGR-13|模型删除;树节点轴绑定;XYZ mm、A/B/C deg 输入驱动局部层级;local/world 变换和 MILL700 父子联动验收|
|
|MGR-14 层级运动验证|verified|MGR-12,MGR-13|模型删除;树节点轴绑定;XYZ mm、A/B/C deg 输入驱动局部层级;local/world 变换和 MILL700 父子联动验收|
|
||||||
|MGR-15 关节局部坐标系|verified|MGR-12,MGR-14|选中树节点时局部 XYZ 辅助轴挂在节点 `(0,0,0)`,随关节和父链变换,不重建 WebGL|
|
|MGR-15 关节局部坐标系|verified|MGR-12,MGR-14|选中树节点时局部 XYZ 辅助轴挂在节点 `(0,0,0)`,随关节和父链变换,不重建 WebGL|
|
||||||
|MGR-16 部件节点相对父级变换|verified|MGR-05,MGR-14|编辑树中当前节点的“部件平移/部件旋转”后,该节点相对父级的位置和姿态改变;父节点与兄弟节点不被误改;子节点继承;预览、导出和 AC/BC 应用保持一致|
|
|MGR-16 部件节点相对父级变换|verified|MGR-05,MGR-14|编辑树中当前节点的“部件平移/部件旋转”后,该节点相对父级的位置和姿态改变;父节点与兄弟节点不被误改;子节点继承;预览、导出和 AC/BC 应用保持一致|
|
||||||
|
|
||||||
|
|MGR-17 单自由度与运行轴映射|verified|MGR-16|每节点固定或唯一 XYZABC 自由度;单值编辑;JSON 保存到场景/底座;单 GLB 完整应用;导出往返与 AC/BC 实时轴映射验收|
|
||||||
|
|||||||
@@ -103,3 +103,26 @@
|
|||||||
- JT3 的父、兄弟、子节点编辑前后位置与四元数记录于 MILL700 报告的 `nodeEditing`;验证重选、重开对话框、编辑后换父级和再次输入。
|
- JT3 的父、兄弟、子节点编辑前后位置与四元数记录于 MILL700 报告的 `nodeEditing`;验证重选、重开对话框、编辑后换父级和再次输入。
|
||||||
|
|
||||||
- 线上 E12:`https://49.232.250.35:58015/` 已发布代码 `91ebd56`,资源 `assets/index-CJLtuazD.js`。以线上 URL 重跑 MILL700(含父/兄弟节点隔离、子节点继承、换父级和重开对话框),全部通过、浏览器错误 0。发布报告:`coverage/glb-machine-manager/parent-transform-deployment.report.json`(`GLB-MGR-E12-CLOUD`)。
|
- 线上 E12:`https://49.232.250.35:58015/` 已发布代码 `91ebd56`,资源 `assets/index-CJLtuazD.js`。以线上 URL 重跑 MILL700(含父/兄弟节点隔离、子节点继承、换父级和重开对话框),全部通过、浏览器错误 0。发布报告:`coverage/glb-machine-manager/parent-transform-deployment.report.json`(`GLB-MGR-E12-CLOUD`)。
|
||||||
|
|
||||||
|
## E13 / R11 / 单自由度、JSON 与实时轴映射
|
||||||
|
|
||||||
|
- 数学与错误边界:`node scripts/test_machine_joint_model.mjs`;`GLB-MGR-E13-MATH`,报告 `coverage/glb-machine-manager/joint-dof/math.report.json`。验证父级旋转、非单位零姿态旋转、正负号、mm/deg、物理零点、100 次更新无累积、错误 JSON、缺失轴值整帧拒绝。
|
||||||
|
- 完整浏览器命令:`MACHINE_MANAGER_URL=http://127.0.0.1:5180/ node scripts/test_machine_joint_dof_browser.mjs`;`GLB-MGR-E13`,报告 `coverage/glb-machine-manager/joint-dof/browser.report.json`。
|
||||||
|
- AC/BC 分别导入 MILL700,五个关节独立绑定;Z 轴改方向并反向,固定节点锁定,无六分量输入和自由操纵器;重复轴拒绝,子节点继承、父兄节点保持不变。
|
||||||
|
- 导出 JSON 与 GLB 场景 extras、实际床身底座 extras 内容一致;GLB 重新导入管理器恢复方向和物理零点,标准“导入机床模型”也可载入;单 GLB 可应用到控制面板。
|
||||||
|
- 同时五轴程序:`M428; G21 G90 G94 G17 G40 G49; G1 X20 Y10 Z10 A/B20 C30 F120; G1 X-10 Y-5 Z-5 A/B-10 C-20 F120; M2`。每种机型采样 8 个不同渲染 tick,逐轴校验实际 WASM 关节值减物理零点、方向符号及父级局部运动。
|
||||||
|
- 示例:`coverage/glb-machine-manager/joint-dof/xyzac.json`、`xyzbc.json`;截图 `xyzac-live.png`、`xyzbc-live.png`;Schema 用 Python `jsonschema` 对两份实例验证。
|
||||||
|
- 八个独立部件回归:`scripts/test_machine_manager_browser.mjs` 已更新单自由度 UI;生产和源模式均通过,旧 URDF 驱动兼容性检查保留。
|
||||||
|
- 控件清单 115 项;双画布、XYZAC Three.js 契约、TypeScript 和生产构建通过。
|
||||||
|
|
||||||
|
- E13 最终状态:verified。XYZAC/XYZBC 各 8 个不同渲染 tick,五个轴均发生变化;浏览器 warning/error=0。此前 E01–E12 为历史验证记录,本次现行输入契约以 E13 为准。
|
||||||
|
|
||||||
|
|E13 文件|SHA-256|
|
||||||
|
|---|---|
|
||||||
|
|`joint-dof/browser.report.json`|`7b92b906fcdce8f30a9d22e0a8f9ac116b2fd3ebe3b5eef60aa770d134d7f6b7`|
|
||||||
|
|`joint-dof/math.report.json`|`ea994774e16ea5b506d028c8f7bcd3ff19fe8d3e2514aaa8447b870738fb07f4`|
|
||||||
|
|`joint-dof/controls.report.json`|`03a734e9a9b09610818a010dea90eb9ba196b01dfbd3c9e4d21501c8baa0f6c8`|
|
||||||
|
|`joint-dof/xyzac.json`|`99fa305279f05a331916138274c1bc1586ca4d4bdc93a1e34848fee160fe3fda`|
|
||||||
|
|`joint-dof/xyzbc.json`|`5e59370657eff67156f4a6338897e35e73c0b3bd3f955345a2698b05b88ea522`|
|
||||||
|
|`joint-dof/xyzac-live.png`|`6e4574082a11703c2b537e452aad039293100bb22a6420c2b7551d02ffef44fe`|
|
||||||
|
|`joint-dof/xyzbc-live.png`|`51fcd171ac07ad67ef6c1d733c098f24e1938474e1de2c1322701eb33ed49647`|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## D03 / 编辑层级自由,应用层级受求解器限制
|
## D03 / 编辑层级自由,应用层级受求解器限制
|
||||||
|
|
||||||
UI 允许用户选择父级以建立层级和发现错误;应用时只接受底座→X→Y→Z→刀具及底座→A/B→C→工件。当前 WASM 只支持这两类 TRT,不能把任意树伪装成可执行机床。
|
UI 允许用户选择父级以建立层级和发现错误;应用时只接受底座→X→Y→Z→刀具及底座→A/B→C→工件。当前 WASM 只支持这两类 TRT,不能把任意树伪装成可执行机床。MGR-17 起允许任意视觉层级按单自由度绑定五轴,数控仍使用所选 AC/BC TRT;八个独立文件不再是应用前提。
|
||||||
|
|
||||||
## D04 / 原 GLB 不原地改写
|
## D04 / 原 GLB 不原地改写
|
||||||
|
|
||||||
@@ -55,3 +55,7 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复
|
|||||||
## D14 / 部件编辑变换作用于当前树节点的父级局部空间
|
## D14 / 部件编辑变换作用于当前树节点的父级局部空间
|
||||||
|
|
||||||
“部件平移”和“部件旋转”编辑当前选中的树节点。根节点的值保存为部件级 `translationMm`、`rotationDeg`;内部节点的值按稳定源路径保存为 `nodeTransforms[path]`,直接表示该节点相对当前父节点的位置和姿态。节点自身局部原点是旋转中心,子节点自然继承父节点变换,父节点和兄弟节点不因编辑子节点而改变。拖放改变父级后,先保持世界姿态,再重新计算局部值。导出和应用复用同一套局部变换,保证预览、组合 GLB 和运行机床一致。
|
“部件平移”和“部件旋转”编辑当前选中的树节点。根节点的值保存为部件级 `translationMm`、`rotationDeg`;内部节点的值按稳定源路径保存为 `nodeTransforms[path]`,直接表示该节点相对当前父节点的位置和姿态。节点自身局部原点是旋转中心,子节点自然继承父节点变换,父节点和兄弟节点不因编辑子节点而改变。拖放改变父级后,先保持世界姿态,再重新计算局部值。导出和应用复用同一套局部变换,保证预览、组合 GLB 和运行机床一致。
|
||||||
|
|
||||||
|
## D15 / 单自由度约束与 JSON 运动契约(替代 D14 的自由编辑)
|
||||||
|
|
||||||
|
每个运动节点只能绑定一个 CNC 轴,每轴只绑定一个节点。固定装配节点无自由度。输入为相对物理零点的单个关节量;保留导入零姿态,禁止通过其他数值或自由操纵器绕过约束。节点身份使用稳定 ID,实际绘制以父级方向和正负号驱动。以 `linuxcnc.joint-model/1` 扩展既有 userData,不更换数控 RTCP 求解器;URDF 坐标框架与视觉装配分离以避免双重运动。临时测试姿态不保存为零姿态。
|
||||||
|
|||||||
68
docs/glb-machine-manager/07-单自由度JSON与实时驱动.md
Normal file
68
docs/glb-machine-manager/07-单自由度JSON与实时驱动.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# 单自由度 JSON 与实时驱动
|
||||||
|
|
||||||
|
配置字段为 `userData.linuxcncJointModel`,版本 `linuxcnc.joint-model/1`。它与原 `linuxcncMachine`(URDF)和 `linuxcncMachineGeometry`(TRT 标定)一起保存在导出 GLB 的场景、运行底座及被标记为“床身底座”的实际模型节点中。GLB 将 userData 序列化为 extras。源 GLB 文件不覆盖。
|
||||||
|
|
||||||
|
## 对话框
|
||||||
|
|
||||||
|
1. 选中层级节点,设置“唯一自由度”:X/Y/Z 是平移,A/B/C 是旋转;固定零件选择“固定(无自由度)”。每个运动节点只有一个自由度,每个数控轴只能绑定一个节点。
|
||||||
|
2. 设置父级坐标系中的 X/Y/Z 运动方向和正负向。AC 模型可用 XYZAC,BC 模型可用 XYZBC;另一倾转轴禁用。
|
||||||
|
3. 当前节点只有一个“位移/转角”可编辑数值,单位 mm/deg。固定节点不能编辑;已导入的局部位置、姿态作为零姿态保留。旧版六分量自由编辑和自由操纵器已被单自由度输入替代(MGR-17)。
|
||||||
|
4. 层级运动测试与节点输入共用对应轴值,所有输入均为相对物理零点的运动量,不写数控系统。配置 JSON 和 GLB 保存零姿态、自由度和标定,不将临时测试姿态烘焙进去。
|
||||||
|
5. “跟随数控系统实时关节位置”使用当前机型的有效 WASM 快照,锁定手动关节输入。控制面板正式模型始终由 WASM 实际关节位置驱动。
|
||||||
|
6. 绑定齐五个轴后可应用机床、导出 GLB;单个已有层级的 GLB 也可应用。可单独导出 JSON。未完成绑定的 JSON 是草稿,控制器读取时必须通过完整性校验。
|
||||||
|
7. 重新导入本工具导出的 GLB 到管理器,可恢复层级、自由度、正负向、C 原点、倾转轴偏置和物理零点。GLB 机型必须与当前工作区相同。
|
||||||
|
|
||||||
|
## 字段契约
|
||||||
|
|
||||||
|
正式 JSON Schema:[joint-model.schema.json](joint-model.schema.json)。AC/BC 完整实例由验收程序生成,见 `coverage/glb-machine-manager/joint-dof/xyzac.json` 和 `xyzbc.json`。
|
||||||
|
|
||||||
|
| 字段 | 意义 |
|
||||||
|
|---|---|
|
||||||
|
| `schema` | 固定版本 `linuxcnc.joint-model/1` |
|
||||||
|
| `coordinates` | `XYZAC` 或 `XYZBC` |
|
||||||
|
| `units` | 设计长度 mm、角度 deg;GLB 节点实际长度 m |
|
||||||
|
| `controller.source` | `linuxcnc-wasm.visual.joints`,取实际运动学关节值 |
|
||||||
|
| `controller.axisOrder` | 实际快照数组的轴顺序,AC 为 `[X,Y,Z,A,C]`,BC 为 `[X,Y,Z,B,C]` |
|
||||||
|
| `calibration.cOriginMm` | C 轴原点,沿用 TRT rot-point 参数 |
|
||||||
|
| `calibration.tiltOffsetMm` | A/B 到 C 偏置,沿用 TRT offset 参数 |
|
||||||
|
| `calibration.physicalZero` | 每个数控轴对应模型零姿态的实际读数 |
|
||||||
|
| `axisMap` | 数控轴名到模型节点 ID,避免按名称或树排序推测关节 |
|
||||||
|
| `nodes[].id / parentId` | 唯一节点 ID 与直接父级;根为 null |
|
||||||
|
| `nodes[].source` | 源文件名、SHA-256 和导入路径,便于追溯 |
|
||||||
|
| `nodes[].zero` | 相对父级的零姿态:位置 mm、四元数 `[x,y,z,w]`、比例 |
|
||||||
|
| `nodes[].joint` | null 表示固定;对象表示唯一自由度 |
|
||||||
|
| `joint.axis / type / unit` | 数控轴 / prismatic 或 revolute / mm 或 deg |
|
||||||
|
| `joint.direction / frame` | 父级坐标系中的 x/y/z;frame 固定 parent |
|
||||||
|
| `joint.sign` | +1 或 −1,控制模型运动与数控轴正向的关系 |
|
||||||
|
| `joint.dof` | 固定为 1 |
|
||||||
|
|
||||||
|
Z 轴关节的配置片段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "part-id:scene/0/2/1",
|
||||||
|
"parentId": "part-id:scene/0/2",
|
||||||
|
"name": "主轴 Z 滑台",
|
||||||
|
"role": "z",
|
||||||
|
"source": { "file": "MILL700-U_1.glb", "hash": "源文件SHA256", "path": "scene/0/2/1" },
|
||||||
|
"zero": { "positionMm": [-20.804, -904.049, 368.925], "quaternion": [0,0,0,1], "scale": [1,1,1] },
|
||||||
|
"joint": { "axis": "Z", "direction": "z", "sign": -1, "dof": 1, "type": "prismatic", "unit": "mm", "frame": "parent" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 驱动规则
|
||||||
|
|
||||||
|
控制器先根据 `controller.axisOrder` 把快照数组转换为轴名 → 实际位置,然后通过 `axisMap` 找到运动节点。GLB 节点上的 `userData.linuxcncNodeId` 对应 `nodes[].id`,不依赖可重名的中文模型名称。
|
||||||
|
|
||||||
|
设实际位置 q、物理零点 q0、正负方向 s,则位移或转角 d = s × (q − q0)。设 e 为 `joint.direction` 对应的父级单位向量:
|
||||||
|
|
||||||
|
- 平移:`p = zero.positionMm / 1000 + e * d / 1000`,四元数仍为零姿态。
|
||||||
|
- 旋转:`Q = Quaternion(e, d * PI / 180) * zero.quaternion`,位置仍为零姿态,因此绕自身原点旋转。
|
||||||
|
- 子节点继承父级世界矩阵;父级与兄弟节点不会因本节点运动改变。
|
||||||
|
- 每次从零姿态求绝对变换,不进行增量累加,连续运行不产生累积漂移。
|
||||||
|
|
||||||
|
预览和运行复用 `machine-joints.ts` 的同一驱动函数。JSON 用于视觉关节映射;当前数控求解器仍为原生 AC/BC TRT,不根据任意视觉层级生成新的逆运动学。实际配置需要符合所选机床的机械结构、轴正向和标定;轴行程、速度和回零仍以控制器 INI 为准。
|
||||||
|
|
||||||
|
运行场景中的 URDF 控制坐标框架继续承担既有刀尖、刀长和刀路投影。导入模型的视觉装配位于固定底座下,仅由 JSON 关节绑定驱动,避免同一节点同时受到 URDF 和 JSON 两次运动。
|
||||||
|
|
||||||
|
读取时还要校验:节点 ID 唯一、父级有效且无环、每个节点最多一个自由度、轴绑定不重复、机型完整、轴索引一致、零姿态与标定为有限数值、四元数归一化。缺少实时轴值时整帧拒绝更新,避免只更新部分轴。
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
|
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
|
||||||
|
|
||||||
当前状态:MGR-01 至 MGR-16 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R10,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12` 位于 `coverage/glb-machine-manager/`。
|
当前状态:MGR-17 单自由度/JSON/实时映射已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R11,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13` 位于 `coverage/glb-machine-manager/`。
|
||||||
|
|
||||||
文档:
|
文档:
|
||||||
|
|
||||||
@@ -23,3 +23,6 @@
|
|||||||
- [06-决策记录](06-决策记录.md)
|
- [06-决策记录](06-决策记录.md)
|
||||||
|
|
||||||
相关旧专项:[GLB / URDF 五轴机床子项目](../glb-machine/README.md)。旧专项已完成整机导入、AC/BC 识别、几何参数同步和实际关节驱动,本专项不重复这些底层能力。
|
相关旧专项:[GLB / URDF 五轴机床子项目](../glb-machine/README.md)。旧专项已完成整机导入、AC/BC 识别、几何参数同步和实际关节驱动,本专项不重复这些底层能力。
|
||||||
|
|
||||||
|
- [07-单自由度JSON与实时驱动](07-单自由度JSON与实时驱动.md)
|
||||||
|
- [关节 JSON Schema](joint-model.schema.json)
|
||||||
|
|||||||
475
docs/glb-machine-manager/joint-model.schema.json
Normal file
475
docs/glb-machine-manager/joint-model.schema.json
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:linuxcnc:joint-model:1",
|
||||||
|
"title": "Web LinuxCNC single-DOF joint model",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema",
|
||||||
|
"coordinates",
|
||||||
|
"units",
|
||||||
|
"controller",
|
||||||
|
"calibration",
|
||||||
|
"axisMap",
|
||||||
|
"nodes"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema": {
|
||||||
|
"const": "linuxcnc.joint-model/1"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"enum": [
|
||||||
|
"XYZAC",
|
||||||
|
"XYZBC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"const": {
|
||||||
|
"length": "mm",
|
||||||
|
"angle": "deg",
|
||||||
|
"gltfLength": "m"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"controller": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"axisOrder"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"source": {
|
||||||
|
"const": "linuxcnc-wasm.visual.joints"
|
||||||
|
},
|
||||||
|
"axisOrder": {
|
||||||
|
"enum": [
|
||||||
|
[
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"A",
|
||||||
|
"C"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"B",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"cOriginMm",
|
||||||
|
"tiltOffsetMm",
|
||||||
|
"physicalZero"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"cOriginMm": {
|
||||||
|
"$ref": "#/$defs/vector3"
|
||||||
|
},
|
||||||
|
"tiltOffsetMm": {
|
||||||
|
"$ref": "#/$defs/vector3"
|
||||||
|
},
|
||||||
|
"physicalZero": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"c": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"axisMap": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"X": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"Y": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"Z": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"A": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"B": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"C": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 12000,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/node"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"vector3": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"minItems": 3,
|
||||||
|
"maxItems": 3
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"parentId",
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
"source",
|
||||||
|
"zero",
|
||||||
|
"joint"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"parentId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"file",
|
||||||
|
"hash",
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zero": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"positionMm",
|
||||||
|
"quaternion",
|
||||||
|
"scale"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"positionMm": {
|
||||||
|
"$ref": "#/$defs/vector3"
|
||||||
|
},
|
||||||
|
"quaternion": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
"minItems": 4,
|
||||||
|
"maxItems": 4
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0
|
||||||
|
},
|
||||||
|
"minItems": 3,
|
||||||
|
"maxItems": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"joint": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/dof"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dof": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"axis",
|
||||||
|
"direction",
|
||||||
|
"sign",
|
||||||
|
"dof",
|
||||||
|
"type",
|
||||||
|
"unit",
|
||||||
|
"frame"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"axis": {
|
||||||
|
"enum": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"enum": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sign": {
|
||||||
|
"enum": [
|
||||||
|
-1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dof": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"frame": {
|
||||||
|
"const": "parent"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"prismatic",
|
||||||
|
"revolute"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"enum": [
|
||||||
|
"mm",
|
||||||
|
"deg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"axis": {
|
||||||
|
"enum": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "prismatic"
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"const": "mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"else": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "revolute"
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"const": "deg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"coordinates": {
|
||||||
|
"const": "XYZAC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"controller": {
|
||||||
|
"properties": {
|
||||||
|
"axisOrder": {
|
||||||
|
"const": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"A",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"axisMap": {
|
||||||
|
"propertyNames": {
|
||||||
|
"enum": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"A",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"items": {
|
||||||
|
"properties": {
|
||||||
|
"joint": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"axis": {
|
||||||
|
"enum": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"A",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"coordinates": {
|
||||||
|
"const": "XYZBC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"controller": {
|
||||||
|
"properties": {
|
||||||
|
"axisOrder": {
|
||||||
|
"const": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"B",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"axisMap": {
|
||||||
|
"propertyNames": {
|
||||||
|
"enum": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"B",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"items": {
|
||||||
|
"properties": {
|
||||||
|
"joint": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"axis": {
|
||||||
|
"enum": [
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"B",
|
||||||
|
"C"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1071,3 +1071,7 @@ The document deliberately records ABI blockers and source boundaries. A
|
|||||||
`verified` Web slice means the implemented path matches this contract; it does
|
`verified` Web slice means the implemented path matches this contract; it does
|
||||||
not claim that an unavailable LinuxCNC host or official native oracle has been
|
not claim that an unavailable LinuxCNC host or official native oracle has been
|
||||||
silently reproduced in the browser.
|
silently reproduced in the browser.
|
||||||
|
|
||||||
|
### MGR-17 single-DOF joint controls
|
||||||
|
|
||||||
|
`machine-joint-direction`, `machine-joint-sign`, `machine-joint-value`, `machine-joint-follow-live`, `machine-joint-live-state`, `machine-manager-export-json`: one CNC axis per moving node, one scalar input in mm/deg, parent-frame direction/sign, real WASM joint following and JSON export. `machine-tree-axis-role` now binds XYZABC directly; fixed nodes cannot move. Unrestricted translate/rotate gizmos and six-component inputs are retired. Runtime uses `linuxcnc.joint-model/1` with source order XYZAC/XYZBC and physical-zero subtraction.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([
|
|||||||
const controlIds = [
|
const controlIds = [
|
||||||
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
|
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
|
||||||
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
|
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
|
||||||
"workspace-case-select", "workspace-load-3d-chips", "workspace-rtcp-programs", "rtcp-program-scope", "rtcp-program-select", "rtcp-program-source", "rtcp-program-load", "rtcp-program-download", "machine-manager-settings", "machine-manager-import", "machine-manager-files", "machine-manager-delete", "machine-manager-translate", "machine-manager-rotate", "machine-manager-origin", "machine-manager-translation", "machine-manager-rotation", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-tree-axis-role", "machine-test-x", "machine-test-y", "machine-test-z", "machine-test-a", "machine-test-b", "machine-test-c", "machine-test-reset", "machine-test-state", "machine-manager-apply", "machine-manager-export", "manual-home-all", "manual-home-joint-select",
|
"workspace-case-select", "workspace-load-3d-chips", "workspace-rtcp-programs", "rtcp-program-scope", "rtcp-program-select", "rtcp-program-source", "rtcp-program-load", "rtcp-program-download", "machine-manager-settings", "machine-manager-import", "machine-manager-files", "machine-manager-delete", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-tree-axis-role", "machine-joint-direction", "machine-joint-sign", "machine-joint-value", "machine-joint-follow-live", "machine-joint-live-state", "machine-manager-export-json", "machine-test-x", "machine-test-y", "machine-test-z", "machine-test-a", "machine-test-b", "machine-test-c", "machine-test-reset", "machine-test-state", "machine-manager-apply", "machine-manager-export", "manual-home-all", "manual-home-joint-select",
|
||||||
"switchkins-0", "switchkins-1", "switchkins-2",
|
"switchkins-0", "switchkins-1", "switchkins-2",
|
||||||
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
|
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
|
||||||
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",
|
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",
|
||||||
|
|||||||
106
scripts/test_machine_joint_dof_browser.mjs
Normal file
106
scripts/test_machine_joint_dof_browser.mjs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
||||||
|
const pw = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? "/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js"));
|
||||||
|
const browser = await (pw.default ?? pw).chromium.launch({ headless: true, executablePath: "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||||
|
const output = new URL("../coverage/glb-machine-manager/joint-dof/", import.meta.url); await mkdir(output, { recursive: true });
|
||||||
|
const bytes = await readFile(new URL("../MILL700-U_1.glb", import.meta.url));
|
||||||
|
const report = { report_id: "GLB-MGR-E13", status: "running", url: process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5180/", machines: [], browser_errors: [] };
|
||||||
|
const near = (a, b, label) => { assert.equal(a.length, b.length, label); a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 2e-6, `${label} ${i}: ${v} vs ${b[i]}`)); };
|
||||||
|
try {
|
||||||
|
for (const coordinates of (process.env.JOINT_TEST_COORDINATES ?? "XYZAC,XYZBC").split(",")) {
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||||
|
page.on("pageerror", (e) => report.browser_errors.push(e.message));
|
||||||
|
page.on("console", (m) => { if (["error", "warning"].includes(m.type())) report.browser_errors.push(m.text()); });
|
||||||
|
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||||
|
const preview = page.locator("[data-machine-manager-preview]");
|
||||||
|
const idle = () => page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 60000 });
|
||||||
|
await page.goto(report.url); await idle();
|
||||||
|
if (coordinates === "XYZBC") { await c("workspace-case-select").selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); await idle(); }
|
||||||
|
await c("machine-manager-settings").click(); await c("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: bytes });
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
|
||||||
|
assert.equal(await c("machine-joint-value").isDisabled(), true);
|
||||||
|
assert.equal(await c("machine-manager-apply").isDisabled(), true);
|
||||||
|
assert.equal(await c("machine-manager-translation").count(), 0); assert.equal(await c("machine-manager-rotation").count(), 0);
|
||||||
|
const choose = async (name) => {
|
||||||
|
const node = c("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^${name}$`) }) }).first();
|
||||||
|
const path = await node.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path"); await node.dispatchEvent("click");
|
||||||
|
await page.waitForFunction((path) => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerSelectedJoint?.endsWith(`:${path}`), path);
|
||||||
|
return JSON.parse(await preview.getAttribute("data-machine-manager-selected-transform"));
|
||||||
|
};
|
||||||
|
const names = { X: "MILL700-A-主轴-JT1", Y: "MILL700-A-主轴-JT2", Z: "MILL700-A-主轴-JT3", [coordinates[3]]: "MILL700-A-工作台-JT1", C: "MILL700-A-工作台-JT2" };
|
||||||
|
for (const [axis, name] of Object.entries(names)) { await choose(name); await c("machine-tree-axis-role").selectOption(axis); }
|
||||||
|
await choose("MILL700-A底座"); await c("machine-manager-role").selectOption("base");
|
||||||
|
assert.equal(await c("machine-manager-apply").isDisabled(), false);
|
||||||
|
const parent = await choose(names.Y), sibling = await choose("SHS25C100020_Track_TSXXX-0000058522002"), child = await choose("MILL700-A-主轴-JT4"), zeroZ = await choose(names.Z);
|
||||||
|
await c("machine-joint-direction").selectOption("x"); await c("machine-joint-sign").selectOption("-1"); await c("machine-joint-value").fill("30");
|
||||||
|
const movedZ = await choose(names.Z); near(movedZ.local, [zeroZ.local[0] - .03, ...zeroZ.local.slice(1)], "single parent X translation"); near(movedZ.localQuaternion, zeroZ.localQuaternion, "locked rotation");
|
||||||
|
assert.deepEqual(await choose(names.Y), parent); assert.deepEqual(await choose("SHS25C100020_Track_TSXXX-0000058522002"), sibling);
|
||||||
|
const movedChild = await choose("MILL700-A-主轴-JT4"); near(movedChild.world, [child.world[0] - .03, ...child.world.slice(1)], "child follows only configured DOF");
|
||||||
|
await choose(names.Z); await c("machine-joint-value").fill("30"); near((await choose(names.Z)).local, movedZ.local, "absolute, no accumulated movement");
|
||||||
|
await c("machine-joint-value").fill("0"); await c("machine-joint-direction").selectOption("z");
|
||||||
|
await choose("MILL700-A-主轴-JT4"); await c("machine-tree-axis-role").selectOption("Z"); assert.match(await page.locator(".machine-manager-footer").textContent(), /重复|已绑定/); assert.equal(await c("machine-tree-axis-role").inputValue(), "");
|
||||||
|
const rotaryZero = await choose(names[coordinates[3]]); await c("machine-joint-value").fill("25"); const rotaryMoved = await choose(names[coordinates[3]]); near(rotaryMoved.local, rotaryZero.local, "rotation pivot fixed");
|
||||||
|
const axis = coordinates === "XYZAC" ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
|
||||||
|
near(rotaryMoved.localQuaternion, new THREE.Quaternion().setFromAxisAngle(axis, 25 * Math.PI / 180).multiply(new THREE.Quaternion(...rotaryZero.localQuaternion)).toArray(), "one rotational DOF");
|
||||||
|
await c("machine-test-reset").click();
|
||||||
|
const zeroFields = page.locator("fieldset.machine-zero"); await zeroFields.getByLabel("X", { exact: true }).fill("5"); await zeroFields.getByLabel("Z", { exact: true }).fill("7"); await zeroFields.getByLabel(coordinates[3], { exact: true }).fill("12"); await zeroFields.getByLabel("C", { exact: true }).fill("9");
|
||||||
|
const jsonDownload = page.waitForEvent("download"); await c("machine-manager-export-json").click(); const json = JSON.parse(await readFile(await (await jsonDownload).path(), "utf8"));
|
||||||
|
assert.equal(json.schema, "linuxcnc.joint-model/1"); assert.equal(json.coordinates, coordinates); assert.deepEqual(Object.keys(json.axisMap).sort(), [...coordinates].sort()); assert.equal(json.nodes.filter((n) => n.joint).length, 5); assert.ok(json.nodes.every((n) => n.joint === null || n.joint.dof === 1));
|
||||||
|
assert.equal(json.nodes.find((n) => n.id === json.axisMap.Z).joint.sign, -1);
|
||||||
|
const glbDownload = page.waitForEvent("download"); await c("machine-manager-export").click(); const glb = await readFile(await (await glbDownload).path());
|
||||||
|
const gltf = JSON.parse(glb.toString("utf8", 20, 20 + glb.readUInt32LE(12)));
|
||||||
|
assert.deepEqual(gltf.scenes[0].extras.linuxcncJointModel, json);
|
||||||
|
const bed = gltf.nodes.find((n) => n.name === "MILL700-A底座"); assert.deepEqual(bed.extras.linuxcncJointModel, json); assert.ok(bed.extras.linuxcncMachineGeometry);
|
||||||
|
await writeFile(new URL(`${coordinates.toLowerCase()}.json`, output), JSON.stringify(json, null, 2) + "\n");
|
||||||
|
await c("machine-manager-delete").click(); await c("machine-manager-files").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
|
||||||
|
await choose(names.Z); assert.equal(await c("machine-tree-axis-role").inputValue(), "Z"); assert.equal(await c("machine-joint-sign").inputValue(), "-1"); assert.equal(await zeroFields.getByLabel("Z", { exact: true }).inputValue(), "7");
|
||||||
|
console.log(`${coordinates}: GLB export and editor roundtrip verified`);
|
||||||
|
// Standard machine-import route validates exported GLB, JSON hierarchy and zero restoration.
|
||||||
|
await page.keyboard.press("Escape"); await c("machine-glb-settings").click(); await c("machine-glb-file").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
|
||||||
|
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineJointState, null, { timeout: 60000 }); await idle(); await page.keyboard.press("Escape");
|
||||||
|
console.log(`${coordinates}: standard GLB importer verified`);
|
||||||
|
// Apply directly from the restored one-file hierarchy as well.
|
||||||
|
await c("machine-manager-settings").click(); await c("machine-manager-apply").click(); await idle(); assert.match(await page.locator(".machine-manager-footer").textContent(), /已应用/); await page.keyboard.press("Escape");
|
||||||
|
const program = `M428
|
||||||
|
G21 G90 G94 G17 G40 G49
|
||||||
|
G1 X20 Y10 Z10 ${coordinates[3]}20 C30 F120
|
||||||
|
G1 X-10 Y-5 Z-5 ${coordinates[3]}-10 C-20 F120
|
||||||
|
M2
|
||||||
|
`;
|
||||||
|
await c("program-file-input").setInputFiles({ name: `joint-map-${coordinates.toLowerCase()}.ngc`, mimeType: "text/plain", buffer: Buffer.from(program) }); await idle();
|
||||||
|
console.log(`${coordinates}: apply verified; starting simultaneous five-axis WASM motion`);
|
||||||
|
await c("toolbar-power").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisStateMachine === "ON"); await c("manual-home-all").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisHomed === "[true,true,true,true,true]", null, { timeout: 60000 }); await c("toolbar-run").click();
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const view = document.querySelector(".xyzac-machine-view");
|
||||||
|
const q = JSON.parse(view?.dataset.machineJointInputs ?? "[]");
|
||||||
|
return Number(document.querySelector("main.axis-app")?.dataset.axisCurrentLine) > 0 && q.some((v) => Math.abs(v) > 1e-6);
|
||||||
|
}, null, { timeout: 60000 });
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (samples.length) await page.waitForFunction((tick) => Number(document.querySelector(".xyzac-machine-view")?.dataset.machineJointTick) > tick, samples.at(-1).tick, { timeout: 60000 });
|
||||||
|
const sample = await page.locator(".xyzac-machine-view").evaluate((el) => ({ tick: Number(el.dataset.machineJointTick), q: JSON.parse(el.dataset.machineJointInputs), nodes: JSON.parse(el.dataset.machineJointState) }));
|
||||||
|
for (const [index, axisName] of [...coordinates].entries()) {
|
||||||
|
const state = sample.nodes[axisName]; const original = json.nodes.find((n) => n.joint?.axis === axisName); const d = original.joint;
|
||||||
|
const q = d.sign * (sample.q[index] - json.calibration.physicalZero[axisName.toLowerCase()]); const direction = new THREE.Vector3(...["x", "y", "z"].map((a) => a === d.direction ? 1 : 0));
|
||||||
|
if ("XYZ".includes(axisName)) { near(state.localMm, new THREE.Vector3(...original.zero.positionMm).addScaledVector(direction, q).toArray(), `${axisName} runtime position`); near(state.quaternion, original.zero.quaternion, `${axisName} locked orientation`); }
|
||||||
|
else { near(state.localMm, original.zero.positionMm, `${axisName} fixed pivot`); near(state.quaternion, new THREE.Quaternion().setFromAxisAngle(direction, q * Math.PI / 180).multiply(new THREE.Quaternion(...original.zero.quaternion)).toArray(), `${axisName} runtime rotation`); }
|
||||||
|
}
|
||||||
|
samples.push(sample);
|
||||||
|
}
|
||||||
|
for (let axisIndex = 0; axisIndex < 5; axisIndex++) assert.ok(samples.some((s) => Math.abs(s.q[axisIndex] - samples[0].q[axisIndex]) > 1e-6), `${coordinates[axisIndex]} live movement required`);
|
||||||
|
await c("machine-manager-settings").click(); await c("machine-joint-follow-live").check(); assert.equal(await c("machine-joint-value").isDisabled(), true);
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-joint-live-state"]')?.textContent.includes("WASM tick"));
|
||||||
|
const liveSample = await page.evaluate(() => ({ q: JSON.parse(document.querySelector('[data-axis-control="machine-test-state"]').textContent), joints: JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms) }));
|
||||||
|
const z = json.nodes.find((n) => n.joint?.axis === "Z"); near(liveSample.joints.Z.localMm, [z.zero.positionMm[0], z.zero.positionMm[1], z.zero.positionMm[2] - liveSample.q.z], "dialog follows live Z sign");
|
||||||
|
await page.screenshot({ path: new URL(`${coordinates.toLowerCase()}-live.png`, output).pathname });
|
||||||
|
report.machines.push({ coordinates, checks: ["fixed or single DOF", "direction/sign and parent isolation", "pivot and child inheritance", "duplicate axis rejected", "JSON on scene and real bed", "GLB roundtrip restores calibration and bindings", "single-file apply", "WASM runtime axis mapping", "live dialog follows actual joints"], nodeCount: json.nodes.length, samples, liveSample });
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
assert.deepEqual(report.browser_errors, []); report.status = "verified";
|
||||||
|
} catch (e) { report.status = "failed"; report.error = e.stack; for (const page of browser.contexts().flatMap((c) => c.pages())) { report.pageText = await page.locator("body").innerText().catch(() => ""); await page.screenshot({ path: new URL("failure.png", output).pathname }).catch(() => {}); } throw e; }
|
||||||
|
finally { await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }
|
||||||
|
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ coordinates: m.coordinates, checks: m.checks, samples: m.samples.length })), browser_errors: report.browser_errors }));
|
||||||
27
scripts/test_machine_joint_model.mjs
Normal file
27
scripts/test_machine_joint_model.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
||||||
|
import { driveJoint, jointZero, validateJointModel, bindJointModel, defaultDof } from "../web/react/src/machine-joints.ts";
|
||||||
|
const near = (a, b) => a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 1e-9));
|
||||||
|
const parent = new THREE.Group(); parent.rotation.z = Math.PI / 2;
|
||||||
|
const joint = new THREE.Group(); joint.position.set(.1, .2, .3); joint.rotation.y = Math.PI / 6; parent.add(joint);
|
||||||
|
const child = new THREE.Group(); child.position.set(.4, 0, 0); joint.add(child);
|
||||||
|
const zero = jointZero(joint), childLocal = child.position.toArray();
|
||||||
|
const dof = { axis: "X", direction: "x", sign: -1 };
|
||||||
|
for (let i = 0; i < 100; i++) driveJoint(joint, zero, dof, 30, 10);
|
||||||
|
near(joint.position.toArray(), [.08, .2, .3]); near(joint.quaternion.toArray(), zero.quaternion);
|
||||||
|
parent.updateMatrixWorld(true); near(joint.getWorldPosition(new THREE.Vector3()).toArray(), [-.2, .08, .3]); near(child.position.toArray(), childLocal);
|
||||||
|
driveJoint(joint, zero, { axis: "A", direction: "z", sign: 1 }, 100, 10);
|
||||||
|
near(joint.position.toArray(), zero.positionMm.map((v) => v / 1000));
|
||||||
|
near(joint.quaternion.toArray(), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2).multiply(new THREE.Quaternion(...zero.quaternion)).toArray());
|
||||||
|
assert.throws(() => driveJoint(joint, zero, dof, NaN), /无效/);
|
||||||
|
const scene = new THREE.Group(); const nodes = [..."XYZAC"].map((axis) => {
|
||||||
|
const n = new THREE.Group(); n.name = axis; n.userData.linuxcncNodeId = axis; scene.add(n);
|
||||||
|
const config = defaultDof(axis); return { id: axis, name: axis, parentId: null, role: "", source: { file: "test", hash: "hash", path: axis }, zero: jointZero(n), joint: { ...config, dof: 1, type: "XYZ".includes(axis) ? "prismatic" : "revolute", unit: "XYZ".includes(axis) ? "mm" : "deg", frame: "parent" } };
|
||||||
|
});
|
||||||
|
const model = { schema: "linuxcnc.joint-model/1", coordinates: "XYZAC", units: { length: "mm", angle: "deg", gltfLength: "m" }, controller: { source: "linuxcnc-wasm.visual.joints", axisOrder: [..."XYZAC"] }, calibration: { cOriginMm: [0,0,0], tiltOffsetMm: [0,0,0], physicalZero: { x: 2, y: 0, z: 0, a: 5, b: 0, c: 0 } }, axisMap: Object.fromEntries(nodes.map((n) => [n.id, n.id])), nodes };
|
||||||
|
validateJointModel(model); const driver = bindJointModel(scene, model); driver.update({ X: 12, Y: 0, Z: 0, A: 35, C: 0 });
|
||||||
|
near(driver.state().X.localMm, [10,0,0]); near(driver.state().A.quaternion, [Math.sin(Math.PI / 12),0,0,Math.cos(Math.PI / 12)]);
|
||||||
|
const beforeInvalid = driver.state(); assert.throws(() => driver.update({ X: 999, Y: 0, Z: 0, A: 0 }), /缺少/); assert.deepEqual(driver.state(), beforeInvalid);
|
||||||
|
const reject = (mutate) => { const copy = structuredClone(model); mutate(copy); assert.throws(() => validateJointModel(copy)); };
|
||||||
|
reject((m) => { m.nodes[1].joint.axis = "X"; }); reject((m) => { m.nodes[0].joint.dof = 2; }); reject((m) => { m.nodes[0].joint.sign = 0; }); reject((m) => { m.axisMap.X = "Y"; }); reject((m) => { m.nodes[0].parentId = "X"; }); reject((m) => { m.calibration.physicalZero.z = NaN; }); reject((m) => { m.units.length = "m"; }); reject((m) => { m.nodes[0].zero.quaternion = [0,0,0,0]; });
|
||||||
|
console.log(JSON.stringify({ report_id: "GLB-MGR-E13-MATH", status: "verified", checks: ["parent-frame motion with nonidentity zero orientation", "unit/sign/physical-zero conversion", "100 updates without drift", "child inheritance", "rotation about own origin", "malformed JSON rejected", "missing live axis rejected atomically"] }));
|
||||||
@@ -31,29 +31,17 @@ try {
|
|||||||
await control("machine-manager-files").setInputFiles(fixtures);
|
await control("machine-manager-files").setInputFiles(fixtures);
|
||||||
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
|
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
|
||||||
assert.equal(await page.locator('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1.45"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
|
assert.equal(await page.locator('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1.45"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
|
||||||
report.checks.push("eight GLB parts rendered with studio PMREM lighting and calibrated exposure");
|
report.checks.push("eight GLB parts rendered with studio PMREM lighting");
|
||||||
const roots = page.locator('[data-machine-tree-path="scene"]'); const sourceRoot = roots.nth(7), targetRoot = roots.nth(6), sourcePart = await sourceRoot.getAttribute("data-machine-tree-part"), targetPart = await targetRoot.getAttribute("data-machine-tree-part"), transfer = await page.evaluateHandle(() => new DataTransfer()); await sourceRoot.locator("button").first().dispatchEvent("dragstart", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("dragover", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("drop", { dataTransfer: transfer }); await page.waitForFunction(({ sourcePart, targetPart }) => document.querySelector(`[data-machine-tree-part="${sourcePart}"][data-machine-tree-path="scene"]`)?.getAttribute("data-machine-tree-parent") === targetPart, { sourcePart, targetPart });
|
const selectRoot = async (index) => { const node = control("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^part_${index}$`) }) }).first(); await node.dispatchEvent("click"); };
|
||||||
report.checks.push("dragging GLB root tree nodes updates component parent hierarchy");
|
for (const [index, axis] of [[1, "X"], [2, "Y"], [3, "Z"], [5, "A"], [6, "C"]]) { await selectRoot(index); await control("machine-tree-axis-role").selectOption(axis); }
|
||||||
|
await selectRoot(0); assert.equal(await control("machine-joint-value").isDisabled(), true);
|
||||||
await control("machine-manager-show-origin").uncheck(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "7");
|
await control("machine-manager-show-origin").uncheck();
|
||||||
const beforePartOrigin = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-origin")); const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-origin") ?? "[]"); return current.length === 3 && Math.abs(current[0] - before[0] - .01) < 1e-7; }, { before: beforePartOrigin });
|
await selectRoot(1); await control("machine-joint-value").fill("10");
|
||||||
const beforePartQuaternion = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-quaternion")); const rotation = page.locator("fieldset.machine-vector").filter({ hasText: "部件旋转" }); await rotation.getByLabel("Z", { exact: true }).fill("15"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-quaternion") ?? "[]"); return current.length === 4 && Math.abs(current[3] - before[3]) > 1e-4; }, { before: beforePartQuaternion });
|
await page.waitForFunction(() => Math.abs(JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms).X.localMm[0] - 10) < 1e-7);
|
||||||
await control("machine-manager-rotate").click(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-transform-mode"), "rotate");
|
assert.equal(await control("machine-manager-translation").count(), 0);
|
||||||
assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
|
await control("machine-test-reset").click();
|
||||||
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname });
|
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname });
|
||||||
report.checks.push("origin visibility, numeric translation/rotation and TransformControls mode update");
|
report.checks.push("eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X");
|
||||||
|
|
||||||
// Editing an imported mesh must preserve the enclosing GLB placement.
|
|
||||||
const meshNode = control("machine-tree-node").filter({ hasText: "mesh_0" }).first();
|
|
||||||
const meshPath = await meshNode.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path");
|
|
||||||
await meshNode.dispatchEvent("click");
|
|
||||||
await control("machine-manager-translation").getByLabel("X", { exact: true }).fill("35");
|
|
||||||
await control("machine-manager-rotation").getByLabel("Z", { exact: true }).fill("30");
|
|
||||||
const baseRoot = page.locator('[data-machine-tree-path="scene"]').first();
|
|
||||||
await baseRoot.locator('[data-axis-control="machine-tree-node"]').first().dispatchEvent("click");
|
|
||||||
assert.equal(await translation.getByLabel("X", { exact: true }).inputValue(), "10");
|
|
||||||
assert.equal(await rotation.getByLabel("Z", { exact: true }).inputValue(), "15");
|
|
||||||
|
|
||||||
const calibrations = page.locator(".machine-manager-calibration fieldset.machine-vector");
|
const calibrations = page.locator(".machine-manager-calibration fieldset.machine-vector");
|
||||||
const cOrigin = calibrations.filter({ hasText: "C 轴原点" }); await cOrigin.getByLabel("X", { exact: true }).fill("24"); await cOrigin.getByLabel("Y", { exact: true }).fill("-12"); await cOrigin.getByLabel("Z", { exact: true }).fill("8");
|
const cOrigin = calibrations.filter({ hasText: "C 轴原点" }); await cOrigin.getByLabel("X", { exact: true }).fill("24"); await cOrigin.getByLabel("Y", { exact: true }).fill("-12"); await cOrigin.getByLabel("Z", { exact: true }).fill("8");
|
||||||
const offset = calibrations.filter({ hasText: "A 到 C 轴偏置" }); await offset.getByLabel("X", { exact: true }).fill("0"); await offset.getByLabel("Y", { exact: true }).fill("18"); await offset.getByLabel("Z", { exact: true }).fill("-45");
|
const offset = calibrations.filter({ hasText: "A 到 C 轴偏置" }); await offset.getByLabel("X", { exact: true }).fill("0"); await offset.getByLabel("Y", { exact: true }).fill("18"); await offset.getByLabel("Z", { exact: true }).fill("-45");
|
||||||
@@ -61,16 +49,14 @@ try {
|
|||||||
assert.equal(await control("machine-manager-export").isDisabled(), false); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb");
|
assert.equal(await control("machine-manager-export").isDisabled(), false); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb");
|
||||||
const exportedBytes = await readFile(await download.path());
|
const exportedBytes = await readFile(await download.path());
|
||||||
const exported = JSON.parse(exportedBytes.toString("utf8", 20, 20 + exportedBytes.readUInt32LE(12)));
|
const exported = JSON.parse(exportedBytes.toString("utf8", 20, 20 + exportedBytes.readUInt32LE(12)));
|
||||||
const exportedMesh = exported.nodes.find((node) => node.name === "mesh_0");
|
const model = exported.scenes[0].extras.linuxcncJointModel;
|
||||||
assert.ok(Math.abs(exportedMesh.translation[0] - .035) < 1e-7);
|
assert.equal(model.schema, "linuxcnc.joint-model/1");
|
||||||
assert.ok(Math.abs(exportedMesh.rotation[2] - Math.sin(Math.PI / 12)) < 1e-7);
|
assert.equal(model.nodes.filter((n) => n.joint).length, 5);
|
||||||
report.checks.push("internal mesh parent-relative position and rotation survive GLB export without moving its parent");
|
|
||||||
report.checks.push("validated assembly exports a self-contained managed GLB");
|
report.checks.push("validated assembly exports a self-contained managed GLB");
|
||||||
assert.equal(await control("machine-manager-apply").isDisabled(), false); await control("machine-manager-apply").click();
|
assert.equal(await control("machine-manager-apply").isDisabled(), false); await control("machine-manager-apply").click();
|
||||||
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineManagerSchema === "linuxcnc.machine-manager/1" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 });
|
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineManagerSchema === "linuxcnc.machine-manager/1" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 });
|
||||||
const geometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry"));
|
const geometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry"));
|
||||||
assert.deepEqual(geometry.cOriginMm, [24, -12, 8]); assert.deepEqual(geometry.tiltOffsetMm, [0, 18, -45]); assert.equal(geometry.physicalZero.x, 5); assert.equal(geometry.physicalZero.a, 10); assert.equal(geometry.physicalZero.c, 20); assert.equal(geometry.parts.length, 8); assert.equal(geometry.parts[0].translationMm[0], 10); assert.equal(geometry.parts[0].rotationDeg[2], 15);
|
assert.deepEqual(geometry.cOriginMm, [24, -12, 8]); assert.deepEqual(geometry.tiltOffsetMm, [0, 18, -45]); assert.equal(geometry.physicalZero.x, 5); assert.equal(geometry.physicalZero.a, 10); assert.equal(geometry.physicalZero.c, 20); assert.equal(geometry.parts.length, 8); assert.equal(geometry.parts[1].nodeJoints.scene.axis, "X");
|
||||||
assert.deepEqual(geometry.parts[0].nodeTransforms[meshPath], { translationMm: [35, 0, 0], rotationDeg: [0, 0, 30] });
|
|
||||||
assert.match(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-model-source-path"), /^managed-xyzac\.glb$/);
|
assert.match(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-model-source-path"), /^managed-xyzac\.glb$/);
|
||||||
report.checks.push("validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData");
|
report.checks.push("validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData");
|
||||||
|
|
||||||
@@ -78,6 +64,7 @@ try {
|
|||||||
const xyzbcCase = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"; await control("workspace-case-select").selectOption(xyzbcCase);
|
const xyzbcCase = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"; await control("workspace-case-select").selectOption(xyzbcCase);
|
||||||
await page.waitForFunction((path) => document.querySelector('[data-axis-control="workspace-case-select"]')?.value === path && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", xyzbcCase, { timeout: 45000 });
|
await page.waitForFunction((path) => document.querySelector('[data-axis-control="workspace-case-select"]')?.value === path && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", xyzbcCase, { timeout: 45000 });
|
||||||
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZBC/);
|
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZBC/);
|
||||||
|
await selectRoot(5); await control("machine-tree-axis-role").selectOption("B");
|
||||||
const bcCalibrations = page.locator(".machine-manager-calibration fieldset.machine-vector"); const bcOrigin = bcCalibrations.filter({ hasText: "C 轴原点" }); await bcOrigin.getByLabel("X", { exact: true }).fill("-18"); await bcOrigin.getByLabel("Y", { exact: true }).fill("14"); await bcOrigin.getByLabel("Z", { exact: true }).fill("12");
|
const bcCalibrations = page.locator(".machine-manager-calibration fieldset.machine-vector"); const bcOrigin = bcCalibrations.filter({ hasText: "C 轴原点" }); await bcOrigin.getByLabel("X", { exact: true }).fill("-18"); await bcOrigin.getByLabel("Y", { exact: true }).fill("14"); await bcOrigin.getByLabel("Z", { exact: true }).fill("12");
|
||||||
const bcOffset = bcCalibrations.filter({ hasText: "B 到 C 轴偏置" }); await bcOffset.getByLabel("X", { exact: true }).fill("22"); await bcOffset.getByLabel("Y", { exact: true }).fill("0"); await bcOffset.getByLabel("Z", { exact: true }).fill("-40");
|
const bcOffset = bcCalibrations.filter({ hasText: "B 到 C 轴偏置" }); await bcOffset.getByLabel("X", { exact: true }).fill("22"); await bcOffset.getByLabel("Y", { exact: true }).fill("0"); await bcOffset.getByLabel("Z", { exact: true }).fill("-40");
|
||||||
const bcZeros = page.locator("fieldset.machine-zero"); await bcZeros.getByLabel("B", { exact: true }).fill("12"); await bcZeros.getByLabel("C", { exact: true }).fill("5"); await control("machine-manager-apply").click();
|
const bcZeros = page.locator("fieldset.machine-zero"); await bcZeros.getByLabel("B", { exact: true }).fill("12"); await bcZeros.getByLabel("C", { exact: true }).fill("5"); await control("machine-manager-apply").click();
|
||||||
|
|||||||
@@ -1,105 +1,4 @@
|
|||||||
import assert from "node:assert/strict";
|
// MGR-17 supersedes the unrestricted six-component pose editor. Keep this
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
// established entrypoint exercising MILL700 through the single-DOF contract.
|
||||||
import { createHash } from "node:crypto";
|
process.env.JOINT_TEST_COORDINATES ??= "XYZAC";
|
||||||
import { pathToFileURL } from "node:url";
|
await import("./test_machine_joint_dof_browser.mjs");
|
||||||
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
|
||||||
|
|
||||||
const modulePath = process.env.PLAYWRIGHT_MODULE;
|
|
||||||
if (!modulePath) { console.log(JSON.stringify({ schema: "machine-manager-mill700-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); }
|
|
||||||
const root = new URL("../", import.meta.url);
|
|
||||||
const modelPath = process.env.MILL700_GLB ?? new URL("MILL700-U_1.glb", root).pathname;
|
|
||||||
const model = await readFile(modelPath); const sha256 = createHash("sha256").update(model).digest("hex");
|
|
||||||
let offset = 12, json = null;
|
|
||||||
while (offset < model.length) { const length = model.readUInt32LE(offset), type = model.readUInt32LE(offset + 4); if (type === 0x4e4f534a) json = JSON.parse(model.toString("utf8", offset + 8, offset + 8 + length)); offset += 8 + length; }
|
|
||||||
assert.ok(json); const parents = new Map(); json.nodes.forEach((node, index) => (node.children ?? []).forEach((child) => parents.set(child, index)));
|
|
||||||
const depth = (index) => parents.has(index) ? 1 + depth(parents.get(index)) : 0;
|
|
||||||
const hierarchy = { root: json.nodes[json.scenes[json.scene ?? 0].nodes[0]].name, nodes: json.nodes.length, meshes: json.meshes.length, maxDepth: Math.max(...json.nodes.map((_, index) => depth(index))), materials: json.materials.length, metallic: json.materials.filter((material) => Number(material.pbrMetallicRoughness?.metallicFactor ?? 1) >= .9).length };
|
|
||||||
const output = new URL("../coverage/glb-machine-manager/mill700/", import.meta.url); await mkdir(output, { recursive: true });
|
|
||||||
const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.default ?? imported;
|
|
||||||
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
|
||||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); const errors = [];
|
|
||||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (["warning", "error"].includes(message.type())) errors.push(`console:${message.type()}:${message.text()}`); });
|
|
||||||
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
|
||||||
const report = { schema: "machine-manager-mill700-browser.v1", report_id: "GLB-MGR-E05", status: "running", model: { path: modelPath, bytes: model.length, sha256 }, checks: [] };
|
|
||||||
try {
|
|
||||||
await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5174/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
||||||
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 45000 });
|
|
||||||
await control("machine-manager-settings").click(); await control("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: model });
|
|
||||||
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "1" && document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerRendered === "true" && document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132, null, { timeout: 60000 });
|
|
||||||
assert.equal(hierarchy.root, "MILL700-U_1"); assert.equal(hierarchy.nodes, 132); assert.equal(hierarchy.meshes, 126); assert.ok(hierarchy.maxDepth >= 6); assert.ok(hierarchy.metallic > 20);
|
|
||||||
const transformPreview = page.locator("[data-machine-manager-preview]"); const beforePartTransform = JSON.parse(await transformPreview.getAttribute("data-machine-manager-selected-joint-world-origin")); await control("machine-manager-translation").locator("input").nth(0).fill("100"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-origin") ?? "[]"); return current.length === 3 && Math.abs(current[0] - before[0] - .1) < 1e-7; }, { before: beforePartTransform }); const beforePartRotation = JSON.parse(await transformPreview.getAttribute("data-machine-manager-selected-joint-world-quaternion")); await control("machine-manager-rotation").locator("input").nth(2).fill("20"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-quaternion") ?? "[]"); return current.length === 4 && Math.abs(current[3] - before[3]) > 1e-4; }, { before: beforePartRotation }); report.checks.push("root and internal node transforms update their local position and posture relative to their parent");
|
|
||||||
const readSelected = async () => JSON.parse(await transformPreview.getAttribute("data-machine-manager-selected-transform"));
|
|
||||||
const choose = async (name) => {
|
|
||||||
const button = control("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^${name}$`) }) }).first();
|
|
||||||
const path = await button.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path");
|
|
||||||
await button.dispatchEvent("click");
|
|
||||||
await page.waitForFunction((path) => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerSelectedJoint?.endsWith(`:${path}`), path);
|
|
||||||
return readSelected();
|
|
||||||
};
|
|
||||||
const closeVector = (actual, expected, label) => actual.forEach((value, index) => assert.ok(Math.abs(value - expected[index]) < 1e-7, `${label}[${index}]: ${value} != ${expected[index]}`));
|
|
||||||
const sampleBranch = async () => ({
|
|
||||||
parent: await choose("MILL700-A-主轴-JT2"),
|
|
||||||
sibling: await choose("SHS25C100020_Track_TSXXX-0000058522002"),
|
|
||||||
child: await choose("MILL700-A-主轴-JT4"),
|
|
||||||
selected: await choose("MILL700-A-主轴-JT3"),
|
|
||||||
});
|
|
||||||
const beforeEdit = await sampleBranch();
|
|
||||||
const translationInputs = control("machine-manager-translation").locator("input");
|
|
||||||
for (let i = 0; i < 3; i++) assert.ok(Math.abs(Number(await translationInputs.nth(i).inputValue()) - beforeEdit.selected.local[i] * 1000) < 1e-7);
|
|
||||||
assert.match(await control("machine-tree-origin").textContent(), /节点自身局部原点.*装配位置.*装配姿态/s);
|
|
||||||
await translationInputs.nth(0).fill("120");
|
|
||||||
// Screenshot complaint: move only the selected joint by -44 mm in its parent's Z direction.
|
|
||||||
await translationInputs.nth(2).fill(String(beforeEdit.selected.local[2] * 1000 - 44));
|
|
||||||
const translated = await sampleBranch();
|
|
||||||
const delta = new THREE.Vector3(.12 - beforeEdit.selected.local[0], 0, -.044)
|
|
||||||
.applyQuaternion(new THREE.Quaternion(...beforeEdit.parent.worldQuaternion));
|
|
||||||
closeVector(translated.selected.world, new THREE.Vector3(...beforeEdit.selected.world).add(delta).toArray(), "translation follows rotated parent axes");
|
|
||||||
closeVector(translated.child.world, new THREE.Vector3(...beforeEdit.child.world).add(delta).toArray(), "child inherits translation");
|
|
||||||
for (const key of ["parent", "sibling"]) assert.deepEqual(translated[key], beforeEdit[key], `${key} must not move`);
|
|
||||||
closeVector(translated.child.local, beforeEdit.child.local, "child local position unchanged");
|
|
||||||
await control("machine-manager-rotation").locator("input").nth(2).fill("25");
|
|
||||||
const rotated = await sampleBranch();
|
|
||||||
closeVector(rotated.selected.localQuaternion, new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), 25 * Math.PI / 180).toArray(), "selected local rotation");
|
|
||||||
closeVector(rotated.selected.world, translated.selected.world, "rotation about own origin");
|
|
||||||
for (const key of ["parent", "sibling"]) assert.deepEqual(rotated[key], beforeEdit[key], `${key} must not rotate`);
|
|
||||||
closeVector(rotated.child.local, beforeEdit.child.local, "child local origin unchanged");
|
|
||||||
const childWorld = new THREE.Vector3(...rotated.child.local).applyQuaternion(new THREE.Quaternion(...rotated.selected.worldQuaternion)).add(new THREE.Vector3(...rotated.selected.world));
|
|
||||||
closeVector(rotated.child.world, childWorld.toArray(), "child inherits selected rotation");
|
|
||||||
assert.ok(rotated.child.world.some((value, i) => Math.abs(value - translated.child.world[i]) > 1e-6));
|
|
||||||
assert.equal(await transformPreview.getAttribute("data-machine-manager-selected-joint-axes"), "local-xyz");
|
|
||||||
await choose("MILL700-A-主轴-JT2");
|
|
||||||
assert.equal(Number(await control("machine-manager-rotation").locator("input").nth(2).inputValue()), 0);
|
|
||||||
await choose("MILL700-A-主轴-JT3");
|
|
||||||
assert.equal(Number(await translationInputs.nth(0).inputValue()), 120);
|
|
||||||
assert.equal(Number(await control("machine-manager-rotation").locator("input").nth(2).inputValue()), 25);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await control("machine-manager-settings").click();
|
|
||||||
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerRendered === "true");
|
|
||||||
closeVector((await readSelected()).local, rotated.selected.local, "reopening preserves node edit");
|
|
||||||
report.nodeEditing = { before: beforeEdit, translated, rotated };
|
|
||||||
report.checks.push("MILL700 JT3 edits preserve parent and sibling transforms; child inherits movement and rotation around the joint origin; values survive reselection and dialog reopening");
|
|
||||||
await page.screenshot({ path: new URL("mill700-studio-lighting.png", output).pathname, timeout: 60000 });
|
|
||||||
const source = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A-门1-JT1" }).first(), target = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A底座" }).first(); await source.dispatchEvent("click");
|
|
||||||
await control("machine-manager-rotation").locator("input").nth(2).fill("-15");
|
|
||||||
const doorBefore = await readSelected();
|
|
||||||
const sourceItem = source.locator("xpath=ancestor::li[1]"), targetItem = target.locator("xpath=ancestor::li[1]"), sourcePath = await sourceItem.getAttribute("data-machine-tree-path"), targetPath = await targetItem.getAttribute("data-machine-tree-path"), partId = await sourceItem.getAttribute("data-machine-tree-part"); await target.evaluate((element, payload) => { const transfer = new DataTransfer(); transfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: payload.partId, path: payload.sourcePath })); element.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })); }, { partId, sourcePath }); await page.waitForFunction(({ sourcePath, targetPath }) => document.querySelector(`[data-machine-tree-path="${sourcePath}"]`)?.getAttribute("data-machine-tree-parent") === targetPath, { sourcePath, targetPath }, { timeout: 60000 }); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
|
|
||||||
const doorAfter = await readSelected();
|
|
||||||
closeVector(doorAfter.world, doorBefore.world, "reparent preserves edited world position");
|
|
||||||
closeVector(doorAfter.worldQuaternion, doorBefore.worldQuaternion, "reparent preserves edited world orientation");
|
|
||||||
const doorLocalX = Number(await translationInputs.nth(0).inputValue());
|
|
||||||
await translationInputs.nth(0).fill(String(doorLocalX + 12));
|
|
||||||
const doorEdited = await readSelected();
|
|
||||||
closeVector(doorEdited.local, [doorAfter.local[0] + .012, ...doorAfter.local.slice(1)], "editing uses stable path after reparent");
|
|
||||||
report.checks.push("reparenting an edited node preserves world pose and subsequent inputs edit that node under its new parent");
|
|
||||||
report.hierarchy = hierarchy; report.checks.push("selected MILL700 joint displays its local XYZ coordinate system at local origin"); report.checks.push("existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins");
|
|
||||||
const preview = page.locator("[data-machine-manager-preview]"); assert.equal(await preview.getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await preview.getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await preview.getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await preview.getAttribute("data-machine-manager-camera-up"), "0,0,1"); assert.equal(await preview.getAttribute("data-machine-manager-model-transform"), "none-on-import"); assert.equal(await preview.getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await preview.getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); assert.equal(Number(await preview.getAttribute("data-machine-manager-scene-meshes")), 126); assert.ok(Number(await preview.getAttribute("data-machine-manager-scene-objects")) >= 132); const bounds = JSON.parse(await preview.getAttribute("data-machine-manager-bounds")); assert.ok(bounds.min.every(Number.isFinite) && bounds.max.every(Number.isFinite) && bounds.size > 0); const [bufferWidth, bufferHeight] = (await preview.getAttribute("data-machine-manager-buffer-size")).split("x").map(Number), maxBuffer = Number(await preview.getAttribute("data-machine-manager-max-buffer")); assert.ok(bufferWidth > 0 && bufferHeight > 0 && bufferWidth <= maxBuffer && bufferHeight <= maxBuffer);
|
|
||||||
const bind = async (name, role) => { const node = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: name }).first(); await node.dispatchEvent("click"); await control("machine-tree-axis-role").selectOption(role); };
|
|
||||||
await bind("MILL700-A-主轴-JT1", "x"); await bind("MILL700-A-主轴-JT2", "y"); await bind("MILL700-A-主轴-JT3", "z"); await bind("MILL700-A-工作台-JT1", "tilt"); await bind("MILL700-A-工作台-JT2", "c"); await page.waitForFunction(() => { try { const roles = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerRoleTransforms ?? "{}"); return ["x", "y", "z", "tilt", "c"].every((role) => roles[role]); } catch { return false; } });
|
|
||||||
const baseline = JSON.parse(await preview.getAttribute("data-machine-manager-role-transforms")); await control("machine-test-x").fill("10"); await control("machine-test-y").fill("20"); await control("machine-test-z").fill("30"); await control("machine-test-a").fill("15"); await control("machine-test-c").fill("45"); await page.waitForFunction(() => JSON.parse(document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerTestPose ?? "{}").c === 45);
|
|
||||||
const driven = JSON.parse(await preview.getAttribute("data-machine-manager-role-transforms")), quaternionAngle = (left, right) => 2 * Math.acos(Math.min(1, Math.abs(left.reduce((sum, value, index) => sum + value * right[index], 0))));
|
|
||||||
assert.ok(Math.abs(driven.x.local[0] - baseline.x.local[0] - .01) < 1e-9); assert.ok(Math.abs(driven.y.local[1] - baseline.y.local[1] - .02) < 1e-9); assert.ok(Math.abs(driven.z.local[2] - baseline.z.local[2] - .03) < 1e-9); assert.ok(Math.abs(quaternionAngle(driven.tilt.localQuaternion, baseline.tilt.localQuaternion) - 15 * Math.PI / 180) < 1e-7); assert.ok(Math.abs(quaternionAngle(driven.c.localQuaternion, baseline.c.localQuaternion) - 45 * Math.PI / 180) < 1e-7); assert.deepEqual(driven.tilt.world, baseline.tilt.world); assert.deepEqual(driven.c.local, baseline.c.local); assert.ok(driven.c.world.some((value, index) => Math.abs(value - baseline.c.world[index]) > 1e-6)); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); report.motion = { input: { x: 10, y: 20, z: 30, a: 15, c: 45 }, baseline, driven }; report.checks.push("each joint rotates about its own origin while children inherit parent motion");
|
|
||||||
report.checks.push("metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"); assert.deepEqual(errors, []); report.status = "verified";
|
|
||||||
await control("machine-manager-delete").click(); await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "0" && document.querySelectorAll('[data-axis-control="machine-tree-node"]').length === 0); report.checks.push("imported model can be deleted and its tree is removed");
|
|
||||||
} catch (error) { report.status = "failed"; report.error = error instanceof Error ? error.stack : String(error); await page.screenshot({ path: new URL("failure.png", output).pathname, timeout: 5000 }).catch(() => undefined); throw error; }
|
|
||||||
finally { report.browser_errors = errors; await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }
|
|
||||||
console.log(JSON.stringify(report));
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,28 +1,28 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
|
||||||
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
||||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||||
import { DraggableDialog } from "./DraggableDialog";
|
import { DraggableDialog } from "./DraggableDialog";
|
||||||
import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
|
import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
|
||||||
import { configureMachinePartScene, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
|
import { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
|
||||||
|
import { driveJoint, jointZero, defaultDof, isLinear, type JointDof, type CncAxis } from "./machine-joints";
|
||||||
|
import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
|
||||||
import type { Vec3 } from "./machine-urdf";
|
import type { Vec3 } from "./machine-urdf";
|
||||||
|
|
||||||
type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
type Props = { snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||||
type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
|
type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||||
type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] };
|
type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] };
|
||||||
const zero3 = (): Vec3 => [0, 0, 0];
|
const zero3 = (): Vec3 => [0, 0, 0];
|
||||||
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||||
|
|
||||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode, testPose, onTransform, onMotionState }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; mode: "translate" | "rotate"; testPose: TestPose; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void; onMotionState: (state: Record<string, MotionTransform>) => void }) {
|
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, mode: "translate" | "rotate", testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
||||||
const host = useRef<PreviewHost>(null);
|
const host = useRef<PreviewHost>(null);
|
||||||
const stateRef = useRef({ parts, selectedId, selectedNodePath, mode, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, mode, testPose, coordinates };
|
const stateRef = useRef({ parts, selectedId, selectedNodePath, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, testPose, coordinates };
|
||||||
const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform;
|
|
||||||
const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState;
|
const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState;
|
||||||
const [fault, setFault] = useState("");
|
const [fault, setFault] = useState("");
|
||||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, mode, testPose, coordinates); }, [parts, selectedId, selectedNodePath, mode, testPose, coordinates]);
|
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = host.current; if (!element) return;
|
const element = host.current; if (!element) return;
|
||||||
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
|
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
|
||||||
@@ -43,27 +43,21 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
|||||||
const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.up.set(0, 0, 1); camera.position.set(2.4, -2.8, 1.8);
|
const 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 orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true;
|
||||||
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
|
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
|
||||||
|
let dofNodes: Array<{ object: THREE.Object3D; zero: ReturnType<typeof jointZero>; dof: JointDof }> = [];
|
||||||
type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion };
|
type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion };
|
||||||
let nodes = new Map<string, THREE.Group>(), visualNodes = new Map<string, Map<string, THREE.Object3D>>(), roleNodes = new Map<MachinePartRole, RoleNode>(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedJointObject: THREE.Object3D | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0;
|
let nodes = new Map<string, THREE.Group>(), visualNodes = new Map<string, Map<string, THREE.Object3D>>(), roleNodes = new Map<MachinePartRole, RoleNode>(), helpers: THREE.AxesHelper[] = [], selectedJointObject: THREE.Object3D | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0;
|
||||||
const selectedJointAxes = new THREE.Group(); selectedJointAxes.name = "selected-joint-local-coordinate-system"; selectedJointAxes.visible = false;
|
const 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));
|
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);
|
jointAxis(new THREE.Vector3(1, 0, 0), 0xff3030); jointAxis(new THREE.Vector3(0, 1, 0), 0x35d05b); jointAxis(new THREE.Vector3(0, 0, 1), 0x327cff); scene.add(selectedJointAxes);
|
||||||
const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper());
|
|
||||||
transform.addEventListener("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never);
|
|
||||||
transform.addEventListener("objectChange", (() => {
|
|
||||||
if (!selectedPartId || !selectedObject) return;
|
|
||||||
onTransformRef.current(selectedPartId, selectedObject.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selectedObject.rotation.x), THREE.MathUtils.radToDeg(selectedObject.rotation.y), THREE.MathUtils.radToDeg(selectedObject.rotation.z)]);
|
|
||||||
}) as never);
|
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (contextLost || !renderable) return;
|
if (contextLost || !renderable) return;
|
||||||
renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera);
|
renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera);
|
||||||
element.dataset.machineManagerRendered = "true"; element.dataset.machineManagerRenderState = "rendered"; dirty = false;
|
element.dataset.machineManagerRendered = "true"; element.dataset.machineManagerRenderState = "rendered"; dirty = false;
|
||||||
};
|
};
|
||||||
element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextMode, nextPose, nextCoordinates) => {
|
element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextPose, nextCoordinates) => {
|
||||||
transform.setMode(nextMode);
|
|
||||||
if (nextParts !== lastParts) {
|
if (nextParts !== lastParts) {
|
||||||
delete element.dataset.machineManagerRendered; element.dataset.machineManagerRenderState = "syncing-scene";
|
delete element.dataset.machineManagerRendered; element.dataset.machineManagerRenderState = "syncing-scene";
|
||||||
transform.detach(); selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map();
|
selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; dofNodes = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map();
|
||||||
for (const part of nextParts) {
|
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);
|
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
|
// Clone transforms/hierarchy only. Geometry, materials, textures and
|
||||||
@@ -71,6 +65,9 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
|||||||
const { scene: visual, nodes: pathNodes } = configureMachinePartScene(part.scene.clone(true), part);
|
const { scene: visual, nodes: pathNodes } = configureMachinePartScene(part.scene.clone(true), part);
|
||||||
const internalRoles = Object.entries(part.nodeRoles ?? {}).map(([path, role]) => ({ object: pathNodes.get(path) ?? null, role }));
|
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);
|
visual.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); node.add(visual); visualNodes.set(part.id, pathNodes);
|
||||||
|
const rootDof = draftNodeDof(part, "scene", nextCoordinates);
|
||||||
|
if (rootDof) dofNodes.push({ object: node, zero: jointZero(node), dof: rootDof });
|
||||||
|
for (const [path, object] of pathNodes) { const dof = path === "scene" ? null : draftNodeDof(part, path, nextCoordinates); if (dof) dofNodes.push({ object, zero: jointZero(object), dof }); }
|
||||||
const axes = new THREE.AxesHelper(.12); helpers.push(axes); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node);
|
const axes = new THREE.AxesHelper(.12); helpers.push(axes); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node);
|
||||||
if (part.role) roleNodes.set(part.role, { object: node, position: node.position.clone(), quaternion: node.quaternion.clone() });
|
if (part.role) roleNodes.set(part.role, { object: node, position: node.position.clone(), quaternion: node.quaternion.clone() });
|
||||||
for (const binding of internalRoles) if (binding.object && binding.role) roleNodes.set(binding.role, { object: binding.object, position: binding.object.position.clone(), quaternion: binding.object.quaternion.clone() });
|
for (const binding of internalRoles) if (binding.object && binding.role) roleNodes.set(binding.role, { object: binding.object, position: binding.object.position.clone(), quaternion: binding.object.quaternion.clone() });
|
||||||
@@ -85,17 +82,15 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
|||||||
}
|
}
|
||||||
lastParts = nextParts;
|
lastParts = nextParts;
|
||||||
}
|
}
|
||||||
selectedPartId = nextSelectedId; selectedObject = nextSelectedId && nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject);
|
|
||||||
selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = nextNodePath === "scene" ? "part-root" : "none-internal-joint"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; }
|
selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = "single-dof-input"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; }
|
||||||
for (const binding of roleNodes.values()) { binding.object.position.copy(binding.position); binding.object.quaternion.copy(binding.quaternion); }
|
for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase() as keyof TestPose]);
|
||||||
const translate = (role: MachinePartRole, axis: "x" | "y" | "z", value: number) => { const binding = roleNodes.get(role); if (binding) { const direction = new THREE.Vector3(axis === "x" ? 1 : 0, axis === "y" ? 1 : 0, axis === "z" ? 1 : 0).applyQuaternion(binding.quaternion); binding.object.position.addScaledVector(direction, value / 1000); } };
|
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);
|
||||||
const rotate = (role: MachinePartRole, axis: THREE.Vector3, degrees: number) => { const binding = roleNodes.get(role); if (binding) binding.object.quaternion.copy(binding.quaternion).multiply(new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(degrees))); };
|
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;
|
||||||
translate("x", "x", nextPose.x); translate("y", "y", nextPose.y); translate("z", "z", nextPose.z); rotate("tilt", nextCoordinates === "XYZBC" ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0), nextCoordinates === "XYZBC" ? nextPose.b : nextPose.a); rotate("c", new THREE.Vector3(0, 0, 1), nextPose.c);
|
|
||||||
root.updateMatrixWorld(true); let objectCount = 0, meshCount = 0; root.traverse((object) => { let cursor: THREE.Object3D | null = object, isJointHelper = false; while (cursor) { if (cursor === selectedJointAxes) { isJointHelper = true; break; } cursor = cursor.parent; } if (!isJointHelper) { objectCount += 1; if ((object as THREE.Mesh).isMesh) meshCount += 1; } }); const transforms = Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }])); element.dataset.machineManagerSceneObjects = String(objectCount); element.dataset.machineManagerSceneMeshes = String(meshCount); element.dataset.machineManagerRoleTransforms = JSON.stringify(transforms); if (selectedJointObject) { element.dataset.machineManagerSelectedJointWorldOrigin = JSON.stringify(selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray()); element.dataset.machineManagerSelectedJointWorldQuaternion = JSON.stringify(selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray()); element.dataset.machineManagerSelectedJointAxesScale = selectedAxesScale.toFixed(6); } const selectedState = selectedJointObject ? { local: selectedJointObject.position.toArray(), localQuaternion: selectedJointObject.quaternion.toArray(), world: selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray() } : null;
|
|
||||||
element.dataset.machineManagerSelectedTransform = JSON.stringify(selectedState);
|
element.dataset.machineManagerSelectedTransform = JSON.stringify(selectedState);
|
||||||
onMotionStateRef.current(selectedState ? { ...transforms, selected: selectedState } : transforms); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; draw();
|
onMotionStateRef.current(selectedState ? { ...transforms, selected: selectedState } : transforms); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; draw();
|
||||||
};
|
};
|
||||||
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.mode, stateRef.current.testPose, stateRef.current.coordinates);
|
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.testPose, stateRef.current.coordinates);
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const width = element.clientWidth, height = element.clientHeight;
|
const width = element.clientWidth, height = element.clientHeight;
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; }
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; }
|
||||||
@@ -109,28 +104,28 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
|||||||
const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; setFault("WebGL 上下文已丢失,请关闭并重新打开三维机床管理器"); };
|
const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; setFault("WebGL 上下文已丢失,请关闭并重新打开三维机床管理器"); };
|
||||||
renderer.domElement.addEventListener("webglcontextlost", contextLostHandler);
|
renderer.domElement.addEventListener("webglcontextlost", contextLostHandler);
|
||||||
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
||||||
orbit.addEventListener("change", () => { dirty = true; }); transform.addEventListener("change", () => { dirty = true; });
|
orbit.addEventListener("change", () => { dirty = true; });
|
||||||
const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render();
|
const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render();
|
||||||
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
|
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
|
||||||
}, []);
|
}, []);
|
||||||
return <div className="machine-manager-preview-shell"><div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && <output className="machine-manager-webgl-fault" role="alert">{fault}</output>}</div>;
|
return <div className="machine-manager-preview-shell"><div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && <output className="machine-manager-webgl-fault" role="alert">{fault}</output>}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode }) {
|
function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode }) {
|
||||||
const key = `${part.id}:${node.path}`;
|
const key = `${part.id}:${node.path}`;
|
||||||
const [expanded, setExpanded] = useState(node.path === "scene");
|
const [expanded, setExpanded] = useState(node.path === "scene");
|
||||||
const hasChildren = node.children.length > 0 || Boolean(extra);
|
const hasChildren = node.children.length > 0 || Boolean(extra);
|
||||||
const binding = node.path === "scene" ? part.role : part.nodeRoles?.[node.path];
|
const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]);
|
||||||
return <li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} data-machine-tree-part={part.id} data-machine-tree-path={node.path} data-machine-tree-parent={node.path === "scene" ? part.parentId ?? "root" : Object.hasOwn(part.nodeParents, node.path) ? part.nodeParents[node.path] ?? "scene" : "source"}>
|
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>
|
<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>}
|
{hasChildren && <ul role="group" hidden={!expanded}>{node.children.map((child) => <TreeNode key={child.path} part={part} node={child} selected={selected} onSelect={onSelect} onDrop={onDrop} />)}{extra}</ul>}
|
||||||
</li>;
|
</li>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }: Props) {
|
||||||
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
|
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
|
||||||
const [parts, setParts] = useState<MachinePartDraft[]>([]); const partsRef = useRef(parts); partsRef.current = parts;
|
const [parts, setParts] = useState<MachinePartDraft[]>([]); const partsRef = useRef(parts); partsRef.current = parts;
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); const [mode, setMode] = useState<"translate" | "rotate">("translate");
|
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene");
|
||||||
const [cOriginMm, setCOriginMm] = useState<Vec3>([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState<Vec3>([0, 0, 0]);
|
const [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 [physicalZero, setPhysicalZero] = useState<PhysicalZero>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef<HTMLInputElement>(null);
|
||||||
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
|
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
|
||||||
@@ -143,30 +138,27 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
|||||||
const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null;
|
const 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 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 vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string, control?: string) => <fieldset className="machine-vector" data-axis-control={control}><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<input type="number" step="any" value={value[index]} onChange={(event) => { const next = [...value] as Vec3; next[index] = number(event.target.value); set(next); }} /></label>)}</fieldset>;
|
||||||
const updateSelectedTransform = (field: "translationMm" | "rotationDeg", value: Vec3) => {
|
|
||||||
if (!selected || !selectedTreeNode) return;
|
|
||||||
if (selectedNodePath === "scene") { update(selected.id, { [field]: value }); return; }
|
|
||||||
setParts((current) => current.map((part) => {
|
|
||||||
if (part.id !== selected.id) return part;
|
|
||||||
const currentTransform = part.nodeTransforms?.[selectedNodePath] ?? { translationMm: selectedTreeNode!.originMm, rotationDeg: selectedTreeNode!.rotationDeg };
|
|
||||||
return { ...part, nodeTransforms: { ...(part.nodeTransforms ?? {}), [selectedNodePath]: { ...currentTransform, [field]: value } } };
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const importFiles = async (list: FileList | File[] | null) => {
|
const importFiles = async (list: FileList | File[] | null) => {
|
||||||
if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||||
try {
|
try {
|
||||||
const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name)));
|
const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name)));
|
||||||
|
if (loaded.some((source) => source.restoredModel && source.restoredModel.coordinates !== coordinates)) {
|
||||||
|
loaded.forEach((source) => disposeMachineScene(source.scene));
|
||||||
|
throw new Error(`导入机型不同,请先切换到 ${loaded.find((source) => source.restoredModel?.coordinates !== coordinates && source.restoredModel)?.restoredModel?.coordinates}`);
|
||||||
|
}
|
||||||
setParts((current) => {
|
setParts((current) => {
|
||||||
const next = [...current];
|
const next = [...current];
|
||||||
for (const source of loaded) {
|
for (const source of loaded) {
|
||||||
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
|
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
|
||||||
const parentRole = role ? expectedParentRole(role) : null;
|
const parentRole = role ? expectedParentRole(role) : null;
|
||||||
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
|
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
|
||||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeRoles: {}, nodeTransforms: {} });
|
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} });
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(`已导入 ${loaded.length} 个部件`);
|
const restored = loaded.find((source) => source.restoredModel)?.restoredModel;
|
||||||
|
if (restored) { setCOriginMm(restored.calibration.cOriginMm); setTiltOffsetMm(restored.calibration.tiltOffsetMm); setPhysicalZero(restored.calibration.physicalZero as PhysicalZero); setMessage(`已恢复 ${restored.coordinates} 单自由度配置`); }
|
||||||
|
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已恢复 ${restored.coordinates} 关节配置和物理零点` : `已导入 ${loaded.length} 个部件`);
|
||||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||||
finally { setPending(false); }
|
finally { setPending(false); }
|
||||||
};
|
};
|
||||||
@@ -199,7 +191,9 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
|||||||
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
|
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
|
||||||
try {
|
try {
|
||||||
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
|
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
|
||||||
const bytes = await new GLTFExporter().parseAsync(machine.scene, { binary: true, trs: true, onlyVisible: false });
|
const exportScene = new THREE.Scene(); exportScene.name = machine.scene.name; exportScene.userData = machine.scene.userData;
|
||||||
|
for (const child of machine.scene.children) exportScene.add(child.clone(true));
|
||||||
|
const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false });
|
||||||
const url = URL.createObjectURL(new Blob([bytes as ArrayBuffer], { type: "model/gltf-binary" }));
|
const 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);
|
const link = document.createElement("a"); link.href = url; link.download = machine.filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
||||||
@@ -207,23 +201,28 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
|||||||
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
||||||
};
|
};
|
||||||
const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => {
|
const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => {
|
||||||
|
if (selectedNodePath !== "scene") { update(part.id, { nodeRoles: { ...part.nodeRoles, [selectedNodePath]: role } }); return; }
|
||||||
const parentRole = role ? expectedParentRole(role) : null;
|
const parentRole = role ? expectedParentRole(role) : null;
|
||||||
update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null });
|
update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null });
|
||||||
};
|
};
|
||||||
const selectedBinding = selected ? selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? "" : "";
|
const selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null;
|
||||||
const assignTreeRole = (role: MachinePartRole | "") => {
|
const setDof = (dof: JointDof | null) => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setParts((current) => current.map((part) => {
|
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; }
|
||||||
const nodeRoles = Object.fromEntries(Object.entries(part.nodeRoles ?? {}).filter(([, value]) => !role || value !== role));
|
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 }; }));
|
||||||
let partRole = role && part.role === role ? "" : part.role;
|
setMessage("");
|
||||||
if (part.id === selected.id) {
|
|
||||||
if (selectedNodePath === "scene") partRole = role;
|
|
||||||
else { delete nodeRoles[selectedNodePath]; if (role) nodeRoles[selectedNodePath] = role; }
|
|
||||||
}
|
|
||||||
return { ...part, role: partRole, nodeRoles } as MachinePartDraft;
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<input data-axis-control={`machine-test-${axis}`} type="number" step="any" value={testPose[axis]} onChange={(event) => setTestPose({ ...testPose, [axis]: number(event.target.value) })} /><small>{unit}</small></label>;
|
const [followLive, setFollowLive] = useState(false);
|
||||||
|
const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]);
|
||||||
|
const liveReady = visual.authoritative && visual.machineModel?.coordinate_system === coordinates;
|
||||||
|
const effectivePose: TestPose = followLive && liveReady ? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - physicalZero[axis.toLowerCase() as keyof PhysicalZero]])) } : testPose;
|
||||||
|
const validation = useMemo(() => { try { const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]);
|
||||||
|
const exportJson = () => {
|
||||||
|
if (!validation.model) return;
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(validation.model, null, 2)], { type: "application/json" }));
|
||||||
|
const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
};
|
||||||
|
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<input data-axis-control={`machine-test-${axis}`} type="number" step="any" disabled={followLive || !parts.some((part) => Object.values(part.nodeJoints ?? {}).some((joint) => joint.axis.toLowerCase() === axis))} value={effectivePose[axis]} onChange={(event) => setTestPose({ ...testPose, [axis]: number(event.target.value) })} /><small>{unit}</small></label>;
|
||||||
const receiveMotionState = (state: Record<string, MotionTransform>) => { const hash = JSON.stringify(state); if (hash !== motionStateHash.current) { motionStateHash.current = hash; setMotionState(state); } };
|
const 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 selectedDriven = motionState.selected ?? null;
|
||||||
const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?.[selectedNodePath] ?? (selectedTreeNode ? { translationMm: selectedTreeNode.originMm, rotationDeg: selectedTreeNode.rotationDeg } : null);
|
const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?.[selectedNodePath] ?? (selectedTreeNode ? { translationMm: selectedTreeNode.originMm, rotationDeg: selectedTreeNode.rotationDeg } : null);
|
||||||
@@ -231,18 +230,21 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
|||||||
return <section className="machine-manager" aria-label="三维机床管理">
|
return <section className="machine-manager" aria-label="三维机床管理">
|
||||||
<button type="button" data-axis-control="machine-manager-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>三维机床管理</button>
|
<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)}>
|
<DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" onClose={() => setOpen(false)}>
|
||||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-translate" aria-pressed={mode === "translate"} onClick={() => setMode("translate")}>平移部件</button><button type="button" data-axis-control="machine-manager-rotate" aria-pressed={mode === "rotate"} onClick={() => setMode("rotate")}>旋转部件</button><span>{parts.length}/8 部件</span></div>
|
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><span>{parts.length} 个导入模型 · 每个运动节点一个自由度</span></div>
|
||||||
<div className="machine-manager-layout">
|
<div className="machine-manager-layout">
|
||||||
{open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} mode={mode} testPose={testPose} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} onMotionState={receiveMotionState} />}
|
{open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />}
|
||||||
<div className="machine-manager-editor">
|
<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>
|
<section className="machine-part-list"><h3>部件与层级</h3>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
|
||||||
{selected && <section className="machine-part-editor"><header><h3>{selected.filename}</h3><button type="button" data-axis-control="machine-manager-delete" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div>}<label>轴绑定<select data-axis-control="machine-tree-axis-role" value={selectedBinding} onChange={(event) => assignTreeRole(event.target.value as MachinePartRole | "")}><option value="">未绑定</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><label>机床角色<select data-axis-control="machine-manager-role" value={selected.role} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><label>父部件<select data-axis-control="machine-manager-parent" value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => part.id !== selected.id).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label><div className="machine-part-flags"><label><input type="checkbox" checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div>{selectedNodePath === "scene" && vectorInput("导入文件装配原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm", "machine-manager-origin")}
|
{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>
|
||||||
{selectedTransform && <><p className="machine-transform-reference">编辑对象:{selectedTreeNode?.name}。数值为相对父级的位置和 XYZ 欧拉角;旋转中心为本节点原点。</p>{vectorInput("部件平移(相对父级位置)", selectedTransform.translationMm, (value) => updateSelectedTransform("translationMm", value), "mm", "machine-manager-translation")}{vectorInput("部件旋转(相对父级姿态)", selectedTransform.rotationDeg, (value) => updateSelectedTransform("rotationDeg", value), "deg", "machine-manager-rotation")}</>}</section>}
|
{selectedDof && <><label>父级运动方向<select data-axis-control="machine-joint-direction" value={selectedDof.direction} onChange={(event) => setDof({ ...selectedDof, direction: event.target.value as "x" | "y" | "z" })}>{["x", "y", "z"].map((axis) => <option key={axis} value={axis}>{axis.toUpperCase()}</option>)}</select></label><label>正负方向<select data-axis-control="machine-joint-sign" value={selectedDof.sign} onChange={(event) => setDof({ ...selectedDof, sign: Number(event.target.value) as 1 | -1 })}><option value="1">正向 +1</option><option value="-1">反向 −1</option></select></label></>}
|
||||||
|
<label>节点结构角色<select data-axis-control="machine-manager-role" value={selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? ""} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><label>模型装配父级<select data-axis-control="machine-manager-parent" value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => part.id !== selected.id).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label><div className="machine-part-flags"><label><input type="checkbox" checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div><p className="machine-transform-reference">当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}</p>
|
||||||
|
<label>{selectedDof ? `${selectedDof.axis} ${isLinear(selectedDof.axis) ? "位移 (mm)" : "转角 (deg)"}` : "关节值"}<input data-axis-control="machine-joint-value" type="number" step="any" disabled={!selectedDof || followLive} value={selectedDof ? effectivePose[selectedDof.axis.toLowerCase() as keyof TestPose] : 0} onChange={(event) => { if (selectedDof) setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: number(event.target.value) })); }} /></label>
|
||||||
|
</section>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section className="machine-motion-test"><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><output data-axis-control="machine-test-state">{JSON.stringify(testPose)}</output></section>
|
<section className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={!liveReady} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "等待有效机床数据" : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={followLive} onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></section>
|
||||||
<section className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点 (XYZ mm / ABC deg)</legend>{["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => <label key={axis}>{axis.toUpperCase()}<input type="number" step="any" value={physicalZero[axis as keyof PhysicalZero]} onChange={(event) => setPhysicalZero({ ...physicalZero, [axis]: number(event.target.value) })} /></label>)}</fieldset></section>
|
<section className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点 (XYZ mm / ABC deg)</legend>{["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => <label key={axis}>{axis.toUpperCase()}<input type="number" step="any" value={physicalZero[axis as keyof PhysicalZero]} onChange={(event) => setPhysicalZero({ ...physicalZero, [axis]: number(event.target.value) })} /></label>)}</fieldset></section>
|
||||||
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || parts.length !== 8} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || parts.length !== 8} onClick={() => void exportGlb()}>导出组合 GLB</button><output role="status">{message || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}</output></footer>
|
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || Boolean(validation.error)} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || Boolean(validation.error)} onClick={() => void exportGlb()}>导出组合 GLB</button><button type="button" data-axis-control="machine-manager-export-json" disabled={!validation.model} onClick={exportJson}>导出关节 JSON</button><output role="status">{message || validation.error || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}</output></footer>
|
||||||
<input ref={file} data-axis-control="machine-manager-files" hidden multiple type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
|
<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>
|
</DraggableDialog>
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = host.current;
|
const element = host.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
const managedJoints = Boolean(importedMachine?.scene.userData.linuxcncJointModel);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: !managedJoints, alpha: false, preserveDrawingBuffer: !managedJoints });
|
||||||
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
|
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
renderer.toneMappingExposure = 1;
|
renderer.toneMappingExposure = 1;
|
||||||
renderer.shadowMap.enabled = true;
|
renderer.shadowMap.enabled = !managedJoints;
|
||||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
||||||
element.appendChild(renderer.domElement);
|
element.appendChild(renderer.domElement);
|
||||||
@@ -151,6 +152,11 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
|||||||
const render = () => {
|
const render = () => {
|
||||||
const state = visualRef.current, config = settings.current;
|
const state = visualRef.current, config = settings.current;
|
||||||
assembly.update(state);
|
assembly.update(state);
|
||||||
|
if (assembly.machine.userData.linuxcncJointState) {
|
||||||
|
element.dataset.machineJointState = JSON.stringify(assembly.machine.userData.linuxcncJointState);
|
||||||
|
element.dataset.machineJointTick = String(state.tick);
|
||||||
|
element.dataset.machineJointInputs = JSON.stringify(state.joints);
|
||||||
|
}
|
||||||
const paths = (config.toolpath ?? state.toolpath) as Segment[];
|
const paths = (config.toolpath ?? state.toolpath) as Segment[];
|
||||||
pathGroup.position.copy(assembly.workOrigin.position);
|
pathGroup.position.copy(assembly.workOrigin.position);
|
||||||
liveGroup.position.copy(assembly.workOrigin.position);
|
liveGroup.position.copy(assembly.workOrigin.position);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { bindJointModel, validateJointModel, type JointModel, type AxisValues } from "./machine-joints";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||||
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
|
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
|
||||||
@@ -18,7 +19,14 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
|
|||||||
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
||||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
|
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
|
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
|
||||||
return { scene: gltf.scene, structure, hash, filename };
|
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||||
|
if (model) {
|
||||||
|
validateJointModel(model); if (model.coordinates !== structure.coordinates) throw new Error("关节 JSON 与 URDF 机型不一致");
|
||||||
|
bindJointModel(gltf.scene, model);
|
||||||
|
}
|
||||||
|
const physicalZero = model?.calibration.physicalZero ?? gltf.scene.userData.linuxcncMachineGeometry?.physicalZero;
|
||||||
|
if (physicalZero && Object.values(physicalZero).some((value) => typeof value !== "number" || !Number.isFinite(value))) throw new Error("物理零点无效");
|
||||||
|
return { scene: gltf.scene, structure, hash, filename, physicalZero };
|
||||||
}
|
}
|
||||||
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record<string, string> }, structure: MachineStructure) {
|
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record<string, string> }, structure: MachineStructure) {
|
||||||
const config = readTrtConfiguration(bundle);
|
const config = readTrtConfiguration(bundle);
|
||||||
@@ -68,11 +76,22 @@ export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: {
|
|||||||
export function buildImportedMachine(asset: ImportedMachine) {
|
export function buildImportedMachine(asset: ImportedMachine) {
|
||||||
const scene = asset.scene.clone(true);
|
const scene = asset.scene.clone(true);
|
||||||
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
|
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
|
||||||
|
const jointModel = scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||||
|
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
|
||||||
|
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
|
||||||
const managed = scene.userData.linuxcncMachineGeometry;
|
const managed = scene.userData.linuxcncMachineGeometry;
|
||||||
if (managed?.schema === "linuxcnc.machine-manager/1") {
|
if (managed?.schema === "linuxcnc.machine-manager/1") {
|
||||||
const clean = (value: number) => Object.is(value, -0) ? 0 : value;
|
const clean = (value: number) => Object.is(value, -0) ? 0 : value;
|
||||||
const effective = { ...managed, cOriginMm: [asset.structure.offsets.x_rot_point, asset.structure.offsets.y_rot_point, asset.structure.offsets.z_rot_point].map(clean), tiltOffsetMm: [asset.structure.offsets.x_offset, asset.structure.offsets.y_offset, asset.structure.offsets.z_offset].map(clean), physicalZero: { ...(asset.physicalZero ?? managed.physicalZero) } };
|
const effective = { ...managed, cOriginMm: [asset.structure.offsets.x_rot_point, asset.structure.offsets.y_rot_point, asset.structure.offsets.z_rot_point].map(clean), tiltOffsetMm: [asset.structure.offsets.x_offset, asset.structure.offsets.y_offset, asset.structure.offsets.z_offset].map(clean), physicalZero: { ...(asset.physicalZero ?? managed.physicalZero) } };
|
||||||
scene.userData.linuxcncMachineGeometry = effective;
|
scene.userData.linuxcncMachineGeometry = effective;
|
||||||
|
if (jointModel) {
|
||||||
|
const effectiveJoints: JointModel = { ...jointModel, calibration: { cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3, physicalZero: { ...physicalZero } } };
|
||||||
|
scene.userData.linuxcncJointModel = effectiveJoints;
|
||||||
|
scene.traverse((node) => {
|
||||||
|
if (node.userData.linuxcncJointModel) node.userData.linuxcncJointModel = effectiveJoints;
|
||||||
|
if (node.userData.linuxcncMachineGeometry) node.userData.linuxcncMachineGeometry = effective;
|
||||||
|
});
|
||||||
|
}
|
||||||
const base = scene.getObjectByName(asset.structure.base); if (base) base.userData.linuxcncMachineGeometry = effective;
|
const base = scene.getObjectByName(asset.structure.base); if (base) base.userData.linuxcncMachineGeometry = effective;
|
||||||
}
|
}
|
||||||
// Include fixed work-frame joints as well as moving pivots. The source GLB
|
// Include fixed work-frame joints as well as moving pivots. The source GLB
|
||||||
@@ -98,11 +117,12 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
|||||||
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
|
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
|
||||||
for (const { joint, node: link, axis } of moving) {
|
for (const { joint, node: link, axis } of moving) {
|
||||||
const coordinate = joint.coordinate.toLowerCase() as "x" | "y" | "z" | "a" | "b" | "c";
|
const coordinate = joint.coordinate.toLowerCase() as "x" | "y" | "z" | "a" | "b" | "c";
|
||||||
const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(asset.physicalZero?.[coordinate] ?? 0);
|
const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(physicalZero[coordinate] ?? 0);
|
||||||
link.position.fromArray(joint.origin); link.quaternion.identity();
|
link.position.fromArray(joint.origin); link.quaternion.identity();
|
||||||
if (joint.type === "prismatic") link.position.addScaledVector(axis, value / 1000);
|
if (joint.type === "prismatic") link.position.addScaledVector(axis, value / 1000);
|
||||||
else link.quaternion.setFromAxisAngle(axis, value * Math.PI / 180);
|
else link.quaternion.setFromAxisAngle(axis, value * Math.PI / 180);
|
||||||
}
|
}
|
||||||
|
if (jointDriver) jointDriver.update(Object.fromEntries([...asset.structure.coordinates].map((axis, i) => [axis, state.joints[i]])) as AxisValues, physicalZero);
|
||||||
const length = state.offsets.tool_offset / 1000;
|
const length = state.offsets.tool_offset / 1000;
|
||||||
toolTip.position.set(0, 0, -length);
|
toolTip.position.set(0, 0, -length);
|
||||||
cutter.visible = Math.abs(length) > 1e-9; cutter.scale.y = Math.abs(length); cutter.position.z = -length / 2;
|
cutter.visible = Math.abs(length) > 1e-9; cutter.scale.y = Math.abs(length); cutter.position.z = -length / 2;
|
||||||
@@ -110,6 +130,7 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
|||||||
// the master-tool difference along local C-Z, while physical pivots stay fixed.
|
// the master-tool difference along local C-Z, while physical pivots stay fixed.
|
||||||
workOrigin.position.copy(originalWork); workOrigin.position.z -= length;
|
workOrigin.position.copy(originalWork); workOrigin.position.z -= length;
|
||||||
machine.updateMatrixWorld(true);
|
machine.updateMatrixWorld(true);
|
||||||
|
if (jointDriver) machine.userData.linuxcncJointState = jointDriver.state();
|
||||||
};
|
};
|
||||||
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
|
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
|
||||||
}
|
}
|
||||||
|
|||||||
88
web/react/src/machine-joints.ts
Normal file
88
web/react/src/machine-joints.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
import type { Vec3 } from "./machine-urdf";
|
||||||
|
|
||||||
|
export type CncAxis = "X" | "Y" | "Z" | "A" | "B" | "C";
|
||||||
|
export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z"; sign: 1 | -1 };
|
||||||
|
export type AxisValues = Partial<Record<CncAxis, number>>;
|
||||||
|
export type JointZero = { positionMm: Vec3; quaternion: [number, number, number, number]; scale: Vec3 };
|
||||||
|
export type JointRecord = {
|
||||||
|
id: string; parentId: string | null; name: string; role: string;
|
||||||
|
source: { file: string; hash: string; path: string };
|
||||||
|
zero: JointZero;
|
||||||
|
joint: (JointDof & { dof: 1; type: "prismatic" | "revolute"; unit: "mm" | "deg"; frame: "parent" }) | null;
|
||||||
|
};
|
||||||
|
export type JointModel = {
|
||||||
|
schema: "linuxcnc.joint-model/1";
|
||||||
|
coordinates: "XYZAC" | "XYZBC";
|
||||||
|
units: { length: "mm"; angle: "deg"; gltfLength: "m" };
|
||||||
|
controller: { source: "linuxcnc-wasm.visual.joints"; axisOrder: CncAxis[] };
|
||||||
|
calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: Record<string, number> };
|
||||||
|
axisMap: Partial<Record<CncAxis, string>>;
|
||||||
|
nodes: JointRecord[];
|
||||||
|
};
|
||||||
|
export const JOINT_MODEL_SCHEMA = "linuxcnc.joint-model/1";
|
||||||
|
export const isLinear = (axis: CncAxis) => "XYZ".includes(axis);
|
||||||
|
export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z" } as const)[axis], sign: 1 });
|
||||||
|
export const jointZero = (node: THREE.Object3D): JointZero => ({ positionMm: node.position.toArray().map((v) => v * 1000) as Vec3, quaternion: node.quaternion.toArray(), scale: node.scale.toArray() });
|
||||||
|
|
||||||
|
// Absolute evaluation from a stored zero pose: no incremental accumulation.
|
||||||
|
// Direction is expressed in the parent frame; a rotation leaves the pivot at p0.
|
||||||
|
export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | null, value: number, physicalZero = 0) {
|
||||||
|
if (dof && ![value, physicalZero].every(Number.isFinite)) throw new Error(`轴 ${dof.axis} 位置无效`);
|
||||||
|
node.position.fromArray(zero.positionMm).multiplyScalar(.001);
|
||||||
|
node.quaternion.fromArray(zero.quaternion); node.scale.fromArray(zero.scale);
|
||||||
|
if (dof) {
|
||||||
|
const direction = new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
|
||||||
|
const q = dof.sign * (value - physicalZero);
|
||||||
|
if (isLinear(dof.axis)) node.position.addScaledVector(direction, q / 1000);
|
||||||
|
else node.quaternion.premultiply(new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q)));
|
||||||
|
}
|
||||||
|
node.updateMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateJointModel(model: JointModel, complete = true) {
|
||||||
|
const fail = (message: string): never => { throw new Error(`关节配置无效:${message}`); };
|
||||||
|
if (!model || model.schema !== JOINT_MODEL_SCHEMA || !["XYZAC", "XYZBC"].includes(model.coordinates)) fail("schema 或机型错误");
|
||||||
|
if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints" || model.controller.axisOrder.join("") !== model.coordinates) fail("控制器来源、单位或轴顺序错误");
|
||||||
|
if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
|
||||||
|
const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
|
||||||
|
if (!finite(model.calibration?.cOriginMm, 3) || !finite(model.calibration?.tiltOffsetMm, 3) || !model.calibration?.physicalZero || ![...model.coordinates].every((a) => Number.isFinite(model.calibration.physicalZero[a.toLowerCase()]))) fail("标定参数错误");
|
||||||
|
const nodes = new Map<string, JointRecord>(), axes = new Map<CncAxis, string>();
|
||||||
|
for (const record of model.nodes) {
|
||||||
|
if (!record.id || nodes.has(record.id)) fail("节点 ID 缺失或重复");
|
||||||
|
nodes.set(record.id, record);
|
||||||
|
if (!finite(record.zero?.positionMm, 3) || !finite(record.zero?.quaternion, 4) || !finite(record.zero?.scale, 3) || record.zero.scale.some((v) => v <= 0) || Math.abs(record.zero.quaternion.reduce((s, v) => s + v * v, 0) - 1) > 1e-5) fail(`${record.name} 零姿态错误`);
|
||||||
|
const joint = record.joint;
|
||||||
|
if (joint) {
|
||||||
|
if (![...model.coordinates].includes(joint.axis) || joint.dof !== 1 || !["x", "y", "z"].includes(joint.direction) || ![1, -1].includes(joint.sign) || joint.frame !== "parent" || joint.type !== (isLinear(joint.axis) ? "prismatic" : "revolute") || joint.unit !== (isLinear(joint.axis) ? "mm" : "deg")) fail(`${record.name} 单自由度错误`);
|
||||||
|
if (axes.has(joint.axis)) fail(`${joint.axis} 轴重复绑定`);
|
||||||
|
axes.set(joint.axis, record.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const record of model.nodes) {
|
||||||
|
const seen = new Set<string>([record.id]); let parent = record.parentId;
|
||||||
|
while (parent !== null) { if (seen.has(parent) || !nodes.has(parent)) fail("父级不存在或层级循环"); seen.add(parent); parent = nodes.get(parent)!.parentId; }
|
||||||
|
}
|
||||||
|
if (!model.axisMap || Object.keys(model.axisMap).length !== axes.size) fail("轴索引错误");
|
||||||
|
for (const [axis, id] of axes) if (model.axisMap[axis] !== id) fail(`${axis} 轴索引错误`);
|
||||||
|
if (complete && [...model.coordinates].some((axis) => !axes.has(axis as CncAxis))) fail(`请完整绑定 ${model.coordinates} 的五个关节`);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindJointModel(scene: THREE.Object3D, model: JointModel) {
|
||||||
|
validateJointModel(model);
|
||||||
|
const nodes = new Map<string, THREE.Object3D>();
|
||||||
|
scene.traverse((node) => { const id = node.userData.linuxcncNodeId; if (id) { if (nodes.has(id)) throw new Error("关节模型节点 ID 重复"); nodes.set(id, node); } });
|
||||||
|
const bindings = model.nodes.map((record) => {
|
||||||
|
const node = nodes.get(record.id);
|
||||||
|
if (!node || (node.parent?.userData.linuxcncNodeId ?? null) !== record.parentId) throw new Error(`关节节点 ${record.name} 与 JSON 层级不一致`);
|
||||||
|
return { node, record };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
update(values: AxisValues, physicalZero = model.calibration.physicalZero) {
|
||||||
|
for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[record.joint.axis]) || !Number.isFinite(physicalZero[record.joint.axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${record.joint.axis} 轴实时位置或物理零点无效`);
|
||||||
|
for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint, record.joint ? values[record.joint.axis]! : 0, record.joint ? Number(physicalZero[record.joint.axis.toLowerCase()] ?? 0) : 0);
|
||||||
|
},
|
||||||
|
state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [record.joint!.axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||||
|
import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
|
||||||
import type { ImportedMachine } from "./imported-machine";
|
import type { ImportedMachine } from "./imported-machine";
|
||||||
|
|
||||||
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
|
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
|
||||||
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group };
|
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record<string, JointDof>; restoredRoles?: Record<string, MachinePartRole | "">; restoredModel?: JointModel };
|
||||||
export type MachineNodeTransform = { translationMm: Vec3; rotationDeg: Vec3 };
|
export type MachineNodeTransform = { translationMm: Vec3; rotationDeg: Vec3 };
|
||||||
export type MachinePartDraft = MachinePartSource & {
|
export type MachinePartDraft = MachinePartSource & {
|
||||||
role: MachinePartRole | "";
|
role: MachinePartRole | "";
|
||||||
@@ -17,6 +18,7 @@ export type MachinePartDraft = MachinePartSource & {
|
|||||||
nodeParents: Record<string, string | null>;
|
nodeParents: Record<string, string | null>;
|
||||||
nodeRoles: Record<string, MachinePartRole | "">;
|
nodeRoles: Record<string, MachinePartRole | "">;
|
||||||
nodeTransforms: Record<string, MachineNodeTransform>;
|
nodeTransforms: Record<string, MachineNodeTransform>;
|
||||||
|
nodeJoints?: Record<string, JointDof>;
|
||||||
};
|
};
|
||||||
export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; worldOriginMm: Vec3; rotationDeg: Vec3; children: MachineTreeNode[] };
|
export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; worldOriginMm: Vec3; rotationDeg: Vec3; children: MachineTreeNode[] };
|
||||||
export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number };
|
export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||||
@@ -26,7 +28,7 @@ export type ManagedMachineGeometry = {
|
|||||||
cOriginMm: Vec3;
|
cOriginMm: Vec3;
|
||||||
tiltOffsetMm: Vec3;
|
tiltOffsetMm: Vec3;
|
||||||
physicalZero: PhysicalZero;
|
physicalZero: PhysicalZero;
|
||||||
parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean; nodeParents: Record<string, string | null>; nodeRoles: Record<string, MachinePartRole | "">; nodeTransforms: Record<string, MachineNodeTransform> }>;
|
parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole | ""; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean; nodeParents: Record<string, string | null>; nodeRoles: Record<string, MachinePartRole | "">; nodeTransforms: Record<string, MachineNodeTransform>; nodeJoints?: Record<string, JointDof> }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
|
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
|
||||||
@@ -130,6 +132,21 @@ export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Pro
|
|||||||
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
||||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((number) => number.toString(16).padStart(2, "0")).join("");
|
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((number) => number.toString(16).padStart(2, "0")).join("");
|
||||||
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
|
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
|
||||||
|
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||||
|
if (model) {
|
||||||
|
bindJointModel(gltf.scene, model);
|
||||||
|
const assembly = gltf.scene.getObjectByProperty("name", "joint_model_assembly");
|
||||||
|
if (!assembly?.userData.linuxcncVisualAssembly) throw new Error("缺少已保存的关节装配");
|
||||||
|
const scene = assembly.clone(true) as THREE.Group;
|
||||||
|
const records = new Map(model.nodes.map((record) => [record.id, record]));
|
||||||
|
const restoredJoints: Record<string, JointDof> = {}, restoredRoles: Record<string, MachinePartRole | ""> = {};
|
||||||
|
for (const [path, node] of scenePathMap(scene).nodes) {
|
||||||
|
const record = records.get(node.userData.linuxcncNodeId);
|
||||||
|
if (record?.joint) restoredJoints[path] = { axis: record.joint.axis, direction: record.joint.direction, sign: record.joint.sign };
|
||||||
|
if (record?.role) restoredRoles[path] = record.role as MachinePartRole;
|
||||||
|
}
|
||||||
|
return { id: crypto.randomUUID(), filename, hash, scene, restoredJoints, restoredRoles, restoredModel: model };
|
||||||
|
}
|
||||||
return { id: crypto.randomUUID(), filename, hash, scene: gltf.scene };
|
return { id: crypto.randomUUID(), filename, hash, scene: gltf.scene };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +197,7 @@ function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffset
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise<ImportedMachine> {
|
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise<ImportedMachine> {
|
||||||
|
if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
|
||||||
const byRole = validateMachineAssembly(input.coordinates, input.parts);
|
const byRole = validateMachineAssembly(input.coordinates, input.parts);
|
||||||
for (const [axis, value] of Object.entries(input.physicalZero)) if (!Number.isFinite(value)) throw new Error(`${axis.toUpperCase()} 物理零点无效`);
|
for (const [axis, value] of Object.entries(input.physicalZero)) if (!Number.isFinite(value)) throw new Error(`${axis.toUpperCase()} 物理零点无效`);
|
||||||
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm);
|
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm);
|
||||||
@@ -208,3 +226,69 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
|
|||||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signature)))].map((number) => number.toString(16).padStart(2, "0")).join("");
|
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signature)))].map((number) => number.toString(16).padStart(2, "0")).join("");
|
||||||
return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } };
|
return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function draftNodeDof(part: MachinePartDraft, path: string, coordinates: "XYZAC" | "XYZBC"): JointDof | null {
|
||||||
|
if (part.nodeJoints !== undefined) return part.nodeJoints[path] ?? null;
|
||||||
|
const role = path === "scene" ? part.role : part.nodeRoles?.[path];
|
||||||
|
const axis = role === "tilt" ? coordinates[3] : role && ["x", "y", "z", "c"].includes(role) ? role.toUpperCase() : "";
|
||||||
|
return axis ? defaultDof(axis as CncAxis) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedInput = { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||||
|
|
||||||
|
export function makeJointAssembly(input: ManagedInput, deepClone = false) {
|
||||||
|
if (!input.parts.length) throw new Error("请先导入机床模型");
|
||||||
|
const assembly = new THREE.Group(); assembly.name = "joint_model_assembly"; assembly.userData.linuxcncVisualAssembly = true;
|
||||||
|
const placements = new Map<string, THREE.Group>(), records: JointRecord[] = [];
|
||||||
|
for (const part of input.parts) {
|
||||||
|
if (placements.has(part.id)) throw new Error("部件 ID 重复");
|
||||||
|
finite3(part.translationMm, "部件位置"); finite3(part.rotationDeg, "部件姿态"); finite3(part.originMm, "装配原点");
|
||||||
|
const placement = new THREE.Group(); placement.name = `component_${part.id}`; placement.visible = part.visible;
|
||||||
|
placement.position.fromArray(part.translationMm).multiplyScalar(.001); placement.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3);
|
||||||
|
placements.set(part.id, placement); assembly.add(placement);
|
||||||
|
const { scene, nodes } = configureMachinePartScene(deepClone ? cloneMachinePartScene(part.scene) : part.scene.clone(true), part);
|
||||||
|
scene.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); placement.add(scene);
|
||||||
|
const addRecord = (node: THREE.Object3D, path: string, role: string, dof: JointDof | null) => {
|
||||||
|
const id = `${part.id}:${path}`; node.userData.linuxcncNodeId = id;
|
||||||
|
const joint: JointRecord["joint"] = dof ? { ...dof, dof: 1, type: "XYZ".includes(dof.axis) ? "prismatic" : "revolute", unit: "XYZ".includes(dof.axis) ? "mm" : "deg", frame: "parent" } : null;
|
||||||
|
node.userData.linuxcncJoint = joint;
|
||||||
|
records.push({ id, parentId: null, name: path === "scene" ? part.filename : node.name, role, source: { file: part.filename, hash: part.hash, path }, zero: jointZero(node), joint });
|
||||||
|
};
|
||||||
|
addRecord(placement, "scene", part.role, draftNodeDof(part, "scene", input.coordinates));
|
||||||
|
for (const [path, node] of nodes) addRecord(node, path === "scene" ? "source-scene" : path, part.nodeRoles?.[path] ?? "", path === "scene" ? null : draftNodeDof(part, path, input.coordinates));
|
||||||
|
}
|
||||||
|
for (const part of input.parts) if (part.parentId) {
|
||||||
|
const parent = placements.get(part.parentId); if (!parent) throw new Error("父部件不存在");
|
||||||
|
let cursor: string | null = part.parentId; const seen = new Set([part.id]);
|
||||||
|
while (cursor) { if (seen.has(cursor)) throw new Error("部件层级循环"); seen.add(cursor); cursor = input.parts.find((p) => p.id === cursor)?.parentId ?? null; }
|
||||||
|
parent.add(placements.get(part.id)!);
|
||||||
|
}
|
||||||
|
const recordMap = new Map(records.map((record) => [record.id, record]));
|
||||||
|
assembly.traverse((node) => { const record = recordMap.get(node.userData.linuxcncNodeId); if (record) record.parentId = node.parent?.userData.linuxcncNodeId ?? null; });
|
||||||
|
const model: JointModel = { schema: "linuxcnc.joint-model/1", coordinates: input.coordinates, units: { length: "mm", angle: "deg", gltfLength: "m" }, controller: { source: "linuxcnc-wasm.visual.joints", axisOrder: [...input.coordinates] as CncAxis[] }, calibration: { cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero } }, nodes: records, axisMap: Object.fromEntries(records.filter((r) => r.joint).map((r) => [r.joint!.axis, r.id])) };
|
||||||
|
validateJointModel(model, false);
|
||||||
|
return { assembly, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildJointManagedMachine(input: ManagedInput): Promise<ImportedMachine> {
|
||||||
|
// Validate using shallow transform clones before allocating deep runtime resources.
|
||||||
|
validateJointModel(makeJointAssembly(input).model);
|
||||||
|
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm), structure = parseMachineUrdf(urdf);
|
||||||
|
const { assembly, model } = makeJointAssembly(input, true);
|
||||||
|
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
|
||||||
|
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
|
||||||
|
scene.userData.linuxcncJointModel = model;
|
||||||
|
const geometry: ManagedMachineGeometry = { schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero }, parts: input.parts.map(({ scene: _scene, restoredJoints: _j, restoredRoles: _r, restoredModel: _m, ...part }) => JSON.parse(JSON.stringify(part))) };
|
||||||
|
scene.userData.linuxcncMachineGeometry = geometry;
|
||||||
|
const frame = new THREE.Group(); frame.name = "urdf_frame"; frame.rotation.x = -Math.PI / 2; scene.add(frame);
|
||||||
|
const links = Object.fromEntries(MACHINE_PART_ROLES.map((role) => { const node = new THREE.Group(); node.name = role; node.userData.link = role; return [role, node]; })) as Record<MachinePartRole, THREE.Group>;
|
||||||
|
frame.add(links.base); links.base.add(links.x, links.tilt, assembly); links.x.add(links.y); links.y.add(links.z); links.z.add(links.tool); links.tilt.add(links.c); links.c.add(links.work);
|
||||||
|
for (const joint of structure.joints) links[joint.child as MachinePartRole].position.fromArray(joint.origin);
|
||||||
|
// The existing URDF frames retain the controller's tool/path reference. Only
|
||||||
|
// the explicit JSON bindings drive the visual assembly, preventing double motion.
|
||||||
|
links.base.userData.linuxcncJointModel = model; links.base.userData.linuxcncMachineGeometry = geometry;
|
||||||
|
const bases = new Set(model.nodes.filter((n) => n.role === "base").map((n) => n.id));
|
||||||
|
assembly.traverse((node) => { if (bases.has(node.userData.linuxcncNodeId)) { node.userData.linuxcncJointModel = model; node.userData.linuxcncMachineGeometry = geometry; } });
|
||||||
|
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ model, geometry }))))].map((v) => v.toString(16).padStart(2, "0")).join("");
|
||||||
|
return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user