Improve machine manager engineering workbench

This commit is contained in:
2026-09-21 12:54:56 +08:00
parent a2c6dd1c01
commit a7233bc8e4
18 changed files with 388 additions and 57 deletions

View File

@@ -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",

View File

@@ -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");

View File

@@ -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();

View File

@@ -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']}));

View File

@@ -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));