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 { SimSession, CONTEXT_ID } from '../web/worker/sim-session.mjs'; import { loadSimulationCase, toWorkerConfiguration } from '../web/workspace/sim-case-loader.mjs'; import { writeTrtGeometry } from '../web/workspace/trt-geometry.mjs'; import { MACHINE_DEFINITIONS } from '../web/workspace/machine-definitions.mjs'; 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/', root); const gantry = process.env.GLB_MACHINE_VARIANT === 'gantry'; const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } }; const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); 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(); const report = { report_id: 'glb-machine-five-axis-motion-v1', status: 'running', machines: [] }; try { await page.goto(process.env.XYZAC_PANEL_URL ?? 'http://127.0.0.1:5174/'); await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false'); for (const definition of MACHINE_DEFINITIONS) { const id = definition.coordinates.slice(3).toLowerCase(); const structure = await page.evaluate(async ({ id, gantry }) => { const api = await import('/src/imported-machine.ts'); if (window.motionAsset) api.disposeMachineScene(window.motionAsset.scene); window.motionAsset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`)).arrayBuffer(), `${id}.glb`); return window.motionAsset.structure; }, { id, gantry }); const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) }); const session = new SimSession(core, { sessionId: `glb-motion-${id}` }); let seq = 0; const send = (kind, payload = {}) => { const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `motion-${seq}`, payload }); assert.notEqual(result.kind, 'ERROR', `${kind}: ${JSON.stringify(result.payload)}`); return result; }; send('HELLO'); send('SESSION_ATTACH'); const bundle = writeTrtGeometry(await loadSimulationCase({ path: definition.entrypoint, fetchImpl }), structure.offsets); send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: toWorkerConfiguration(bundle) }); const tilt = definition.coordinates[3]; const source = `G21 G90 G94\nG0 X20 Y10 Z20 ${tilt}0 C0\nM428\nG1 X35 Y24 Z32 ${tilt}25 C40 F900\nG1 X24 Y16 Z24 ${tilt}-20 C-30 F900\nM2\n`; send('PROGRAM_LOAD', { source, path: `five-axis-${id}.ngc` }); send('POWER', { active: true }); send('HOME', { all: true }); for (let tick = 0; tick < 500 && !session.wasmHomeLimit.read().state.homed; tick++) send('SIM_STEP', { cycles: 1 }); assert.equal(session.wasmHomeLimit.read().state.homed, true); send('PROGRAM_RUN'); const samples = []; let simultaneousTicks = 0, previous, result; for (let tick = 0; tick < 3000; tick++) { result = send('SIM_STEP', { cycles: 10 }); const panel = result.payload.panel_snapshot; const joints = panel.visual.joints; if (panel.kinematics.active_type === 1 && joints?.length === 5) { if (previous && joints.every((v, i) => Math.abs(v - previous[i]) > 1e-8)) simultaneousTicks++; samples.push({ tick: result.simulation_tick, pose: panel.visual.pose, joints, offsets: panel.kinematics.offsets }); previous = joints; } if (result.payload.program.program_status === 'completed' && result.payload.wasm_motion.state.queue_depth === 0) break; } assert.equal(result.payload.program.program_status, 'completed'); assert.ok(simultaneousTicks > 10, `only ${simultaneousTicks} simultaneously moving ticks`); assert.ok(samples.length > 10); const projection = await page.evaluate(async (samples) => { const model = (await import('/src/imported-machine.ts')).buildImportedMachine(window.motionAsset); let maxError = 0; for (const sample of samples) { model.update(sample); const point = model.workOrigin.localToWorld(model.toolTip.position.clone().set(sample.pose.x / 1000, sample.pose.y / 1000, sample.pose.z / 1000)); const tip = model.toolTip.getWorldPosition(point.clone()); maxError = Math.max(maxError, point.distanceTo(tip)); } model.dispose(); return { samples: samples.length, max_error_view_units: maxError }; }, samples); assert.ok(projection.max_error_view_units < 1e-7, JSON.stringify(projection)); report.machines.push({ coordinates: structure.coordinates, source, simultaneous_ticks: simultaneousTicks, ...projection, trajectory: samples }); } report.status = 'verified'; } catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; } finally { await writeFile(new URL('motion.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(({ coordinates, simultaneous_ticks, samples, max_error_view_units }) => ({ coordinates, simultaneous_ticks, samples, max_error_view_units })) }));