Files
linuxcncWebWASM/scripts/test_imported_geometry_browser.mjs

133 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { WasmXyzacKinematics } from '../web/worker/wasm-xyzac-kinematics.mjs';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const root = new URL('../', import.meta.url), output = process.env.GLB_EVIDENCE_DIR ? pathToFileURL(`${process.env.GLB_EVIDENCE_DIR.replace(/\/$/, '')}/`) : new URL('coverage/glb-machine/geometry-edit/', root);
const gantry = process.env.GLB_MACHINE_VARIANT === 'gantry';
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.addInitScript(() => { window.showSaveFilePicker = undefined; });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const settled = () => page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false' && document.querySelector('.trt-geometry')?.getAttribute('aria-busy') === 'false');
const machine = page.locator('.xyzac-machine-view');
const snapshot = () => machine.evaluate((e) => ({ hash: e.dataset.machineGlb, filename: e.dataset.xyzacModelSourcePath, offsets: JSON.parse(e.dataset.xyzacOffsets), joints: JSON.parse(e.dataset.xyzacJoints), tip: JSON.parse(e.dataset.xyzacToolTip) }));
const report = { report_id: 'imported-geometry-browser-v1', status: 'running', machines: [] };
try {
await page.goto('http://127.0.0.1:5174/'); await settled();
for (const id of ['ac', 'bc']) {
await control('machine-glb-settings').click(); await control('machine-glb-file').setInputFiles(new URL(`web/react/public/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`, root).pathname); await settled();
await page.keyboard.press('Escape');
const original = await snapshot(), beforePath = await page.locator('main').getAttribute('data-axis-program-path');
assert.ok(original.hash);
await control('geometry-settings').click();
const edits = { x_rot_point: 31, y_rot_point: -9, z_rot_point: 15, x_offset: id === 'bc' ? 27 : 0, y_offset: id === 'ac' ? 23 : 0, z_offset: -34 };
for (const [key, value] of Object.entries(edits)) if (key !== (id === 'ac' ? 'x_offset' : 'y_offset')) await page.getByLabel(key.replaceAll('_', '-'), { exact: true }).fill(String(value));
await control('geometry-apply').click(); await settled();
const applied = await snapshot();
assert.equal(applied.hash, original.hash, 'applying geometry must retain imported GLB');
assert.equal(applied.filename, original.filename);
for (const [key, value] of Object.entries(edits)) assert.ok(Math.abs(applied.offsets[key] - value) < 1e-8);
assert.equal(await page.locator('main').getAttribute('data-axis-program-path'), beforePath);
const downloadEvent = page.waitForEvent('download'); await control('geometry-save').click(); const exported = await readFile(await (await downloadEvent).path(), 'utf8');
assert.match(exported, /x-rot-point\s+31/);
await page.keyboard.press('Escape');
await control('machine-glb-settings').click();
if (!(await page.locator('[data-machine-structure]').evaluate((e) => e.open))) await page.locator('[data-machine-structure] summary').click();
const details = await page.locator('[data-machine-structure]').innerText(); assert.match(details, /31\.000 \/ -9\.000 \/ 15\.000/); assert.match(details, /-34\.000/);
await page.keyboard.press('Escape');
await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}-edited.png`, output).pathname });
// Exercise the actual imported hierarchy with native IK at the edited offsets.
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const samples = [], coordinates = `XYZ${id.toUpperCase()}`;
for (const tool_offset of [0, 17, -4]) {
const offsets = { ...edits, tool_offset };
const native = new WasmXyzacKinematics(core, { coordinates, ...offsets, limits: Object.fromEntries([...coordinates.toLowerCase()].map((a) => [a, [-100000, 100000]])) });
for (const [tilt, c] of [[0, 0], [25, 0], [0, 65], [-35, 125], [42, -80], [-80, 270]]) {
const pose = { x: 32, y: 19, z: 28, a: 0, b: 0, c, u: 0, v: 0, w: 0, [id[0]]: tilt };
const inverse = native.inverseMode(1, pose); assert.equal(inverse.status, 'verified'); samples.push({ pose, joints: inverse.joints, offsets });
}
native.destroy();
}
const projection = await page.evaluate(async ({ id, edits, samples, gantry }) => {
const api = await import('/src/imported-machine.ts');
const geometry = await import('/workspace/trt-geometry.mjs');
const loader = await import('/workspace/sim-case-loader.mjs');
const parser = await import('/src/machine-urdf.ts');
const asset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`)).arrayBuffer(), `${id}.glb`);
const originalUrdf = asset.structure.urdf;
const bundle = await loader.loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id}-trt.ini`, baseUrl: '.' });
const adjusted = api.updateImportedMachineGeometry(asset, geometry.writeTrtGeometry(bundle, edits));
if (asset.scene !== adjusted.scene || asset.hash !== adjusted.hash || asset.structure.urdf !== originalUrdf) throw new Error('source asset changed');
const parsed = parser.parseMachineUrdf(adjusted.structure.urdf);
for (const [key, value] of Object.entries(edits)) if (Math.abs(parsed.offsets[key] - value) > 1e-8) throw new Error(`URDF drift ${key}`);
const model = api.buildImportedMachine(adjusted);
const meshes = []; model.machine.traverse((n) => { if (n.isMesh) meshes.push(n.name); });
if (!meshes.some((name) => name.startsWith(gantry ? 'gantry_post_' : 'portal_column_'))) throw new Error('original mesh lost');
const checks = [];
for (const state of samples) {
model.update(state);
const point = model.workOrigin.localToWorld(model.toolTip.position.clone().set(state.pose.x / 1000, state.pose.y / 1000, state.pose.z / 1000));
const tip = model.toolTip.getWorldPosition(point.clone()); const error = tip.distanceTo(point);
if (error > 1e-8) throw new Error(`IK/model mismatch ${error}`); checks.push(error);
}
const renamed = structuredClone(asset.structure);
renamed.urdf = renamed.urdf.replaceAll('tilt_joint', 'custom_tilt').replaceAll('c_joint', 'custom_spin').replaceAll('work_mount', 'custom_work');
renamed.joints = parser.parseMachineUrdf(renamed.urdf).joints;
const custom = api.updateImportedMachineGeometry({ ...asset, structure: renamed }, geometry.writeTrtGeometry(bundle, edits));
if (!custom.structure.joints.some((j) => j.name === 'custom_tilt')) throw new Error('relies on fixture joint names');
let mismatch = false;
const other = await loader.loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id === 'ac' ? 'bc' : 'ac'}-trt.ini`, baseUrl: '.' });
try { api.updateImportedMachineGeometry(asset, other); } catch { mismatch = true; }
if (!mismatch) throw new Error('incompatible machine not rejected');
const repeat = api.updateImportedMachineGeometry(adjusted, geometry.writeTrtGeometry(bundle, edits));
if (repeat !== adjusted) throw new Error('unchanged geometry should reuse asset');
model.dispose(); api.disposeMachineScene(asset.scene);
return { checks, mesh_count: meshes.length, source_preserved: true, renamed_joint_supported: true, incompatible_rejected: true };
}, { id, edits, samples, gantry });
await control('geometry-settings').click();
// Bad numeric input must leave the successfully applied model and parameters intact.
await page.getByLabel('x-rot-point', { exact: true }).fill(''); await control('geometry-apply').click(); await settled();
assert.match(await page.locator('.trt-geometry footer output').innerText(), /trt_geometry_number/);
assert.equal((await snapshot()).hash, original.hash); assert.deepEqual((await snapshot()).offsets, applied.offsets);
// Reload the INI, then re-import the saved INI: both preserve the model.
await control('geometry-reload').click(); await settled(); assert.equal((await snapshot()).hash, original.hash);
await page.locator('.trt-geometry input[type="file"]').setInputFiles({ name: `xyz${id}-trt.ini`, mimeType: 'text/plain', buffer: Buffer.from(exported) });
await page.waitForFunction(() => document.querySelector('input[aria-label="x-rot-point"]').value === '31');
await control('geometry-apply').click(); await settled(); assert.equal((await snapshot()).hash, original.hash);
assert.deepEqual((await snapshot()).offsets, applied.offsets);
await page.keyboard.press('Escape');
await control('toolbar-power').click(); await control('manual-home-all').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
await control('switchkins-1').click();
await control('tab-manual').click(); await control(`manual-axis-${id[0]}`).check();
const beforeJog = await snapshot(), b = await control('manual-jog-positive').boundingBox();
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); await page.mouse.down();
try { await page.waitForFunction((initial) => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[3] > initial + 2, beforeJog.joints[3]); } finally { await page.mouse.up(); }
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent?.includes('JOG_STOP acknowledged'));
await page.waitForFunction(() => { const e = document.querySelector('.xyzac-machine-view'); return e.dataset.xyzacTick === e.dataset.xyzacRenderedTick; });
const afterJog = await snapshot(); assert.ok(afterJog.joints.slice(0, 3).some((v, i) => Math.abs(v - beforeJog.joints[i]) > 1e-5));
const tcpError = await page.evaluate(async ({ id, edits, beforeJog, afterJog, gantry }) => {
const api = await import('/src/imported-machine.ts'), loader = await import('/workspace/sim-case-loader.mjs'), geometry = await import('/workspace/trt-geometry.mjs');
const asset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`)).arrayBuffer(), id);
const bundle = await loader.loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id}-trt.ini`, baseUrl: '.' });
const model = api.buildImportedMachine(api.updateImportedMachineGeometry(asset, geometry.writeTrtGeometry(bundle, edits)));
model.update(beforeJog); const target = model.workOrigin.worldToLocal(model.toolTip.getWorldPosition(model.toolTip.position.clone()));
model.update(afterJog); const tip = model.toolTip.getWorldPosition(target.clone()), point = model.workOrigin.localToWorld(target.clone());
const error = tip.distanceTo(point), rendered = tip.distanceTo(point.fromArray(afterJog.tip));
model.dispose(); api.disposeMachineScene(asset.scene); return { error, rendered };
}, { id, edits, beforeJog, afterJog, gantry });
assert.ok(tcpError.error < 1e-8 && tcpError.rendered < 1e-8);
await control('geometry-settings').click(); assert.ok(await page.getByLabel('x-rot-point', { exact: true }).isDisabled()); await page.keyboard.press('Escape');
await control('toolbar-power').click(); await page.waitForFunction(() => !document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
report.machines.push({ id, original, applied, edits, projection, tcpError, model_retained_on_reload_and_ini_import: true });
}
// Deliberately selecting a different machine still discards an incompatible GLB.
await control('machine-type-select').selectOption({ label: 'XYZAC TRT' }); await settled(); assert.equal((await snapshot()).hash, '');
assert.deepEqual(errors, []); report.status = 'verified';
} catch (e) { report.status = 'failed'; report.error = String(e.stack ?? e); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw e; }
finally { report.browser_errors = errors; await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ id: m.id, pose_samples: m.projection.checks.length, tcpError: m.tcpError })) }));