Compare commits

...

2 Commits

15 changed files with 42 additions and 17 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -3,6 +3,7 @@
"status": "verified", "status": "verified",
"url": "http://127.0.0.1:5180/", "url": "http://127.0.0.1:5180/",
"checks": [ "checks": [
"Open Files Cancel leaves the machine manager dialog open",
"search expands real nested nodes; axis overview locates selection; binding undo/redo", "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", "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", "TRT forbidden offset blocks export before build; calibration undo/redo",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -127,3 +127,9 @@
- 增加 `linuxcnc.machine-editor/1` GLB 元数据:原部件配置与源节点零姿态拓扑,打开时根据持久节点 ID 重建原始部件树,再应用编辑覆盖,避免合并分组与双重变换。校验恢复结果与运动 JSON 一致。 - 增加 `linuxcnc.machine-editor/1` GLB 元数据:原部件配置与源节点零姿态拓扑,打开时根据持久节点 ID 重建原始部件树,再应用编辑覆盖,避免合并分组与双重变换。校验恢复结果与运动 JSON 一致。
- 文件和元数据在 IndexedDB 一次事务提交;写入失败仍允许下载并明确提示。库打开校验 SHA256;库副本独立于磁盘文件,清站点数据会删除库记录。 - 文件和元数据在 IndexedDB 一次事务提交;写入失败仍允许下载并明确提示。库打开校验 SHA256;库副本独立于磁盘文件,清站点数据会删除库记录。
- 8 部件文件往返测试已通过:刷新后逐项回填、再次保存元数据完全等价、控制面板导入再送回编辑、旧 GLB 兼容、存储失败反馈。MILL700 AC/BC 完整回归通过,各 8 个五轴动态 tick,浏览器错误 0。工作台布局与八部件生产回归通过。MGR-21 verified。 - 8 部件文件往返测试已通过:刷新后逐项回填、再次保存元数据完全等价、控制面板导入再送回编辑、旧 GLB 兼容、存储失败反馈。MILL700 AC/BC 完整回归通过,各 8 个五轴动态 tick,浏览器错误 0。工作台布局与八部件生产回归通过。MGR-21 verified。
## R16 / 2026-09-22 / 文件选择器取消路径
- 修复原生文件选择器 Cancel/项目 Escape 与父 native dialog 的取消事件串扰;文件选择期间父对话框以 cancelGuard 保持打开。
- 三维管理“导入 GLB 部件”“打开已保存机床”、机床导入模型和机床参数导入统一保护;取消后草稿、选择节点、页签和配置不变。
- E18 工作台回归新增真实 filechooser 取消验证,浏览器错误 0。

View File

@@ -24,3 +24,4 @@
|MGR-19 对话框机型与保存整机|verified|MGR-18|先在对话框选择设计机型;绑定/标定受机型约束;草稿独立/可撤销;保存完整 GLB 可由控制面板导入并自动切换机型| |MGR-19 对话框机型与保存整机|verified|MGR-18|先在对话框选择设计机型;绑定/标定受机型约束;草稿独立/可撤销;保存完整 GLB 可由控制面板导入并自动切换机型|
|MGR-20 机型程序一致性|verified|MGR-19|导入/应用跨机型时加载目标默认程序;同机型保留;错轴程序加载/运行/单步/继续拒绝;AC↔BC 浏览器及 Worker 验收通过| |MGR-20 机型程序一致性|verified|MGR-19|导入/应用跨机型时加载目标默认程序;同机型保留;错轴程序加载/运行/单步/继续拒绝;AC↔BC 浏览器及 Worker 验收通过|
|MGR-21 保存位置与完整文档恢复|verified|MGR-20|命名 GLB 下载与本地机床库;显式打开/刷新恢复;多部件配置精确回填;二次保存等价;标准导入、旧版兼容、存储失败和 AC/BC 回归| |MGR-21 保存位置与完整文档恢复|verified|MGR-20|命名 GLB 下载与本地机床库;显式打开/刷新恢复;多部件配置精确回填;二次保存等价;标准导入、旧版兼容、存储失败和 AC/BC 回归|
|MGR-22 文件选择器取消路径|verified|MGR-18|三维管理、机床导入、几何参数三类文件选择器取消/回焦不关闭父对话框;没有文件时保持配置和焦点|

View File

@@ -149,10 +149,10 @@
|文件(相对 coverage/glb-machine-manager/machine-type)|SHA-256| |文件(相对 coverage/glb-machine-manager/machine-type)|SHA-256|
|---|---| |---|---|
|`browser.report.json`|`222c43f2ef36d28f31171773efe2cbe49eff383e766c5086f18bac5bae62047a`| |`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|`dialog-type.png`|`c24f8b3fd445cf5e886b8189e13e0830e6fecf341e57499e7cf73528fd803b9d`| |`dialog-type.png`|`c24f8b3fd445cf5e886b8189e13e0830e6fecf341e57499e7cf73528fd803b9d`|
|`controller-import.png`|`071f8d9a9ac4ac85fe64bfcd51f1fb605112515eb4aaa0e00ceacd539347d67b`| |`controller-import.png`|`071f8d9a9ac4ac85fe64bfcd51f1fb605112515eb4aaa0e00ceacd539347d67b`|
|`mill700/browser.report.json`|`05fb403ef854aba37c1ab64e0334a843d4839a6c2ce559031d25589a68346f3a`| |`mill700/browser.report.json`|`7f5c87b1a2d5491242aa87248d0cb4085e5d849065520ad56e4e64c6453216b2`|
|`saved-models/MILL700-xyzac.glb`|`25ba366a6550c3a613a63623b8e25216d5a3a6dd2781b69481d80d0bb87033e4`| |`saved-models/MILL700-xyzac.glb`|`25ba366a6550c3a613a63623b8e25216d5a3a6dd2781b69481d80d0bb87033e4`|
|`saved-models/MILL700-xyzbc.glb`|`3b87cb8504815c13127608c513eacb3536e1be1c10f9a58fcd59920939fbe0c1`| |`saved-models/MILL700-xyzbc.glb`|`3b87cb8504815c13127608c513eacb3536e1be1c10f9a58fcd59920939fbe0c1`|
@@ -166,7 +166,7 @@
|证据(相对 program-compatibility)|SHA-256| |证据(相对 program-compatibility)|SHA-256|
|---|---| |---|---|
|`browser.report.json`|`c931be222b8c3f541174c26f9892083481f9dfe5c75a372e3d20b222b8a9e89f`| |`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|`worker.report.json`|`986fb9e127894aaf477ac6e5879c06b7084a112c840549532d39fdd6d3e8694e`| |`worker.report.json`|`986fb9e127894aaf477ac6e5879c06b7084a112c840549532d39fdd6d3e8694e`|
|`catalog.report.json`|`ff2e32be3b006e2d8de902012ed89724aada54c425503027ae5e027eaf9cafe2`| |`catalog.report.json`|`ff2e32be3b006e2d8de902012ed89724aada54c425503027ae5e027eaf9cafe2`|
|`replace.report.json`|`fe92778b144b226cea1e91393d10e4905cfd5becf56ba9b3021b5842514c8aa7`| |`replace.report.json`|`fe92778b144b226cea1e91393d10e4905cfd5becf56ba9b3021b5842514c8aa7`|
@@ -187,8 +187,14 @@
|文件(相对 document)|SHA-256| |文件(相对 document)|SHA-256|
|---|---| |---|---|
|`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`| |`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|`files-and-recovery.png`|`ae48e693668a8e100b81fa2cbfe1a6d4dbc21600a230994f73f2d85d2876e389`| |`files-and-recovery.png`|`cef9836f21a56b79916ea0b206e3013553688f988947b701ac68332dffb93af4`|
|`saved-eight-parts.glb`|`f155f46e6d5566e407a2f708a66ba61332366fb2ba5007b01ac99ed04494769e`| |`saved-eight-parts.glb`|`57e268f7278119493004848b97262a1042ada6f81f30103b72f2fb0a8db66456`|
|`mill700/browser.report.json`|`7f5c87b1a2d5491242aa87248d0cb4085e5d849065520ad56e4e64c6453216b2`| |`mill700/browser.report.json`|`7f5c87b1a2d5491242aa87248d0cb4085e5d849065520ad56e4e64c6453216b2`|
- E17 已发布功能代码 `8443930`,线上资源 `index-EHYipPfn.js`;线上完整复测保存、刷新、库打开、多部件字段回填、标准导入、再次编辑、旧版文件及存储失败分支,全部 verified,浏览器错误 0。报告 `document/cloud.report.json`、`document/deployment.report.json`。 - E17 已发布功能代码 `8443930`,线上资源 `index-EHYipPfn.js`;线上完整复测保存、刷新、库打开、多部件字段回填、标准导入、再次编辑、旧版文件及存储失败分支,全部 verified,浏览器错误 0。报告 `document/cloud.report.json`、`document/deployment.report.json`。
## E18 / R16 / 文件选择器取消
- `node scripts/test_machine_manager_workbench.mjs`:report `GLB-MGR-E14` 更新,首项检查真实 `filechooser` 打开后取消,三维机床管理对话框仍保持打开;随后搜索、撤销、标定、布局回归继续通过,浏览器错误 0。
- 三个文件入口统一保护:管理器 GLB/已保存机床、控制面板机床模型、机床几何 INI。
- 工作台报告 SHA-256:`0f6db50633be732a4b65162aff3373eccb23cfe319a1dd7d19fa4260098bde64`。

View File

@@ -11,7 +11,7 @@
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。 现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
当前状态:MGR-21 文件管理与恢复 verified;MGR-20 机型程序一致性 verified;MGR-19 对话框选择机型/整机保存已 verified;MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R15,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14/E15/E16/E17` 位于 `coverage/glb-machine-manager/`。 当前状态:MGR-21 文件管理与恢复 verified;MGR-20 机型程序一致性 verified;MGR-22 文件选择器取消 verified;MGR-19 对话框选择机型/整机保存已 verified;MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R15,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14/E15/E16/E17/E18` 位于 `coverage/glb-machine-manager/`。
文档: 文档:

View File

@@ -14,6 +14,7 @@ const choose = async (name) => { await c('machine-manager-tab-structure').click(
try { 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 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); await c('machine-manager-settings').click(); assert.equal(await page.locator('.machine-preview-empty').isVisible(), true);
const fileDialog = page.locator('.machine-manager-dialog'); const chooser = page.waitForEvent('filechooser'); await c('machine-manager-import').click(); await chooser; await fileDialog.dispatchEvent('cancel'); assert.equal(await fileDialog.isVisible(), true); await page.evaluate(() => window.dispatchEvent(new Event('focus'))); await page.waitForTimeout(20); report.checks.push('Open Files Cancel leaves the machine manager dialog open');
const bytes = await readFile(new URL('../MILL700-U_1.glb', import.meta.url)); 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 }; 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 c('machine-manager-files').setInputFiles(upload); await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);

View File

@@ -1,10 +1,10 @@
import { useEffect, useId, useLayoutEffect, useRef, type PointerEvent, type ReactNode } from "react"; import { useEffect, useId, useLayoutEffect, useRef, type PointerEvent, type ReactNode } from "react";
type Props = { open: boolean; title: string; className?: string; onClose: () => void; children: ReactNode }; type Props = { open: boolean; title: string; className?: string; onClose: () => void; cancelGuard?: () => boolean; children: ReactNode };
// Native dialog provides focus containment, inert background, Escape and focus return. // Native dialog provides focus containment, inert background, Escape and focus return.
// Keeping children mounted preserves drafts and pending import results when closed. // Keeping children mounted preserves drafts and pending import results when closed.
export function DraggableDialog({ open, title, className = "", onClose, children }: Props) { export function DraggableDialog({ open, title, className = "", onClose, cancelGuard, children }: Props) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
const titleId = useId(); const titleId = useId();
const drag = useRef<{ id: number; x: number; y: number; left: number; top: number } | null>(null); const drag = useRef<{ id: number; x: number; y: number; left: number; top: number } | null>(null);
@@ -40,7 +40,7 @@ export function DraggableDialog({ open, title, className = "", onClose, children
position(from.left + event.clientX - from.x, from.top + event.clientY - from.y); position(from.left + event.clientX - from.x, from.top + event.clientY - from.y);
}; };
const stop = () => { drag.current = null; }; const stop = () => { drag.current = null; };
return <dialog ref={ref} className={`machine-dialog ${className}`} aria-modal="true" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); onClose(); }} onKeyDown={(event) => { return <dialog ref={ref} className={`machine-dialog ${className}`} aria-modal="true" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); if (!cancelGuard?.()) onClose(); }} onKeyDown={(event) => {
if (event.key !== "Tab") return; if (event.key !== "Tab") return;
const elements = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]')].filter((element) => element.getClientRects().length > 0); const elements = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]')].filter((element) => element.getClientRects().length > 0);
const first = elements[0], last = elements.at(-1); const first = elements[0], last = elements.at(-1);

View File

@@ -8,6 +8,8 @@ export function MachineImportPanel({ model, disabled, onImport }: { model: Impor
const file = useRef<HTMLInputElement>(null); const file = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const filePickerOpen = useRef(false);
const openFilePicker = () => { if (disabled || !file.current) return; filePickerOpen.current = true; file.current.click(); const clear = () => { window.removeEventListener("focus", clear); setTimeout(() => { filePickerOpen.current = false; }, 0); }; window.addEventListener("focus", clear, { once: true }); };
const run = async (source: File | string) => { const run = async (source: File | string) => {
if (disabled) return; if (disabled) return;
setMessage("正在读取机床结构…"); setMessage("正在读取机床结构…");
@@ -17,10 +19,10 @@ export function MachineImportPanel({ model, disabled, onImport }: { model: Impor
const s = model?.structure; const s = model?.structure;
return <section className="machine-import" aria-label="GLB 机床导入" data-machine-import={model?.hash ?? "builtin"}> return <section className="machine-import" aria-label="GLB 机床导入" data-machine-import={model?.hash ?? "builtin"}>
<div className="machine-import-toolbar"><button type="button" data-axis-control="machine-glb-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>导入机床模型</button></div> <div className="machine-import-toolbar"><button type="button" data-axis-control="machine-glb-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>导入机床模型</button></div>
<DraggableDialog open={open} title="三维机床模型" className="model-dialog" onClose={() => setOpen(false)}> <DraggableDialog open={open} title="三维机床模型" className="model-dialog" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
<header><strong>三维机床</strong><span>{s ? `${s.coordinates} · ${model.filename}` : "内置模型"}</span></header> <header><strong>三维机床</strong><span>{s ? `${s.coordinates} · ${model.filename}` : "内置模型"}</span></header>
<div className="machine-import-actions"> <div className="machine-import-actions">
<button type="button" data-axis-control="machine-glb-import" disabled={disabled} onClick={() => file.current?.click()}>导入 GLB</button> <button type="button" data-axis-control="machine-glb-import" disabled={disabled} onClick={openFilePicker}>导入 GLB</button>
{examples.map(({ id, label, filename }) => <span key={id}><button type="button" data-axis-control={`machine-glb-${id}`} disabled={disabled} onClick={() => void run(`${import.meta.env.BASE_URL}machines/${filename}`)}>{label}</button><a href={`${import.meta.env.BASE_URL}machines/${filename}`} download>下载 {label.replace(" 示例", "")}</a></span>)} {examples.map(({ id, label, filename }) => <span key={id}><button type="button" data-axis-control={`machine-glb-${id}`} disabled={disabled} onClick={() => void run(`${import.meta.env.BASE_URL}machines/${filename}`)}>{label}</button><a href={`${import.meta.env.BASE_URL}machines/${filename}`} download>下载 {label.replace(" 示例", "")}</a></span>)}
</div> </div>
{s && <details data-machine-structure><summary>{`底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}</summary><p>旋转中心 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_rot_point`].toFixed(3)).join(" / ")}</p><p>轴间偏置 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_offset`].toFixed(3)).join(" / ")}</p><p>运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。</p></details>} {s && <details data-machine-structure><summary>{`底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}</summary><p>旋转中心 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_rot_point`].toFixed(3)).join(" / ")}</p><p>轴间偏置 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_offset`].toFixed(3)).join(" / ")}</p><p>运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。</p></details>}

View File

@@ -156,7 +156,13 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value })); const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value }));
const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value })); const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value }));
const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value })); const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value }));
const file = useRef<HTMLInputElement>(null), openFile = useRef<HTMLInputElement>(null); const file = useRef<HTMLInputElement>(null), openFile = useRef<HTMLInputElement>(null), filePickerOpen = useRef(false);
const beginFilePicker = (input: HTMLInputElement | null) => {
if (!input || pending || followLive) return;
filePickerOpen.current = true; input.click();
const clear = () => { window.removeEventListener("focus", clear); setTimeout(() => { filePickerOpen.current = false; }, 0); };
window.addEventListener("focus", clear, { once: true });
};
const documentInfo = draft.documentInfo ?? { name: "", description: "" }; const documentInfo = draft.documentInfo ?? { name: "", description: "" };
const [library, setLibrary] = useState<SavedMachine[]>([]), [libraryError, setLibraryError] = useState(""); const [library, setLibrary] = useState<SavedMachine[]>([]), [libraryError, setLibraryError] = useState("");
const [saveLocation, setSaveLocation] = useState("尚未保存文件"), [savedSignature, setSavedSignature] = useState(""); const [saveLocation, setSaveLocation] = useState("尚未保存文件"), [savedSignature, setSavedSignature] = useState("");
@@ -349,19 +355,19 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} query={query} expansion={expansion} locked={pending || followLive} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />; const renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} query={query} expansion={expansion} locked={pending || followLive} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />;
return <section className="machine-manager" aria-label="三维机床管理"> 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" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
<div className="machine-manager-workbench" aria-busy={pending}> <div className="machine-manager-workbench" aria-busy={pending}>
<div className="machine-type-step"><label><strong>1 · 机床类型</strong><select data-axis-control="machine-manager-type" aria-label="设计机床类型" value={coordinates} disabled={pending || followLive} onChange={(event) => { <div className="machine-type-step"><label><strong>1 · 机床类型</strong><select data-axis-control="machine-manager-type" aria-label="设计机床类型" value={coordinates} disabled={pending || followLive} onChange={(event) => {
const next = event.target.value as "XYZAC" | "XYZBC"; const next = event.target.value as "XYZAC" | "XYZBC";
edit((draft) => changeMachineType(draft, next)); setTab("structure"); edit((draft) => changeMachineType(draft, next)); setTab("structure");
setMessage(`设计机型已选择 ${next};保留模型层级和 XYZ/C 绑定,请重新配置 ${next[3]} 轴并核对标定。可撤销恢复。`); setMessage(`设计机型已选择 ${next};保留模型层级和 XYZ/C 绑定,请重新配置 ${next[3]} 轴并核对标定。可撤销恢复。`);
}}><option value="XYZAC">XYZAC · A/C 五轴转台</option><option value="XYZBC">XYZBC · B/C 五轴转台</option></select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates === "XYZAC" ? "A 绕 X 倾转,C 绕 Z 回转" : "B 绕 Y 倾转,C 绕 Z 回转"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""}</small></div></div> }}><option value="XYZAC">XYZAC · A/C 五轴转台</option><option value="XYZBC">XYZBC · B/C 五轴转台</option></select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates === "XYZAC" ? "A 绕 X 倾转,C 绕 Z 回转" : "B 绕 Y 倾转,C 绕 Z 回转"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""}</small></div></div>
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={requestOpen}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div> <div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={() => beginFilePicker(openFile.current)}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => beginFilePicker(file.current)}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
<div className="machine-axis-overview" aria-label="数控轴绑定总览">{[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div> <div className="machine-axis-overview" aria-label="数控轴绑定总览">{[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div>
<div className="machine-manager-layout"> <div className="machine-manager-layout">
<section className="machine-preview-area" aria-label="机床三维预览"><div className="machine-preview-tools">{([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => <button key={action} type="button" data-axis-control={`machine-view-${action}`} disabled={!parts.length || (action === "selected" && !selected)} onClick={() => setCommand((current) => ({ action, serial: current.serial + 1 }))}>{label}</button>)}<small>Z ↑ · X 红 / Y 绿 / Z 蓝</small></div> <section className="machine-preview-area" aria-label="机床三维预览"><div className="machine-preview-tools">{([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => <button key={action} type="button" data-axis-control={`machine-view-${action}`} disabled={!parts.length || (action === "selected" && !selected)} onClick={() => setCommand((current) => ({ action, serial: current.serial + 1 }))}>{label}</button>)}<small>Z ↑ · X 红 / Y 绿 / Z 蓝</small></div>
{open && <MachineManagerPanelPreview command={command} coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />} {open && <MachineManagerPanelPreview command={command} coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />}
{!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => file.current?.click()}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 32 MiB</small>{library.length > 0 && <button type="button" onClick={() => setTab("files")}>恢复本地机床库({library.length} 个版本)</button>}</div>} {!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => beginFilePicker(file.current)}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 32 MiB</small>{library.length > 0 && <button type="button" onClick={() => setTab("files")}>恢复本地机床库({library.length} 个版本)</button>}</div>}
<p className="machine-preview-hint">左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系</p></section> <p className="machine-preview-hint">左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系</p></section>
<div className="machine-manager-editor"> <div className="machine-manager-editor">
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration", "files"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 4] : event.key === "ArrowLeft" ? ids[(index + 3) % 4] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[3] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div> <div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration", "files"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 4] : event.key === "ArrowLeft" ? ids[(index + 3) % 4] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[3] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div>

View File

@@ -12,6 +12,8 @@ export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }:
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const file = useRef<HTMLInputElement>(null); const file = useRef<HTMLInputElement>(null);
const filePickerOpen = useRef(false);
const openFilePicker = () => { if (locked || !file.current) return; filePickerOpen.current = true; file.current.click(); const clear = () => { window.removeEventListener("focus", clear); setTimeout(() => { filePickerOpen.current = false; }, 0); }; window.addEventListener("focus", clear, { once: true }); };
const geometry = useMemo(() => bundle ? readTrtGeometry(bundle) : null, [bundle]); const geometry = useMemo(() => bundle ? readTrtGeometry(bundle) : null, [bundle]);
useEffect(() => { useEffect(() => {
setValues(Object.fromEntries(TRT_GEOMETRY_KEYS.map((key: string) => [key, String(geometry?.offsets[key] ?? 0)]))); setValues(Object.fromEntries(TRT_GEOMETRY_KEYS.map((key: string) => [key, String(geometry?.offsets[key] ?? 0)])));
@@ -50,13 +52,13 @@ export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }:
const tool = (id: string, label: string, asset: string, action: () => void, off = locked) => <button type="button" data-axis-control={`geometry-${id}`} aria-label={label} title={label} disabled={off} onClick={action}><img src={axisIcon(asset)} alt="" /></button>; const tool = (id: string, label: string, asset: string, action: () => void, off = locked) => <button type="button" data-axis-control={`geometry-${id}`} aria-label={label} title={label} disabled={off} onClick={action}><img src={axisIcon(asset)} alt="" /></button>;
return <section className="trt-geometry" aria-label="机床几何参数" aria-busy={pending || busy} data-dirty={dirty}> return <section className="trt-geometry" aria-label="机床几何参数" aria-busy={pending || busy} data-dirty={dirty}>
<div className="trt-geometry-toolbar"><button type="button" data-axis-control="geometry-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>机床参数</button></div> <div className="trt-geometry-toolbar"><button type="button" data-axis-control="geometry-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>机床参数</button></div>
<DraggableDialog open={open} title="机床几何参数" className="geometry-dialog" onClose={() => setOpen(false)}> <DraggableDialog open={open} title="机床几何参数" className="geometry-dialog" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
<header><strong>{bundle?.entrypoint.split("/").at(-1) ?? "INI"}</strong><span>mm</span><output>{dirty ? "待应用" : "已生效"}</output></header> <header><strong>{bundle?.entrypoint.split("/").at(-1) ?? "INI"}</strong><span>mm</span><output>{dirty ? "待应用" : "已生效"}</output></header>
<div className="trt-geometry-fields">{["旋转中心", "轴间偏置"].map((title, index) => <fieldset key={title}><legend>{title}</legend>{TRT_GEOMETRY_KEYS.slice(index * 3, index * 3 + 3).map((key: string) => <label key={key} title={key === geometry?.unsupported ? "此机型原生运动学不使用该偏置" : key.replaceAll("_", "-")}><span>{key.replaceAll("_", "-")}</span><input aria-label={key.replaceAll("_", "-")} type="number" step="any" disabled={locked || key === geometry?.unsupported} value={values[key] ?? "0"} onChange={(e) => setValues({ ...values, [key]: e.target.value })} /></label>)}</fieldset>)}</div> <div className="trt-geometry-fields">{["旋转中心", "轴间偏置"].map((title, index) => <fieldset key={title}><legend>{title}</legend>{TRT_GEOMETRY_KEYS.slice(index * 3, index * 3 + 3).map((key: string) => <label key={key} title={key === geometry?.unsupported ? "此机型原生运动学不使用该偏置" : key.replaceAll("_", "-")}><span>{key.replaceAll("_", "-")}</span><input aria-label={key.replaceAll("_", "-")} type="number" step="any" disabled={locked || key === geometry?.unsupported} value={values[key] ?? "0"} onChange={(e) => setValues({ ...values, [key]: e.target.value })} /></label>)}</fieldset>)}</div>
<footer> <footer>
<button type="button" data-axis-control="geometry-apply" disabled={locked || !dirty} onClick={() => void run(() => onApply(edited()), "参数已应用,待重新回零")}><img src={axisIcon("tool_verify.gif")} alt="" />应用参数</button> <button type="button" data-axis-control="geometry-apply" disabled={locked || !dirty} onClick={() => void run(() => onApply(edited()), "参数已应用,待重新回零")}><img src={axisIcon("tool_verify.gif")} alt="" />应用参数</button>
{tool("reload", "重新读取 INI", "tool_reload.gif", () => void run(onReload, "已重新读取 INI"))} {tool("reload", "重新读取 INI", "tool_reload.gif", () => void run(onReload, "已重新读取 INI"))}
{tool("import", "导入 INI 参数", "tool_open.gif", () => file.current?.click())} {tool("import", "导入 INI 参数", "tool_open.gif", openFilePicker)}
<button type="button" data-axis-control="geometry-save" disabled={locked} onClick={() => void run(exportIni, "INI 已保存或导出")}>保存 INI</button> <button type="button" data-axis-control="geometry-save" disabled={locked} onClick={() => void run(exportIni, "INI 已保存或导出")}>保存 INI</button>
<output role="status">{message || (disabled ? "关机且程序空闲后可修改" : "")}</output> <output role="status">{message || (disabled ? "关机且程序空闲后可修改" : "")}</output>
</footer> </footer>