Files
linuxcncWebWASM/scripts/test_glb_machine_browser.mjs

202 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { WasmXyzacKinematics } from '../web/worker/wasm-xyzac-kinematics.mjs';
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);
await mkdir(output, { recursive: true });
const wasmBinary = await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root));
const core = await createModule({ wasmBinary });
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({ viewport: { width: 1440, height: 1100 } });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const report = { report_id: 'glb-machine-browser-v1', status: 'running', wasm_sha256: createHash('sha256').update(wasmBinary).digest('hex'), parser: [], machines: [], viewports: [] };
const control = (id) => page.locator(`[data-axis-control="${id}"]`), machine = page.locator('.xyzac-machine-view');
const settled = async () => { await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false'); };
const rendered = async () => { await page.waitForFunction(() => { const e = document.querySelector('.xyzac-machine-view'); return e?.dataset.xyzacTick === e?.dataset.xyzacRenderedTick; }); };
const sample = () => machine.evaluate((e) => ({ joints: JSON.parse(e.dataset.xyzacJoints), offsets: JSON.parse(e.dataset.xyzacOffsets), tip: JSON.parse(e.dataset.xyzacToolTip), tick: Number(e.dataset.xyzacTick), hash: e.dataset.machineGlb }));
const jog = async (axis, index, delta) => {
await control(`manual-axis-${axis}`).check(); const before = await sample();
const box = await control('manual-jog-positive').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down();
try { await page.waitForFunction(({ index, target }) => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[index] >= target, { index, target: before.joints[index] + delta }); }
finally { await page.mouse.up(); }
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent.includes('JOG_STOP acknowledged') || [...document.querySelectorAll('span')].some((s) => s.textContent === 'JOG_STOP acknowledged'));
await rendered(); return { axis, before, after: await sample() };
};
try {
await page.goto(process.env.XYZAC_PANEL_URL ?? 'http://127.0.0.1:5174/'); await settled();
// Exercise the browser's actual XML/GLB parser and GLTFLoader before touching the live machine.
for (const id of ['ac', 'bc']) {
const descriptor = await page.evaluate(async (id) => {
const api = await import('/src/imported-machine.ts');
const parser = await import('/src/machine-urdf.ts');
const bytes = await (await fetch(`/machines/demo-${id}-xyz-head.glb`)).arrayBuffer();
const { json, structure } = parser.inspectMachineGlb(bytes);
const results = [];
const reject = (name, action) => { let error = ''; try { action(); } catch (e) { error = e.message; } if (!error) throw new Error(`must reject ${name}`); results.push({ name, error }); };
const mutate = (change) => {
const next = structuredClone(json); change(next);
const view = new DataView(bytes), oldJsonLength = view.getUint32(12, true);
const bin = new Uint8Array(bytes, 20 + oldJsonLength);
const text = new TextEncoder().encode(JSON.stringify(next)); const length = Math.ceil(text.length / 4) * 4;
const data = new Uint8Array(20 + length + bin.length); const header = new DataView(data.buffer);
header.setUint32(0, 0x46546c67, true); header.setUint32(4, 2, true); header.setUint32(8, data.length, true);
header.setUint32(12, length, true); header.setUint32(16, 0x4e4f534a, true); data.fill(32, 20, 20 + length); data.set(text, 20); data.set(bin, 20 + length);
return () => parser.inspectMachineGlb(data.buffer);
};
reject('truncated', () => parser.inspectMachineGlb(bytes.slice(0, bytes.byteLength - 1)));
reject('missing-schema', mutate((j) => { delete j.scenes[0].extras; }));
reject('external-buffer', mutate((j) => { j.buffers[0].uri = 'https://invalid.example/data.bin'; }));
reject('external-texture', mutate((j) => { j.images = [{ uri: 'file:///etc/passwd' }]; }));
reject('animation', mutate((j) => { j.animations = [{}]; }));
reject('node-cycle', mutate((j) => { j.nodes[0].children = [0]; }));
reject('wrong-hierarchy', mutate((j) => { const i = j.nodes.findIndex((n) => n.name === 'c_table'); j.nodes.find((n) => n.name === 'tilt_table').children = j.nodes.find((n) => n.name === 'tilt_table').children.filter((n) => n !== i); j.nodes.find((n) => n.name === 'base').children.push(i); }));
reject('node-offset-mismatch', mutate((j) => { j.nodes.find((n) => n.name === 'c_table').translation[2] += .01; }));
reject('duplicate-link', mutate((j) => { j.nodes.find((n) => n.name?.startsWith('bed_')).name = 'base'; }));
const matrixCompatible = mutate((j) => {
const node = j.nodes.find((n) => n.name === 'c_table'), v = node.translation;
delete node.translation; delete node.rotation; delete node.scale;
node.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, ...v, 1];
});
if (matrixCompatible().structure.coordinates !== structure.coordinates) throw new Error('equivalent matrix rejected');
reject('matrix-origin-mismatch', mutate((j) => {
const node = j.nodes.find((n) => n.name === 'c_table'); delete node.translation; delete node.rotation; delete node.scale;
node.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 4, 5, 6, 1];
}));
const xml = structure.urdf;
reject('xml-syntax', () => parser.parseMachineUrdf(xml.slice(0, -15)));
reject('doctype', () => parser.parseMachineUrdf('<!DOCTYPE robot>' + xml));
reject('duplicate-coordinate', () => parser.parseMachineUrdf(xml.replace('coordinate="Y"', 'coordinate="X"')));
reject('negative-axis', () => parser.parseMachineUrdf(xml.replace('axis xyz="1 0 0"', 'axis xyz="-1 0 0"')));
reject('nonzero-rpy', () => parser.parseMachineUrdf(xml.replace('rpy="0 0 0"', 'rpy="0 0 0.1"')));
reject('nan-limit', () => parser.parseMachineUrdf(xml.replace('velocity="0.02"', 'velocity="NaN"')));
reject('mimic', () => parser.parseMachineUrdf(xml.replace('</joint>', '<mimic joint="x_joint"/></joint>')));
reject('unknown-profile', () => parser.parseMachineUrdf(xml.replace('trt-xyz-head/1', 'generic/1')));
reject('broken-tree', () => parser.parseMachineUrdf(xml.replace('parent link="base"', 'parent link="spindle_head"')));
const bundle = await (await import('/workspace/sim-case-loader.mjs')).loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id}-trt.ini`, baseUrl: '.' });
const tight = structuredClone(structure); tight.joints.find((j) => j.coordinate === 'X').limit.upper = .01;
reject('runtime-range-outside-model', () => api.configureImportedMachine(bundle, tight));
const slow = structuredClone(structure); slow.joints.find((j) => j.coordinate === 'X').limit.velocity = .001;
reject('runtime-speed-outside-model', () => api.configureImportedMachine(bundle, slow));
const changed = api.configureImportedMachine(bundle, structure);
const parsed = (await import('/workspace/trt-geometry.mjs')).readTrtGeometry(changed);
window.glbTestAsset && api.disposeMachineScene(window.glbTestAsset.scene);
window.glbTestAsset = await api.loadImportedMachine(bytes, `demo-${id}.glb`);
return { structure, hash: window.glbTestAsset.hash, results, writtenOffsets: parsed.offsets };
}, id);
report.parser.push({ id, rejected: descriptor.results });
for (const [key, value] of Object.entries(descriptor.structure.offsets)) assert.ok(Math.abs(descriptor.writtenOffsets[key] - value) < 1e-8);
const coordinates = descriptor.structure.coordinates;
// The real native inverse supplies the reference, not a duplicate JS RTCP formula.
const samples = [];
for (const tool_offset of [0, 17, -4]) {
const offsets = { ...descriptor.structure.offsets, tool_offset };
const native = new WasmXyzacKinematics(core, { coordinates, ...offsets, limits: Object.fromEntries([...coordinates.toLowerCase()].map((c) => [c, [-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, [coordinates[3].toLowerCase()]: tilt };
const inverse = native.inverseMode(1, pose); assert.equal(inverse.status, 'verified');
samples.push({ pose, offsets, joints: inverse.joints });
}
native.destroy();
}
const geometry = await page.evaluate(async (samples) => {
const api = await import('/src/imported-machine.ts');
const model = api.buildImportedMachine(window.glbTestAsset), rows = [];
for (const state of samples) {
model.update(state);
const point = model.workOrigin.position.clone().set(state.pose.x / 1000, state.pose.y / 1000, state.pose.z / 1000);
model.workOrigin.localToWorld(point);
const tip = model.toolTip.getWorldPosition(point.clone());
const error = point.distanceTo(tip);
if (error > 1e-8) throw new Error(`WASM / GLB mismatch: ${JSON.stringify({ state, point, tip, error })}`);
rows.push({ tilt: state.joints[3], c: state.joints[4], tool_offset: state.offsets.tool_offset, error });
}
// The two branches are independent: X moves the head but never the cradle.
model.update({ joints: [0, 0, 0, 0, 0], offsets: { tool_offset: 0 } });
const before = model.joints.rotate.getWorldPosition(model.toolTip.position.clone());
model.update({ joints: [50, 20, 10, 0, 0], offsets: { tool_offset: 0 } });
const after = model.joints.rotate.getWorldPosition(before.clone());
if (before.distanceTo(after) > 1e-10) throw new Error('table moved with XYZ');
model.dispose(); return rows;
}, samples);
// Confirm the Worker reparses the updated INI and receives every derived geometry value.
const definition = MACHINE_DEFINITIONS.find((m) => m.coordinates === coordinates);
const original = await loadSimulationCase({ path: definition.entrypoint, fetchImpl });
const config = toWorkerConfiguration(writeTrtGeometry(original, descriptor.structure.offsets));
config.kinematics.offsets.x_rot_point = 999;
const session = new SimSession(core, { sessionId: `glb-${id}` }); let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `glb-${seq}`, payload });
send('HELLO'); send('SESSION_ATTACH');
const loaded = send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: config }); assert.equal(loaded.kind, 'COMMAND_RESULT');
for (const [key, value] of Object.entries(descriptor.structure.offsets)) assert.ok(Math.abs(loaded.payload.panel_snapshot.kinematics.offsets[key] - value) < 1e-8);
const beforePath = await page.locator('main').getAttribute('data-axis-program-path');
await control('machine-glb-settings').click();
await control('machine-glb-file').setInputFiles(new URL(`web/react/public/machines/demo-${id}-xyz-head.glb`, root).pathname);
await settled();
assert.equal(await machine.getAttribute('data-machine-glb'), descriptor.hash, await page.locator('.machine-import').innerText());
assert.equal(await machine.getAttribute('data-xyzac-model-id'), `xyz${id}-trt`);
assert.equal(await page.locator('main').getAttribute('data-axis-program-path'), beforePath);
const imported = await sample();
for (const [key, value] of Object.entries(descriptor.structure.offsets)) assert.ok(Math.abs(imported.offsets[key] - value) < 1e-8);
// Rejected import must leave the live machine and its configuration untouched.
await control('machine-glb-file').setInputFiles({ name: 'broken.glb', mimeType: 'model/gltf-binary', buffer: Buffer.from('not a glb') }); await settled();
assert.match(await page.locator('.machine-import output').innerText(), /无效/);
assert.equal((await sample()).hash, descriptor.hash); assert.deepEqual((await sample()).offsets, imported.offsets);
await control(`machine-glb-${id}`).click(); await settled();
assert.equal((await sample()).hash, descriptor.hash);
assert.match(await page.locator('.machine-import output').innerText(), /模型及参数已应用/);
const downloadEvent = page.waitForEvent('download'); await page.getByRole('link', { name: `下载 ${id.toUpperCase()}` }).click(); const download = await downloadEvent;
assert.equal(createHash('sha256').update(await readFile(await download.path())).digest('hex'), descriptor.hash);
await page.locator('.model-dialog .machine-dialog-titlebar button').click();
await control('toolbar-power').click();
await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
assert.equal(await control('machine-glb-ac').isDisabled(), true); assert.equal(await control('machine-glb-bc').isDisabled(), true);
await control('manual-home-all').click(); await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
await jog('x', 0, 4); await jog('y', 1, 3);
await control('switchkins-1').click(); await page.waitForFunction(() => document.querySelector('[data-axis-control="switchkins-1"]').getAttribute('aria-pressed') === 'true');
await rendered(); const start = await sample();
const target = await page.evaluate(async (state) => { const m = (await import('/src/imported-machine.ts')).buildImportedMachine(window.glbTestAsset); m.update(state); const p = m.workOrigin.worldToLocal(m.toolTip.getWorldPosition(m.toolTip.position.clone())).toArray(); m.dispose(); return p; }, start);
const jogs = [];
for (const axis of [coordinates[3].toLowerCase(), 'c', coordinates[3].toLowerCase(), 'c']) {
const row = await jog(axis, axis === 'c' ? 4 : 3, 2);
assert.ok(row.after.joints.slice(0, 3).some((v, i) => Math.abs(v - row.before.joints[i]) > 1e-6));
const check = await page.evaluate(async ({ state, target }) => {
const m = (await import('/src/imported-machine.ts')).buildImportedMachine(window.glbTestAsset); m.update(state);
const p = m.workOrigin.localToWorld(m.toolTip.position.clone().fromArray(target)), tip = m.toolTip.getWorldPosition(p.clone());
const result = { tcp_error: p.distanceTo(tip), render_error: tip.distanceTo(p.fromArray(state.tip)) }; m.dispose(); return result;
}, { state: row.after, target });
assert.ok(check.tcp_error < 1e-7); assert.ok(check.render_error < 1e-7);
jogs.push({ ...row, ...check });
}
const pixels = await machine.locator('canvas[data-engine]').evaluate((canvas) => { const gl = canvas.getContext('webgl2'), data = new Uint8Array(canvas.width * canvas.height * 4); gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, data); const colors = new Set(); for (let i = 0; i < data.length; i += 32) colors.add(`${data[i]},${data[i + 1]},${data[i + 2]}`); return { colors: colors.size, width: canvas.width, height: canvas.height }; });
assert.ok(pixels.colors > 100);
await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}-imported.png`, output).pathname });
await control('toolbar-power').click(); await page.waitForFunction(() => !document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
report.machines.push({ id, hash: descriptor.hash, coordinates, offsets: descriptor.structure.offsets, program_preserved: beforePath, geometry, jogs, pixels, worker_derived_offsets: loaded.payload.panel_snapshot.kinematics.offsets });
}
for (const [width, height] of [[1440, 1100], [390, 844]]) {
await page.setViewportSize({ width, height }); await page.locator('.machine-import').scrollIntoViewIfNeeded();
await control('machine-glb-settings').click();
const bounds = await page.locator('.model-dialog button, .model-dialog a').evaluateAll((nodes) => nodes.map((n) => { const r = n.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width }; }));
assert.ok(bounds.every((r) => r.width > 0 && r.x >= -1 && r.right <= width + 1));
await page.screenshot({ path: new URL(`viewport-${width}.png`, output).pathname, fullPage: true });
report.viewports.push({ width, height, bounds });
await page.keyboard.press('Escape');
}
// Reloading a compatible INI updates the effective structure while preserving the imported asset.
const importedHash = await machine.getAttribute('data-machine-glb');
await control('geometry-settings').click();
await control('geometry-reload').click(); await settled(); assert.equal(await machine.getAttribute('data-machine-glb'), importedHash);
assert.deepEqual(errors, []); report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); await page.screenshot({ path: new URL('failure.png', output).pathname, fullPage: true }); throw error; }
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, geometry_samples: m.geometry.length, jogs: m.jogs.length })), rejected: report.parser.reduce((sum, p) => sum + p.rejected.length, 0) }));