diff --git a/coverage/glb-machine-manager/workbench/browser.report.json b/coverage/glb-machine-manager/workbench/browser.report.json new file mode 100644 index 0000000..bd37c9e --- /dev/null +++ b/coverage/glb-machine-manager/workbench/browser.report.json @@ -0,0 +1,14 @@ +{ + "report_id": "GLB-MGR-E14", + "status": "verified", + "url": "http://127.0.0.1:5180/", + "checks": [ + "search expands real nested nodes; axis overview locates selection; binding undo/redo", + "empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset", + "TRT forbidden offset blocks export before build; calibration undo/redo", + "view presets and selected fitting; deletion undo restores model with all axis bindings", + "failed batch import leaves existing assembly intact", + "live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow" + ], + "errors": [] +} diff --git a/coverage/glb-machine-manager/workbench/desktop.png b/coverage/glb-machine-manager/workbench/desktop.png new file mode 100644 index 0000000..d1bbf39 Binary files /dev/null and b/coverage/glb-machine-manager/workbench/desktop.png differ diff --git a/coverage/glb-machine-manager/workbench/viewport-1154.png b/coverage/glb-machine-manager/workbench/viewport-1154.png new file mode 100644 index 0000000..a35722b Binary files /dev/null and b/coverage/glb-machine-manager/workbench/viewport-1154.png differ diff --git a/coverage/glb-machine-manager/workbench/viewport-720.png b/coverage/glb-machine-manager/workbench/viewport-720.png new file mode 100644 index 0000000..6897c68 Binary files /dev/null and b/coverage/glb-machine-manager/workbench/viewport-720.png differ diff --git a/docs/glb-machine-manager/03-推进台账.md b/docs/glb-machine-manager/03-推进台账.md index b821719..ea396b2 100644 --- a/docs/glb-machine-manager/03-推进台账.md +++ b/docs/glb-machine-manager/03-推进台账.md @@ -92,3 +92,12 @@ - App 快照接入对话框实时跟随;运行模型通过实际 WASM 关节名映射驱动,并发布同 tick 的轴输入/模型变换诊断。 - 大型关节模型运行关闭 MSAA 和实时阴影,减轻软件 WebGL 绘制开销。 - 数学/JSON 校验、八部件 AC/BC 回归与完整 MILL700 AC/BC 实时/导出往返验证均通过;每机型 8 个不同 tick,五个轴均变化,浏览器 warning/error=0。MGR-17 verified;证据 E13。 + +## R12 / 2026-09-21 / 工程化工作台体验 + +- 三维管理器改为固定预览区 + 独立编辑区:结构与关节、运动验证、机床标定三个页签;窄窗口和矮窗口保持底部操作区可见。 +- 增加节点搜索、展开/折叠、选择后滚动定位、XYZABC 轴绑定总览和整机/选中/正视/俯视按钮。 +- 增加 30 步撤销/重做;删除、导入、轴绑定、方向、物理零点、标定和显隐变化都进入草稿历史。实时跟随锁定编辑,退出后恢复编辑。 +- 数值输入支持空值/非法值标红,错误不会写入状态;AC/BC 禁止沿倾转轴的非法偏置;批量导入失败保持现有装配。 +- 单自由度输入提供步长 `0.1/1/10` 和本轴归零,所有运动仍使用父级方向和物理零点。 +- E14 工作台浏览器验收通过:搜索、总览定位、撤销/重做、错误输入、标定校验、视图、删除恢复、导入失败保护、实时锁定和 1440/1154/720 视口无溢出。 diff --git a/docs/glb-machine-manager/04-任务矩阵.md b/docs/glb-machine-manager/04-任务矩阵.md index 95243d9..375305f 100644 --- a/docs/glb-machine-manager/04-任务矩阵.md +++ b/docs/glb-machine-manager/04-任务矩阵.md @@ -20,3 +20,4 @@ |MGR-16 部件节点相对父级变换|verified|MGR-05,MGR-14|编辑树中当前节点的“部件平移/部件旋转”后,该节点相对父级的位置和姿态改变;父节点与兄弟节点不被误改;子节点继承;预览、导出和 AC/BC 应用保持一致| |MGR-17 单自由度与运行轴映射|verified|MGR-16|每节点固定或唯一 XYZABC 自由度;单值编辑;JSON 保存到场景/底座;单 GLB 完整应用;导出往返与 AC/BC 实时轴映射验收| +|MGR-18 工程化工作台体验|verified|MGR-17|层级搜索与定位、绑定总览、撤销/重做、输入校验、步骤页签、视图预设、单轴步进、实时跟随锁定、响应式布局和失败导入保护通过| diff --git a/docs/glb-machine-manager/05-验收证据.md b/docs/glb-machine-manager/05-验收证据.md index 85295df..3f200ca 100644 --- a/docs/glb-machine-manager/05-验收证据.md +++ b/docs/glb-machine-manager/05-验收证据.md @@ -128,3 +128,11 @@ |`joint-dof/xyzbc-live.png`|`51fcd171ac07ad67ef6c1d733c098f24e1938474e1de2c1322701eb33ed49647`| - E13 发布:功能代码 `d848864`,线上资源 `assets/index-DZs5acdt.js`,`https://49.232.250.35:58015/`。线上八部件 AC/BC 单自由度配置、GLB 导出应用与 BC WASM 程序回归 verified、浏览器错误 0;MILL700 完整五轴/JSON 往返验收为同一生产构建的本地浏览器测试。发布报告 `joint-dof/deployment.report.json`(`GLB-MGR-E13-CLOUD`)。 + +## E14 / R12 / 工程化工作台体验 + +- 命令:`node scripts/test_machine_manager_workbench.mjs`。 +- report_id:`GLB-MGR-E14`;报告:`coverage/glb-machine-manager/workbench/browser.report.json`;SHA-256:`5be35a6bac22b90854f6d001400ec48b92f2060e2b2edaeb8f80e5fb3bc4eb`。 +- 覆盖:深层节点搜索、搜索展开、轴总览定位、撤销/重做、空数值输入校验、TRT 非法偏置阻止应用/导出、视图预设、删除撤销、坏文件批量导入保护、实时跟随锁定、桌面/矮窗口/窄窗口布局。 +- 截图:`workbench/desktop.png` SHA-256 `876c2cf6d1edf7093752510561973117f8de9b99ac48a14654aee9527b07d533`;`workbench/viewport-1154.png` SHA-256 `90a32870ff473e05aee4caeb2cc84cc0c3144882463893b2ac5fc2e4b95efbc1`;`workbench/viewport-720.png` SHA-256 `7ca1f6010a51274659d663fb72f85c5216de869746976db33c5c007e47a6d964`。 +- 数学历史测试:`node scripts/test_machine_manager_history.mjs`,`GLB-MGR-E14-HISTORY` verified。 diff --git a/docs/glb-machine-manager/README.md b/docs/glb-machine-manager/README.md index 2199e61..29be793 100644 --- a/docs/glb-machine-manager/README.md +++ b/docs/glb-machine-manager/README.md @@ -11,7 +11,7 @@ 现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。 -当前状态: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/`。 +当前状态:MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R12,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14` 位于 `coverage/glb-machine-manager/`。 文档: diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.md b/docs/linuxcnc-axis-web-control-panel-source-logic.md index 73a1fdd..28a6c93 100644 --- a/docs/linuxcnc-axis-web-control-panel-source-logic.md +++ b/docs/linuxcnc-axis-web-control-panel-source-logic.md @@ -1075,3 +1075,7 @@ 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. + +### MGR-18 machine manager workbench controls + +`machine-manager-undo`, `machine-manager-redo`, `machine-manager-draft-state`, `machine-tree-search`, `machine-tree-expand`, `machine-tree-collapse`, `machine-manager-validation`, `machine-manager-tab-structure`, `machine-manager-tab-motion`, `machine-manager-tab-calibration`, `machine-view-fit`, `machine-view-selected`, `machine-view-front`, `machine-view-top`, `machine-joint-step-minus`, `machine-joint-step-plus`, `machine-joint-reset`: bounded draft history, searchable hierarchy, axis binding overview, explicit validation, view presets, single-joint step jog and responsive workbench layout. diff --git a/scripts/test_axis_control_inventory.mjs b/scripts/test_axis_control_inventory.mjs index f124e24..38236b3 100644 --- a/scripts/test_axis_control_inventory.mjs +++ b/scripts/test_axis_control_inventory.mjs @@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([ const controlIds = [ "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", - "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", + "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-step-minus", "machine-joint-step-plus", "machine-joint-reset", "machine-joint-follow-live", "machine-joint-live-state", "machine-manager-export-json", "machine-manager-undo", "machine-manager-redo", "machine-manager-draft-state", "machine-tree-search", "machine-tree-expand", "machine-tree-collapse", "machine-manager-validation", "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", "machine-manager-tab-structure", "machine-manager-tab-motion", "machine-manager-tab-calibration", "machine-view-fit", "machine-view-selected", "machine-view-front", "machine-view-top", "manual-home-all", "manual-home-joint-select", "switchkins-0", "switchkins-1", "switchkins-2", "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", diff --git a/scripts/test_machine_joint_dof_browser.mjs b/scripts/test_machine_joint_dof_browser.mjs index 8f1d92f..3513eec 100644 --- a/scripts/test_machine_joint_dof_browser.mjs +++ b/scripts/test_machine_joint_dof_browser.mjs @@ -24,6 +24,7 @@ try { 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) => { + await c("machine-manager-tab-structure").click(); 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); @@ -44,7 +45,8 @@ try { 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(); + await c("machine-manager-tab-motion").click(); await c("machine-test-reset").click(); + await c("machine-manager-tab-calibration").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)); @@ -54,9 +56,9 @@ try { 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 c("machine-manager-tab-structure").click(); 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"); + await choose(names.Z); assert.equal(await c("machine-tree-axis-role").inputValue(), "Z"); assert.equal(await c("machine-joint-sign").inputValue(), "-1"); await c("machine-manager-tab-calibration").click(); 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 }); @@ -92,7 +94,7 @@ M2 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 c("machine-manager-settings").click(); await c("machine-manager-tab-motion").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"); diff --git a/scripts/test_machine_manager_browser.mjs b/scripts/test_machine_manager_browser.mjs index 4ef272b..65a3fe6 100644 --- a/scripts/test_machine_manager_browser.mjs +++ b/scripts/test_machine_manager_browser.mjs @@ -39,9 +39,10 @@ try { await selectRoot(1); await control("machine-joint-value").fill("10"); await page.waitForFunction(() => Math.abs(JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms).X.localMm[0] - 10) < 1e-7); assert.equal(await control("machine-manager-translation").count(), 0); - await control("machine-test-reset").click(); + await control("machine-manager-tab-motion").click(); await control("machine-test-reset").click(); await control("machine-manager-tab-structure").click(); await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname }); report.checks.push("eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X"); + await control("machine-manager-tab-calibration").click(); 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 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"); @@ -64,7 +65,8 @@ try { 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 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"); + await control("machine-manager-tab-structure").click(); await selectRoot(5); await control("machine-tree-axis-role").selectOption("B"); + await control("machine-manager-tab-calibration").click(); 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 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(); diff --git a/scripts/test_machine_manager_history.mjs b/scripts/test_machine_manager_history.mjs new file mode 100644 index 0000000..9e4de3c --- /dev/null +++ b/scripts/test_machine_manager_history.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { machineDraftHistory } from '../web/react/src/machine-manager-history.ts'; +const draft = { parts: [], cOriginMm:[0,0,0], tiltOffsetMm:[0,0,0], physicalZero:{x:0,y:0,z:0,a:0,b:0,c:0} }; +let state = { current:draft, past:[], future:[] }; +const edit = (n) => ({ type:'edit', change:(d) => ({...d, cOriginMm:[n,0,0]}) }); +state = machineDraftHistory(state, edit(1)); state = machineDraftHistory(state,edit(1)); assert.equal(state.past.length,1); +state = machineDraftHistory(state,{type:'undo'}); assert.equal(state.current.cOriginMm[0],0); +state = machineDraftHistory(state,{type:'redo'}); assert.equal(state.current.cOriginMm[0],1); +state = machineDraftHistory(state,{type:'undo'}); state = machineDraftHistory(state,edit(2)); assert.equal(state.future.length,0); +for(let i=3;i<45;i++) state=machineDraftHistory(state,edit(i)); assert.equal(state.past.length,30); +const current=state.current; state=machineDraftHistory(state,{type:'clear'}); assert.equal(state.current,current); assert.equal(state.past.length,0); +console.log(JSON.stringify({report_id:'GLB-MGR-E14-HISTORY',status:'verified',checks:['undo/redo','duplicate edits ignored','new edit discards redo','30-entry bound','clear preserves draft']})); diff --git a/scripts/test_machine_manager_workbench.mjs b/scripts/test_machine_manager_workbench.mjs new file mode 100644 index 0000000..345f4d9 --- /dev/null +++ b/scripts/test_machine_manager_workbench.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +const module = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? '/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js')); +const browser = await (module.default ?? module).chromium.launch({ headless: true, executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const output = new URL('../coverage/glb-machine-manager/workbench/', import.meta.url); await mkdir(output, { recursive: true }); +const report = { report_id: 'GLB-MGR-E14', status: 'running', url: process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5180/', checks: [], errors: [] }; +page.on('pageerror', (e) => report.errors.push(e.message)); page.on('console', (m) => { if (['error','warning'].includes(m.type())) report.errors.push(m.text()); }); +const expectValue = async (locator, value) => { await locator.evaluate((el, expected) => new Promise((resolve, reject) => { const end = Date.now() + 5000; const check = () => Number(el.value) === expected ? resolve(true) : Date.now() > end ? reject(new Error(`value ${el.value} != ${expected}`)) : setTimeout(check, 20); check(); }), value); }; +const c = (id) => page.locator(`[data-axis-control="${id}"]`); +const preview = page.locator('[data-machine-manager-preview]'); +const choose = async (name) => { await c('machine-manager-tab-structure').click(); await c('machine-tree-search').fill(name); const n = c('machine-tree-node').filter({ has: page.locator('span').filter({ hasText: new RegExp(`^${name}$`) }) }).first(); await n.click(); }; +try { + await page.goto(report.url); await page.waitForFunction(() => document.querySelector('main.axis-app')?.dataset.axisProgramLoading === 'false' && document.querySelector('main.axis-app')?.dataset.axisStateSource === 'linuxcnc-wasm'); + await c('machine-manager-settings').click(); assert.equal(await page.locator('.machine-preview-empty').isVisible(), true); + const bytes = await readFile(new URL('../MILL700-U_1.glb', import.meta.url)); + const upload = { name: 'MILL700-U_1.glb', mimeType: 'model/gltf-binary', buffer: bytes }; + await c('machine-manager-files').setInputFiles(upload); await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132); + await choose('MILL700-A-主轴-JT3'); await c('machine-tree-axis-role').selectOption('Z'); + await c('machine-manager-undo').click(); assert.equal(await c('machine-tree-axis-role').inputValue(), ''); + await c('machine-manager-redo').click(); assert.equal(await c('machine-tree-axis-role').inputValue(), 'Z'); + await c('machine-axis-locate-z').click(); assert.equal(await c('machine-tree-search').inputValue(), ''); assert.match(await page.locator('.machine-part-editor h3').textContent(), /主轴-JT3/); + assert.equal(await c('machine-tree-node').filter({ hasText:'MILL700-A-主轴-JT3' }).first().isVisible(), true); + report.checks.push('search expands real nested nodes; axis overview locates selection; binding undo/redo'); + await c('machine-joint-value').fill('12.5'); const local = JSON.parse(await preview.getAttribute('data-machine-manager-selected-transform')).local; + await c('machine-joint-value').fill(''); assert.equal(await c('machine-joint-value').getAttribute('aria-invalid'), 'true'); assert.match(await c('machine-manager-validation').textContent(), /修正/); + assert.deepEqual(JSON.parse(await preview.getAttribute('data-machine-manager-selected-transform')).local, local); + await c('machine-joint-value').fill('0'); assert.equal(await c('machine-joint-value').getAttribute('aria-invalid'), 'false'); + for (let i=0; i<4; i++) { + await choose('MILL700-A-主轴-JT2'); await choose('MILL700-A-主轴-JT3'); + await c('machine-joint-value').fill('30'); await expectValue(c('machine-joint-value'),30); + const v=JSON.parse(await preview.getAttribute('data-machine-manager-selected-transform')).local; + assert.ok(Math.abs(v[2] - .3989253330230713)<1e-7, 'switching selection and repeated fill must not concatenate input'); + } + await c('machine-joint-step-plus').click(); await expectValue(c('machine-joint-value'),31); + await c('machine-joint-step-minus').click(); await expectValue(c('machine-joint-value'),30); + await c('machine-joint-reset').click(); await expectValue(c('machine-joint-value'),0); + report.checks.push('empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset'); + for (const [axis, name] of [['X','MILL700-A-主轴-JT1'],['Y','MILL700-A-主轴-JT2'],['A','MILL700-A-工作台-JT1'],['C','MILL700-A-工作台-JT2']]) { await choose(name); await c('machine-tree-axis-role').selectOption(axis); } + assert.match(await c('machine-manager-validation').textContent(), /检查通过/); assert.equal(await c('machine-manager-apply').isEnabled(), true); + await c('machine-manager-tab-calibration').click(); const off = page.locator('fieldset.machine-vector').filter({ hasText:'A 到 C 轴偏置' }); + await off.getByLabel('X', { exact:true }).fill('5'); assert.match(await c('machine-manager-validation').textContent(), /必须为 0/); assert.equal(await c('machine-manager-export').isDisabled(), true); + await c('machine-manager-undo').click(); await expectValue(off.getByLabel('X', { exact:true }), 0); + await off.getByLabel('Y', { exact:true }).fill('18'); await c('machine-manager-undo').click(); await expectValue(off.getByLabel('Y', { exact:true }), 0); await c('machine-manager-redo').click(); await expectValue(off.getByLabel('Y', { exact:true }), 18); + report.checks.push('TRT forbidden offset blocks export before build; calibration undo/redo'); + await c('machine-manager-tab-structure').click(); await c('machine-axis-locate-z').click(); + for (const action of ['fit','selected','front','top']) { await c(`machine-view-${action}`).click(); assert.equal(await preview.getAttribute('data-machine-manager-view'),action); } + await c('machine-view-fit').click(); await c('machine-manager-show-origin').uncheck(); await c('machine-manager-show-origin').check(); + await c('machine-manager-delete').click(); await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '0'); + await c('machine-manager-undo').click(); await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '1'); + assert.equal(await c('machine-manager-apply').isEnabled(), true); await c('machine-axis-locate-z').click(); assert.equal(await c('machine-tree-axis-role').inputValue(), 'Z'); + report.checks.push('view presets and selected fitting; deletion undo restores model with all axis bindings'); + const validationBefore = await c('machine-manager-validation').textContent(); + await c('machine-manager-files').setInputFiles([upload,{ name:'bad.glb',mimeType:'model/gltf-binary',buffer:Buffer.from('bad') }]); + await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false'); + assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'1'); assert.equal(await c('machine-manager-validation').textContent(),validationBefore); + report.checks.push('failed batch import leaves existing assembly intact'); + await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').check(); assert.equal(await c('machine-manager-undo').isDisabled(),true); + await c('machine-manager-tab-structure').click(); assert.equal(await c('machine-tree-axis-role').isDisabled(),true); assert.equal(await c('machine-manager-delete').isDisabled(),true); + await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').uncheck(); + await c('machine-manager-tab-structure').click(); await c('machine-view-fit').click(); + await page.screenshot({ path:new URL('desktop.png',output).pathname }); + for (const [width,height] of [[1154,520],[720,900]]) { + await page.setViewportSize({width,height}); await page.waitForTimeout(300); + const bounds = await page.locator('.machine-manager-dialog').boundingBox(); assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x+bounds.width <= width+1 && bounds.y+bounds.height <=height+1); + const footer = await page.locator('.machine-manager-footer').boundingBox(); assert.ok(footer.y+footer.height <=height); + const buffer = (await preview.getAttribute('data-machine-manager-buffer-size')).split('x').map(Number); assert.ok(buffer.every((n)=>n>0 && n<=4096)); + assert.equal(await page.locator('.machine-manager-dialog').evaluate((el)=>el.scrollWidth<=el.clientWidth+1),true); + await page.screenshot({ path:new URL(`viewport-${width}.png`,output).pathname }); + } + report.checks.push('live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow'); + await page.setViewportSize({width:1440,height:900}); + await page.keyboard.press('Escape'); await c('machine-manager-settings').click(); await c('machine-axis-locate-z').click(); assert.equal(await c('machine-tree-axis-role').inputValue(),'Z'); + assert.deepEqual(report.errors, []); report.status='verified'; +} catch(e) { report.status='failed'; report.error=e.stack; 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)); diff --git a/web/react/src/MachineManagerInputs.tsx b/web/react/src/MachineManagerInputs.tsx new file mode 100644 index 0000000..d942898 --- /dev/null +++ b/web/react/src/MachineManagerInputs.tsx @@ -0,0 +1,14 @@ +import { useEffect, useLayoutEffect, useState } from "react"; + +type Props = { value: number; onChange: (value: number) => void; label?: string; disabled?: boolean; control?: string; fieldId: string; onValidity: (field: string, valid: boolean) => void }; +export function MachineNumberInput({ value, onChange, label, disabled, control, fieldId, onValidity }: Props) { + const [text, setText] = useState(String(value)), [invalid, setInvalid] = useState(false); + useLayoutEffect(() => { setText(Number.isFinite(value) ? String(Number(value.toFixed(6))) : ""); setInvalid(false); onValidity(fieldId, true); }, [value, fieldId, onValidity]); + useEffect(() => () => onValidity(fieldId, true), [fieldId, onValidity]); + return { + const raw = event.target.value, next = event.target.valueAsNumber; + const valid = raw.trim() !== "" && Number.isFinite(next); + setText(raw); setInvalid(!valid); onValidity(fieldId, valid); + if (valid) onChange(next); + }} />{invalid && 请输入有效数值}; +} diff --git a/web/react/src/MachineManagerPanel.tsx b/web/react/src/MachineManagerPanel.tsx index 27985de..60b9f79 100644 --- a/web/react/src/MachineManagerPanel.tsx +++ b/web/react/src/MachineManagerPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js"; @@ -8,20 +8,25 @@ import { disposeMachineScene, type ImportedMachine } from "./imported-machine"; import { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager"; import { driveJoint, jointZero, defaultDof, isLinear, type JointDof, type CncAxis } from "./machine-joints"; import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter"; +import { useMachineDraft, draftFingerprint } from "./machine-manager-history"; +import { MachineNumberInput } from "./MachineManagerInputs"; import type { Vec3 } from "./machine-urdf"; type Props = { snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise }; type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number }; type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] }; const zero3 = (): Vec3 => [0, 0, 0]; -const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0; +type EditorTab = "structure" | "motion" | "calibration"; +type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number }; +const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—"; -function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record) => void }) { - type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void }; +function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, command }: { command: PreviewCommand; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record) => void }) { + type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void }; const host = useRef(null); const stateRef = useRef({ parts, selectedId, selectedNodePath, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, testPose, coordinates }; const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState; const [fault, setFault] = useState(""); + useEffect(() => { host.current?.machineManagerView?.(command.action); }, [command]); useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]); useEffect(() => { const element = host.current; if (!element) return; @@ -49,6 +54,18 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test const selectedJointAxes = new THREE.Group(); selectedJointAxes.name = "selected-joint-local-coordinate-system"; selectedJointAxes.visible = false; const jointAxis = (direction: THREE.Vector3, color: number) => selectedJointAxes.add(new THREE.ArrowHelper(direction, new THREE.Vector3(), .28, color, .07, .045)); jointAxis(new THREE.Vector3(1, 0, 0), 0xff3030); jointAxis(new THREE.Vector3(0, 1, 0), 0x35d05b); jointAxis(new THREE.Vector3(0, 0, 1), 0x327cff); scene.add(selectedJointAxes); + element.machineManagerView = (action) => { + root.updateMatrixWorld(true); + const target = action === "selected" ? selectedJointObject : root; + if (!target) return; + const bounds = new THREE.Box3().setFromObject(target); + const center = bounds.isEmpty() ? target.getWorldPosition(new THREE.Vector3()) : bounds.getCenter(new THREE.Vector3()); + const size = bounds.isEmpty() ? .3 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .1); + const direction = action === "top" ? new THREE.Vector3(0, -.001, 1) : action === "front" ? new THREE.Vector3(0, -1, .001) : new THREE.Vector3(1, -.8, .8); + const distance = size / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) * Math.max(1, 1 / camera.aspect) * 1.15; + orbit.enableDamping = false; orbit.target.copy(center); camera.position.copy(center).addScaledVector(direction.normalize(), distance); camera.near = Math.max(.001, distance / 10000); camera.far = Math.max(100, distance * 20); camera.updateProjectionMatrix(); camera.lookAt(center); orbit.update(); orbit.enableDamping = true; + element.dataset.machineManagerView = action; element.dataset.machineManagerCamera = JSON.stringify({ position: camera.position.toArray(), target: orbit.target.toArray(), up: camera.up.toArray() }); dirty = true; + }; const draw = () => { if (contextLost || !renderable) return; renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera); @@ -83,7 +100,7 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test lastParts = nextParts; } - selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = "single-dof-input"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; } + selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = nextParts.find((part) => part.id === nextSelectedId)?.showOrigin ?? true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = "single-dof-input"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; } for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase() as keyof TestPose]); root.updateMatrixWorld(true); let objectCount = 0, meshCount = 0; root.traverse((object) => { let cursor: THREE.Object3D | null = object, isJointHelper = false; while (cursor) { if (cursor === selectedJointAxes) { isJointHelper = true; break; } cursor = cursor.parent; } if (!isJointHelper) { objectCount += 1; if ((object as THREE.Mesh).isMesh) meshCount += 1; } }); const transforms = Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }])); element.dataset.machineManagerSceneObjects = String(objectCount); element.dataset.machineManagerSceneMeshes = String(meshCount); element.dataset.machineManagerRoleTransforms = JSON.stringify(transforms); element.dataset.machineManagerJointTransforms = JSON.stringify(Object.fromEntries(dofNodes.map(({ object, dof }) => [dof.axis, { localMm: object.position.toArray().map((v) => v * 1000), quaternion: object.quaternion.toArray(), world: object.getWorldPosition(new THREE.Vector3()).toArray() }]))); if (selectedJointObject) { element.dataset.machineManagerSelectedJointWorldOrigin = JSON.stringify(selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray()); element.dataset.machineManagerSelectedJointWorldQuaternion = JSON.stringify(selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray()); element.dataset.machineManagerSelectedJointAxesScale = selectedAxesScale.toFixed(6); } const selectedState = selectedJointObject ? { local: selectedJointObject.position.toArray(), localQuaternion: selectedJointObject.quaternion.toArray(), world: selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray() } : null; @@ -96,7 +113,7 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; } const requested = Math.min(globalThis.devicePixelRatio || 1, 1.5); const scale = Math.min(1, maxDimension / (width * requested), maxDimension / (height * requested), Math.sqrt(maxPixels / (width * height * requested * requested))); - const pixelRatio = Math.max(.5, requested * scale); + const pixelRatio = requested * scale; renderer.setPixelRatio(pixelRatio); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); renderable = renderer.domElement.width > 0 && renderer.domElement.height > 0 && renderer.domElement.width <= maxDimension && renderer.domElement.height <= maxDimension; element.dataset.machineManagerPixelRatio = pixelRatio.toFixed(3); element.dataset.machineManagerBufferSize = `${renderer.domElement.width}x${renderer.domElement.height}`; element.dataset.machineManagerRenderState = renderable ? "ready" : "suspended-limit"; dirty = true; draw(); @@ -106,65 +123,96 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test const observer = new ResizeObserver(resize); observer.observe(element); resize(); orbit.addEventListener("change", () => { dirty = true; }); const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render(); - return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; + return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; delete element.machineManagerView; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); grid.geometry.dispose(); for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) material.dispose(); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, []); return
part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && {fault}}
; } -function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode }) { +function TreeNode({ part, node, selected, onSelect, onDrop, extra, query, expansion, locked }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode; query: string; expansion: { all: boolean; serial: number }; locked: boolean }) { const key = `${part.id}:${node.path}`; + const row = useRef(null); + useEffect(() => { if (selected === key) { const frame = requestAnimationFrame(() => row.current?.querySelector(".machine-tree-row")?.scrollIntoView({ block: "nearest", inline: "nearest" })); return () => cancelAnimationFrame(frame); } }, [selected, key]); const [expanded, setExpanded] = useState(node.path === "scene"); + useEffect(() => { if (expansion.serial) setExpanded(expansion.all); }, [expansion]); + useEffect(() => { if (selected.startsWith(`${key}/`)) setExpanded(true); }, [selected, key]); + const matches = (item: MachineTreeNode): boolean => item.name.toLowerCase().includes(query.toLowerCase()) || Boolean(part.nodeJoints?.[item.path]?.axis.toLowerCase().includes(query.toLowerCase())) || item.children.some(matches); + const shown = !query || matches(node) || Boolean(extra); + const isExpanded = Boolean(query) || expanded; const hasChildren = node.children.length > 0 || Boolean(extra); const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]); - return
  • -
    - {hasChildren && } + return
  • ; } export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }: Props) { const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState(""); - const [parts, setParts] = useState([]); const partsRef = useRef(parts); partsRef.current = parts; - const [selectedId, setSelectedId] = useState(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); - const [cOriginMm, setCOriginMm] = useState([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState([0, 0, 0]); - const [physicalZero, setPhysicalZero] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef(null); + const { history, draft, edit, undo, redo, clearHistory } = useMachineDraft(); + const { parts, cOriginMm, tiltOffsetMm, physicalZero } = draft; + const setParts = (change: (current: MachinePartDraft[]) => MachinePartDraft[]) => edit((current) => ({ ...current, parts: change(current.parts) })); + const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value })); + const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value })); + const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value })); + const file = useRef(null); + const [selectedId, setSelectedId] = useState(null), [selectedNodePath, setSelectedNodePath] = useState("scene"); + const [tab, setTab] = useState("structure"), [query, setQuery] = useState(""); + const [expansion, setExpansion] = useState({ all: false, serial: 0 }); + const [command, setCommand] = useState({ action: "fit", serial: 0 }); + const [jogStep, setJogStep] = useState(1); + const [applied, setApplied] = useState(""), [exported, setExported] = useState(""); + const [invalidFields, setInvalidFields] = useState([]); + const validity = useCallback((field: string, valid: boolean) => setInvalidFields((current) => valid ? current.includes(field) ? current.filter((id) => id !== field) : current : current.includes(field) ? current : [...current, field]), []); + const signature = coordinates + draftFingerprint(draft); + const retainedScenes = useRef(new Set()); + // Retain only assets reachable from the bounded undo/redo history. + useEffect(() => { + const keep = new Set([...history.past, history.current, ...history.future].flatMap((d) => d.parts.map((part) => part.scene))); + for (const scene of retainedScenes.current) if (!keep.has(scene as THREE.Group)) disposeMachineScene(scene); + retainedScenes.current = keep; + }, [history]); + useEffect(() => () => { retainedScenes.current.forEach(disposeMachineScene); }, []); const [testPose, setTestPose] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const [motionState, setMotionState] = useState>({}); const motionStateHash = useRef(""); - useEffect(() => () => { for (const part of partsRef.current) disposeMachineScene(part.scene); }, []); - useEffect(() => { setMessage(""); setCOriginMm([0, 0, 0]); setTiltOffsetMm([0, 0, 0]); setPhysicalZero({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); }, [coordinates]); + useEffect(() => { setMessage(""); setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setFollowLive(false); clearHistory(); }, [coordinates, clearHistory]); + useEffect(() => { if (selectedId && !parts.some((part) => part.id === selectedId)) { setSelectedId(parts[0]?.id ?? null); setSelectedNodePath("scene"); } }, [parts, selectedId]); const selected = parts.find((part) => part.id === selectedId) ?? null; const trees = useMemo(() => new Map(parts.map((part) => [part.id, machinePartTree(part)])), [parts]); const findTreeNode = (node: MachineTreeNode | undefined, path: string): MachineTreeNode | null => node ? node.path === path ? node : node.children.map((child) => findTreeNode(child, path)).find(Boolean) ?? null : null; const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null; - const update = (id: string, values: Partial) => 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) =>
    {label} ({unit}){["X", "Y", "Z"].map((axis, index) => )}
    ; + const parentAllowed = (id: string, parentId: string | null) => { let cursor = parentId; const visited = new Set([id]); while (cursor) { if (visited.has(cursor)) return false; visited.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return true; }; + const update = (id: string, values: Partial) => { if (pending || followLive) return; if (values.parentId && !parentAllowed(id, values.parentId)) { setMessage("不能将部件挂到自身或后代节点"); return; } setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); setMessage(""); }; + const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) =>
    {label} ({unit}){["X", "Y", "Z"].map((axis, index) => )}
    ; const importFiles = async (list: FileList | File[] | null) => { - if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…"); + if (!list?.length || pending || followLive) return; setPending(true); setMessage("正在读取 GLB 部件…"); try { - const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name))); + const results = await Promise.allSettled([...list].map(async (item) => { if (!item.name.toLowerCase().endsWith(".glb") || item.size > 32 * 1024 * 1024) throw new Error(`${item.name}:请选择不超过 32 MiB 的 GLB`); try { return await loadMachinePart(await item.arrayBuffer(), item.name); } catch { throw new Error(`${item.name}:模型无法读取,请检查是否为完整的自包含 GLB,或配置是否与模型层级一致`); } })); + const loaded = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []); + const failure = results.find((result) => result.status === "rejected"); + if (failure?.status === "rejected") { loaded.forEach((source) => disposeMachineScene(source.scene)); throw failure.reason; } if (loaded.some((source) => source.restoredModel && source.restoredModel.coordinates !== coordinates)) { loaded.forEach((source) => disposeMachineScene(source.scene)); throw new Error(`导入机型不同,请先切换到 ${loaded.find((source) => source.restoredModel?.coordinates !== coordinates && source.restoredModel)?.restoredModel?.coordinates}`); } - setParts((current) => { - const next = [...current]; + const restored = loaded.find((source) => source.restoredModel)?.restoredModel; + edit((draft) => { + const next = [...draft.parts]; for (const source of loaded) { const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? ""; const parentRole = role ? expectedParentRole(role) : null; const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null; next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} }); } - return next; + return { ...draft, parts: next, ...(restored ? { cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) }; }); - const restored = loaded.find((source) => source.restoredModel)?.restoredModel; - if (restored) { setCOriginMm(restored.calibration.cOriginMm); setTiltOffsetMm(restored.calibration.tiltOffsetMm); setPhysicalZero(restored.calibration.physicalZero as PhysicalZero); setMessage(`已恢复 ${restored.coordinates} 单自由度配置`); } - setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已恢复 ${restored.coordinates} 关节配置和物理零点` : `已导入 ${loaded.length} 个部件`); + setTab("structure"); setQuery(""); setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已恢复 ${restored.coordinates} 关节配置和物理零点` : `已导入 ${loaded.length} 个部件`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); } }; - const remove = (part: MachinePartDraft) => { setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); requestAnimationFrame(() => disposeMachineScene(part.scene)); if (selectedId === part.id) { setSelectedId(null); setSelectedNodePath("scene"); } }; + const remove = (part: MachinePartDraft) => { if (pending || followLive) return; setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); setMessage(`已删除 ${part.filename},可撤销恢复`); if (selectedId === part.id) { setSelectedId(null); setSelectedNodePath("scene"); } }; const selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); }; const reparentTreeNode = (source: { partId: string; path: string }, target: { partId: string; path: string }) => { + if (pending || followLive) return; if (source.partId === target.partId && source.path === target.path) return; if (source.path === "scene") { if (target.path !== "scene") { setMessage("整机/部件根节点只能拖到另一个 GLB 根节点"); return; } @@ -182,12 +230,13 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } }; const apply = async () => { - if (disabled) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null; - try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setMessage("三维机床已应用;请重新回零后运行程序"); } + if (disabled || pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null; + try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setApplied(signature); setMessage("三维机床已应用;请重新回零后运行程序"); } catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); } }; const exportGlb = async () => { + if (pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null; try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); @@ -196,7 +245,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot } const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false }); const url = URL.createObjectURL(new Blob([bytes as ArrayBuffer], { type: "model/gltf-binary" })); const link = document.createElement("a"); link.href = url; link.download = machine.filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); - setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData"); + setExported(signature); setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData"); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { if (machine) disposeMachineScene(machine.scene); setPending(false); } }; @@ -207,44 +256,62 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot } }; const selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null; const setDof = (dof: JointDof | null) => { - if (!selected) return; + if (!selected || pending || followLive) return; if (dof && parts.some((part) => Object.entries(part.nodeJoints ?? {}).some(([path, existing]) => existing.axis === dof.axis && (part.id !== selected.id || path !== selectedNodePath)))) { setMessage(`${dof.axis} 轴已绑定其他节点,请先取消该绑定`); return; } setParts((current) => current.map((part) => { if (part.id !== selected.id) return part; const nodeJoints = { ...(part.nodeJoints ?? {}) }; if (dof) nodeJoints[selectedNodePath] = dof; else delete nodeJoints[selectedNodePath]; return { ...part, nodeJoints }; })); setMessage(""); }; const [followLive, setFollowLive] = useState(false); const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]); - const liveReady = visual.authoritative && visual.machineModel?.coordinate_system === coordinates; - const effectivePose: TestPose = followLive && liveReady ? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - physicalZero[axis.toLowerCase() as keyof PhysicalZero]])) } : testPose; - const validation = useMemo(() => { try { const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]); + const liveReady = visual.joints.every(Number.isFinite) && visual.authoritative && visual.machineModel?.coordinate_system === coordinates; + const heldLivePose = useRef(testPose); + const effectivePose: TestPose = followLive && liveReady ? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - physicalZero[axis.toLowerCase() as keyof PhysicalZero]])) } : followLive ? heldLivePose.current : testPose; + if (followLive && liveReady) heldLivePose.current = effectivePose; + const validation = useMemo(() => { try { if (Math.abs(tiltOffsetMm[coordinates === "XYZAC" ? 0 : 1]) > 1e-9) throw new Error(coordinates === "XYZAC" ? "AC 机床的 A→C 偏置 X 必须为 0" : "BC 机床的 B→C 偏置 Y 必须为 0"); const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]); + const blockingReason = invalidFields.length ? "请修正标红的数值输入" : validation.error; + const bindings = useMemo(() => parts.flatMap((part) => Object.entries(part.nodeJoints ?? {}).map(([path, dof]) => ({ part, path, dof, name: findTreeNode(trees.get(part.id), path)?.name ?? part.filename }))), [parts, trees]); + const moveToBinding = (axis: string) => { const binding = bindings.find((b) => b.dof.axis === axis); setTab("structure"); setQuery(""); if (binding) selectTreeNode(binding.part.id, binding.path); else { setMessage(`在层级树中选择 ${axis} 轴关节,再设置唯一自由度`); } }; + const changeHistory = (direction: "undo" | "redo") => { if (pending || followLive) return; if (direction === "undo") undo(); else redo(); setMessage(direction === "undo" ? "已撤销配置修改" : "已重做配置修改"); }; const exportJson = () => { - if (!validation.model) return; + if (!validation.model || pending || invalidFields.length) return; const url = URL.createObjectURL(new Blob([JSON.stringify(validation.model, null, 2)], { type: "application/json" })); - const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); + const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("关节 JSON 已导出;恢复完整模型请使用组合 GLB"); }; - const poseInput = (axis: keyof TestPose, unit: string) => ; + const poseInput = (axis: keyof TestPose, unit: string) => ; const receiveMotionState = (state: Record) => { const hash = JSON.stringify(state); if (hash !== motionStateHash.current) { motionStateHash.current = hash; setMotionState(state); } }; const selectedDriven = motionState.selected ?? null; const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?.[selectedNodePath] ?? (selectedTreeNode ? { translationMm: selectedTreeNode.originMm, rotationDeg: selectedTreeNode.rotationDeg } : null); - const renderPartTree = (part: MachinePartDraft): React.ReactNode => child.parentId === part.id).map(renderPartTree)} />; + const renderPartTree = (part: MachinePartDraft): React.ReactNode => child.parentId === part.id).map(renderPartTree)} />; return
    setOpen(false)}> -
    {parts.length} 个导入模型 · 每个运动节点一个自由度
    +
    +
    {parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定{!parts.length ? "新建机床" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}
    +
    {[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return ; })}
    - {open && } +
    {([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => )}Z ↑ · X 红 / Y 绿 / Z 蓝
    + {open && } + {!parts.length &&
    建立机床的数字装配

    导入 GLB → 选择关节 → 绑定数控轴 → 验证运动

    支持完整层级 · 单文件不超过 32 MiB
    } +

    左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系

    -

    部件与层级

    {parts.length ?
      {parts.filter((part) => !part.parentId).map(renderPartTree)}
    :

    导入机床或部件后显示 GLB 内部层级。

    }
    - {selected &&

    {selected.filename}

    {selectedTreeNode &&
    {selectedTreeNode.name}节点自身局部原点X 0.000 Y 0.000 Z 0.000装配位置(相对父节点,mm)X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}装配姿态(相对父节点,deg)RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}{selectedDriven && <>当前世界原点(mm)X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}}
    } - {selectedDof && <>} -

    当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}

    - +
    {([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"] ] as const).map(([id, label], index) => )}
    + + +
    -
    {followLive ? liveReady ? `WASM tick ${visual.tick}` : "等待有效机床数据" : "手动验证(相对物理零点)"}

    层级运动测试

    {(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}
    {JSON.stringify(effectivePose)}
    -

    机床标定

    {vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}
    机床物理零点 (XYZ mm / ABC deg){["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => )}
    -
    {message || validation.error || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}
    +
    {blockingReason ? `配置检查:${blockingReason}` : "配置检查通过 · 五轴绑定完整"}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && }
    +
    {message || "关闭对话框保留本次草稿;导出组合 GLB 可保存并在下次导入恢复。"}{message && }
    +
    { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
    ; diff --git a/web/react/src/machine-manager-history.ts b/web/react/src/machine-manager-history.ts new file mode 100644 index 0000000..afb1865 --- /dev/null +++ b/web/react/src/machine-manager-history.ts @@ -0,0 +1,25 @@ +import { useCallback, useReducer } from "react"; +import type { MachinePartDraft, PhysicalZero } from "./machine-manager"; +import type { Vec3 } from "./machine-urdf"; + +export type MachineDraft = { parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }; +type History = { current: MachineDraft; past: MachineDraft[]; future: MachineDraft[] }; +type Action = { type: "edit"; change: (draft: MachineDraft) => MachineDraft } | { type: "undo" | "redo" | "clear" }; +const initial: MachineDraft = { parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 } }; +export function machineDraftHistory(state: History, action: Action): History { + if (action.type === "clear") return { current: state.current, past: [], future: [] }; + if (action.type === "undo") { const current = state.past.at(-1); return current ? { current, past: state.past.slice(0, -1), future: [state.current, ...state.future] } : state; } + if (action.type === "redo") { const current = state.future[0]; return current ? { current, past: [...state.past, state.current].slice(-30), future: state.future.slice(1) } : state; } + if (action.type !== "edit") return state; + const current = action.change(state.current); + if (current === state.current || (draftFingerprint(current) === draftFingerprint(state.current) && current.parts.every((part, i) => part.showOrigin === state.current.parts[i]?.showOrigin))) return state; + return { current, past: [...state.past, state.current].slice(-30), future: [] }; +} +export function useMachineDraft() { + const [history, dispatch] = useReducer(machineDraftHistory, { current: initial, past: [], future: [] }); + const edit = useCallback((change: (draft: MachineDraft) => MachineDraft) => dispatch({ type: "edit", change }), []); + return { history, draft: history.current, edit, undo: () => dispatch({ type: "undo" }), redo: () => dispatch({ type: "redo" }), clearHistory: useCallback(() => dispatch({ type: "clear" }), []) }; +} +export function draftFingerprint(draft: MachineDraft) { + return JSON.stringify({ ...draft, parts: draft.parts.map(({ id, filename, hash, role, parentId, visible, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints }) => ({ id, filename, hash, role, parentId, visible, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints })) }); +} diff --git a/web/react/src/styles.css b/web/react/src/styles.css index fc84f8b..cb816f5 100644 --- a/web/react/src/styles.css +++ b/web/react/src/styles.css @@ -415,3 +415,98 @@ output { font: 12px ui-monospace, monospace; text-align: right; } .machine-manager-calibration { grid-template-columns: 1fr; } .machine-motion-test > div { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* Machine manager workbench: a stable viewport with independently scrolling panels. */ +.machine-manager-dialog { width: min(1440px, calc(100vw - 24px)); height: min(940px, calc(100dvh - 24px)); } +.machine-manager-dialog > .machine-dialog-body { flex: 1; min-height: 0; padding: 10px; overflow: hidden; } +.machine-manager-workbench { height: 100%; display: flex; flex-direction: column; gap: 8px; min-height: 0; } +.machine-manager-toolbar { margin: 0; flex: 0 0 auto; } +.machine-manager-toolbar button, .machine-preview-tools button, .machine-tree-tools button, .machine-editor-tabs button, .machine-axis-overview button { border: 1px solid #9babad; background: #fff; padding: 6px 10px; border-radius: 4px; min-height: 32px; cursor: pointer; } +.machine-manager-dialog button:disabled { opacity: .48; cursor: not-allowed; } +.machine-manager-dialog input, .machine-manager-dialog select { background: #fff; border: 1px solid #9babad; border-radius: 3px; padding: 4px 6px; color: #203b3f; } +.machine-manager-dialog input:focus-visible, .machine-manager-dialog select:focus-visible { outline: 2px solid #2b858a; outline-offset: 1px; } +.machine-manager-dialog input:disabled, .machine-manager-dialog select:disabled { background: #e7edec; color: #596d6f; } +.machine-save-state { margin-left: auto; padding: 5px 9px; border-radius: 20px; background: #dbeae7; font-size: 12px; } +.machine-axis-overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; flex: 0 0 auto; } +.machine-axis-overview button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 7px; text-align: left; align-items: center; } +.machine-axis-overview button.bound { border-color: #74a7a0; background: #e4f1ed; } +.machine-axis-overview button.missing { border-style: dashed; color: #71511e; background: #fff9ec; } +.machine-axis-overview strong { font-size: 17px; } +.machine-axis-overview span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } +.machine-axis-overview small { font: 11px ui-monospace, monospace; } +.machine-manager-layout { flex: 1; min-height: 0; grid-template-columns: minmax(0, 1.25fr) minmax(370px, 1fr); gap: 10px; overflow: hidden; } +.machine-preview-area { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; border: 1px solid #52666a; border-radius: 5px; overflow: hidden; background: #263438; } +.machine-preview-tools { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; padding: 6px; background: #e3eceb; flex-wrap: wrap; } +.machine-preview-tools small { margin-left: auto; font-size: 11px; } +.machine-manager-preview-shell { flex: 1; min-height: 0; } +.machine-manager-preview { position: absolute; inset: 0; min-height: 0; height: 100%; border: 0; } +.machine-preview-hint { flex: 0 0 auto; color: #d1e2df; font-size: 11px; margin: 0; padding: 6px 10px; } +.machine-preview-empty { position: absolute; inset: 48px 16px 40px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; color: #e3eeeb; pointer-events: none; } +.machine-preview-empty strong { font-size: 18px; } +.machine-preview-empty button { pointer-events: auto; background: #e3f3ef; border: 1px solid #90bbb3; border-radius: 5px; padding: 9px 18px; color: #254c4e; } +.machine-preview-empty p { margin: 0; line-height: 1.7; } +.machine-manager-editor { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; gap: 8px; } +.machine-editor-tabs { display: flex; gap: 4px; flex: 0 0 auto; } +.machine-editor-tabs button { flex: 1; white-space: nowrap; } +.machine-editor-tabs [aria-selected="true"] { background: #27585d; color: #fff; border-color: #27585d; } +.machine-manager-dialog [hidden] { display: none !important; } +.machine-structure-panel { display: grid; grid-template-rows: minmax(125px, .9fr) minmax(190px, 1.1fr); gap: 8px; min-height: 0; flex: 1; } +.machine-part-list { display: flex; flex-direction: column; overflow: hidden; border-radius: 4px; } +.machine-part-list > header { display: flex; justify-content: space-between; gap: 8px; } +.machine-part-list > header span { color: #617576; font-size: 11px; } +.machine-part-list > .machine-tree { flex: 1; min-height: 0; overflow: auto; } +.machine-tree-tools { display: flex; gap: 4px; margin-bottom: 6px; } +.machine-tree-tools input { width: 100%; min-width: 0; } +.machine-tree-tools button { white-space: nowrap; padding: 3px 7px; } +.machine-tree .machine-tree-row button { min-height: 28px; } +.machine-tree [data-axis-control="machine-tree-node"] { grid-template-columns: minmax(90px, 1fr) auto; } +.machine-tree [data-axis-control="machine-tree-node"] small { display: none; } +.machine-tree [data-axis-control="machine-tree-node"]:focus-visible { outline: 2px solid #2b858a; outline-offset: -2px; } +.machine-part-editor { border-radius: 4px; } +.machine-part-editor > header h3 { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.machine-part-editor > header h3 small { display: block; color: #627777; font-size: 11px; font-weight: 400; margin-top: 3px; } +.machine-part-editor > header button { white-space: nowrap; font-size: 11px; border: 1px solid #d3aba1; border-radius: 3px; padding: 4px 6px; } +.machine-part-editor > label { grid-template-columns: 120px minmax(0, 1fr); } +.machine-origin-details { margin: 8px 0; } +.machine-origin-details summary { cursor: pointer; color: #36595c; padding: 4px 0; } +.machine-parent-note { margin: 8px 0; color: #627777; font-size: 11px; } +.machine-selection-empty { align-self: start; padding: 14px; color: #627777; } +.machine-number-field { display: block; min-width: 0; } +.machine-number-field input { width: 100%; min-width: 0; height: 30px; font: 12px ui-monospace, monospace; } +.machine-manager-dialog input[aria-invalid="true"] { border: 2px solid #ad4538; background: #fff0ec; } +.machine-field-error { display: block; color: #9c3024; font-size: 11px; } +.machine-manager-editor > .machine-motion-test, .machine-manager-editor > .machine-manager-calibration { flex: 1; min-height: 0; overflow: auto; margin: 0; padding: 12px; border-radius: 4px; } +.machine-motion-test > label { display: flex; align-items: center; } +.machine-motion-test > label > input { width: auto; } +.machine-motion-test > div { grid-template-columns: 1fr; gap: 12px; } +.machine-motion-test > div > label { grid-template-columns: 22px minmax(80px, 1fr) 35px; } +.machine-motion-test details { margin-top: 16px; color: #536467; font-size: 11px; } +.machine-motion-test p { line-height: 1.7; color: #536467; } +.machine-manager-editor > .machine-manager-calibration { display: block; } +.machine-manager-calibration .machine-vector { border: 1px solid #b7c9c5; border-radius: 4px; padding: 10px; margin: 12px 0; } +.machine-manager-calibration .machine-vector label { grid-template-columns: 18px minmax(60px, 1fr); flex: 1 1 105px; } +.machine-validation { flex: 0 0 auto; padding: 7px 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; } +.machine-validation.ready { background: #e1efe7; color: #285d42; } +.machine-validation.needs-attention { background: #fff2d9; color: #74501e; } +.machine-validation button { margin-left: 10px; text-decoration: underline; } +.machine-manager-footer { flex: 0 0 auto; margin: 0; gap: 6px; } +.machine-manager-footer > button { padding: 6px 10px; border: 1px solid #8aa3a4; border-radius: 4px; background: #fff; } +.machine-manager-footer > button:first-child { background: #27585d; color: #fff; } +.machine-manager-footer output { margin: 0; font-size: 11px; color: #536467; flex-basis: 280px; } +@media (max-width: 850px) { + .machine-manager-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(160px, .65fr) minmax(240px, 1fr); overflow: auto; } + .machine-axis-overview button { grid-template-columns: auto 1fr; padding: 4px 6px; } + .machine-axis-overview small { display: none; } + .machine-axis-overview span { font-size: 10px; } + .machine-save-state { margin-left: 0; } + .machine-preview-tools small { display: none; } + .machine-preview-hint { display: none; } +} +@media (max-height: 600px) and (min-width: 851px) { + .machine-structure-panel { grid-template-rows: minmax(85px, .6fr) minmax(100px, 1fr); } + .machine-manager-dialog { height: calc(100dvh - 16px); } + .machine-manager-workbench { gap: 4px; } +} +.machine-joint-step { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; } +.machine-joint-step > label { display: flex; align-items: center; gap: 6px; } +.machine-joint-step button { padding: 5px 9px; border: 1px solid #9babad; border-radius: 3px; background: #fff; }