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