Add AC/BC RTCP programs and 3D machine manager

This commit is contained in:
2026-09-20 12:31:12 +08:00
parent 1c1e5f9672
commit 8aab531b02
363 changed files with 53213 additions and 58 deletions

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
import { mkdir, writeFile } from "node:fs/promises";
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
import { GLTFExporter } from "../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js";
globalThis.FileReader = class {
readAsArrayBuffer(blob) { blob.arrayBuffer().then((result) => { this.result = result; this.onloadend?.(); }); }
readAsDataURL(blob) { blob.arrayBuffer().then((result) => { this.result = `data:${blob.type};base64,${Buffer.from(result).toString("base64")}`; this.onloadend?.(); }); }
};
const partGlb = async (index) => {
const scene = new THREE.Scene(); scene.name = `part_${index}`;
const mesh = new THREE.Mesh(new THREE.BoxGeometry(.08 + index * .004, .06, .05), new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(index / 8, .55, .5) })); mesh.name = `mesh_${index}`; scene.add(mesh);
const bytes = await new GLTFExporter().parseAsync(scene, { binary: true, trs: true, onlyVisible: false }); mesh.geometry.dispose(); mesh.material.dispose(); return Buffer.from(bytes);
};
const fixtures = await Promise.all(Array.from({ length: 8 }, async (_, index) => ({ name: `${String(index + 1).padStart(2, "0")}-part.glb`, mimeType: "model/gltf-binary", buffer: await partGlb(index) })));
const modulePath = process.env.PLAYWRIGHT_MODULE;
if (!modulePath) { console.log(JSON.stringify({ schema: "machine-manager-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); }
const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.default ?? imported;
const output = new URL("../coverage/glb-machine-manager/", import.meta.url); await mkdir(output, { recursive: true });
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
const control = (id) => page.locator(`[data-axis-control="${id}"]`); const main = page.locator("main.axis-app");
const production = process.env.MACHINE_MANAGER_SOURCE_MODULES === "0";
const report = { schema: "machine-manager-browser.v1", report_id: production ? "GLB-MGR-E02" : "GLB-MGR-E01", mode: production ? "production-preview" : "development-source", status: "running", checks: [] };
try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5174/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 45000 });
await control("machine-manager-settings").click(); assert.equal(await page.locator(".machine-manager-dialog[open]").count(), 1); assert.equal(await page.locator("[data-machine-manager-preview] canvas").count(), 1);
await control("machine-manager-files").setInputFiles(fixtures);
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
assert.equal(await page.locator(".machine-part-list > div").count(), 8); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8");
report.checks.push("eight GLB parts imported and rendered in the Three.js editor");
await control("machine-manager-show-origin").uncheck(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "7");
const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10");
const rotation = page.locator("fieldset.machine-vector").filter({ hasText: "部件旋转" }); await rotation.getByLabel("Z", { exact: true }).fill("15");
await control("machine-manager-rotate").click(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-transform-mode"), "rotate");
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname });
report.checks.push("origin visibility, numeric translation/rotation and TransformControls mode update");
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");
const zeros = page.locator("fieldset.machine-zero"); await zeros.getByLabel("X", { exact: true }).fill("5"); await zeros.getByLabel("A", { exact: true }).fill("10"); await zeros.getByLabel("C", { exact: true }).fill("20");
assert.equal(await control("machine-manager-export").isDisabled(), false); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb");
report.checks.push("validated assembly exports a self-contained managed GLB");
assert.equal(await control("machine-manager-apply").isDisabled(), false); await control("machine-manager-apply").click();
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineManagerSchema === "linuxcnc.machine-manager/1" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 });
const geometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry"));
assert.deepEqual(geometry.cOriginMm, [24, -12, 8]); assert.deepEqual(geometry.tiltOffsetMm, [0, 18, -45]); assert.equal(geometry.physicalZero.x, 5); assert.equal(geometry.physicalZero.a, 10); assert.equal(geometry.physicalZero.c, 20); assert.equal(geometry.parts.length, 8); assert.equal(geometry.parts[0].translationMm[0], 10);
assert.match(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-model-source-path"), /^managed-xyzac\.glb$/);
report.checks.push("validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData");
await page.keyboard.press("Escape");
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/);
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();
await page.waitForFunction(() => { const view = document.querySelector(".xyzac-machine-view"); if (view?.dataset.machineManagerSchema !== "linuxcnc.machine-manager/1") return false; try { return JSON.parse(view.dataset.machineManagerGeometry).coordinates === "XYZBC" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false"; } catch { return false; } }, null, { timeout: 45000 });
const bcGeometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry")); assert.deepEqual(bcGeometry.cOriginMm, [-18, 14, 12]); assert.deepEqual(bcGeometry.tiltOffsetMm, [22, 0, -40]); assert.equal(bcGeometry.physicalZero.b, 12);
report.checks.push("same component assembly switches to BC hierarchy and applies BC geometry");
await page.keyboard.press("Escape"); await control("toolbar-power").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisStateMachine === "ON"); await control("manual-home-all").click();
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisHomed === "[true,true,true,true,true]", null, { timeout: 45000 }); await control("toolbar-run").click();
await page.waitForFunction(() => { const main = document.querySelector("main.axis-app"); const joints = JSON.parse(document.querySelector(".xyzac-machine-view")?.dataset.xyzacJoints || "[0,0,0,0,0]"); return Number(main?.dataset.axisCurrentLine || 0) > 0 && joints.some((value) => Math.abs(value) > 1e-6); }, null, { timeout: 45000 });
const runningJoints = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-joints")); assert.equal(runningJoints.length, 5); await page.locator(".axis-preview-machine").screenshot({ path: new URL("managed-bc-running.png", output).pathname }); await control("toolbar-stop").click();
report.checks.push("BC five-axis program drives managed model from authoritative WASM joints");
if (!production) {
const projection = await page.evaluate(async () => {
const manager = await import("/src/machine-manager.ts"); const importedMachine = await import("/src/imported-machine.ts");
const sources = await Promise.all(Array.from({ length: 8 }, async (_, index) => manager.loadMachinePart(await (await fetch("/machines/demo-ac-xyz-head.glb")).arrayBuffer(), `source-${index}.glb`)));
const roles = manager.MACHINE_PART_ROLES; const drafts = sources.map((source, index) => { const role = roles[index]; const parentRole = manager.expectedParentRole(role); return { ...source, role, parentId: parentRole ? sources[roles.indexOf(parentRole)].id : null, visible: true, showOrigin: true, originMm: [0, 0, 0], translationMm: [0, 0, 0], rotationDeg: [0, 0, 0] }; });
const run = async (coordinates, cOriginMm, tiltOffsetMm, physicalZero, joints) => {
const asset = await manager.buildManagedMachine({ coordinates, parts: drafts, cOriginMm, tiltOffsetMm, physicalZero }); const built = importedMachine.buildImportedMachine(asset);
built.update({ joints, offsets: { x_rot_point: cOriginMm[0], y_rot_point: cOriginMm[1], z_rot_point: cOriginMm[2], x_offset: tiltOffsetMm[0], y_offset: tiltOffsetMm[1], z_offset: tiltOffsetMm[2], tool_offset: 0 } });
const link = (name) => { let found = null; built.machine.traverse((node) => { if (!found && node.userData.link === name) found = node; }); return found; };
const result = { xMetres: link("x").position.x, tiltQuaternion: link("tilt").quaternion.toArray(), cQuaternion: link("c").quaternion.toArray(), baseData: link("base").userData.linuxcncMachineGeometry };
built.dispose(); importedMachine.disposeMachineScene(asset.scene); return result;
};
let invalid = ""; try { manager.validateMachineAssembly("XYZAC", drafts.map((part, index) => index === 1 ? { ...part, parentId: null } : part)); } catch (error) { invalid = error.message; }
const ac = await run("XYZAC", [24, -12, 8], [0, 18, -45], { x: 5, y: 0, z: 0, a: 10, b: 0, c: 20 }, [15, 0, 0, 30, 50]);
const bc = await run("XYZBC", [-18, 14, 12], [22, 0, -40], { x: 2, y: 0, z: 0, a: 0, b: 12, c: 5 }, [7, 0, 0, 32, 35]);
sources.forEach((source) => importedMachine.disposeMachineScene(source.scene)); return { ac, bc, invalid };
});
const angle = (quaternion) => 2 * Math.acos(Math.min(1, Math.abs(quaternion[3])));
assert.ok(Math.abs(projection.ac.xMetres - .01) < 1e-9); assert.ok(Math.abs(angle(projection.ac.tiltQuaternion) - 20 * Math.PI / 180) < 1e-9); assert.ok(Math.abs(angle(projection.ac.cQuaternion) - 30 * Math.PI / 180) < 1e-9); assert.deepEqual(projection.ac.baseData.cOriginMm, [24, -12, 8]);
assert.ok(Math.abs(projection.bc.xMetres - .005) < 1e-9); assert.ok(Math.abs(angle(projection.bc.tiltQuaternion) - 20 * Math.PI / 180) < 1e-9); assert.ok(Math.abs(angle(projection.bc.cQuaternion) - 30 * Math.PI / 180) < 1e-9); assert.deepEqual(projection.bc.baseData.tiltOffsetMm, [22, 0, -40]); assert.match(projection.invalid, /必须挂到/);
report.checks.push("AC/BC WASM joint projection subtracts physical zero, preserves userData and rejects invalid hierarchy");
} else report.checks.push("production bundle exercised through AC/BC UI apply and WASM program motion");
assert.deepEqual(errors, []); report.status = "verified";
} catch (error) { report.status = "failed"; report.error = error instanceof Error ? error.stack : String(error); await page.screenshot({ path: "/tmp/machine-manager-failure.png", fullPage: true }); throw error; }
finally { report.browser_errors = errors; report.final = await main.evaluate((element) => ({ program: element.dataset.axisProgramStatus, machine: element.dataset.axisStateMachine, path: element.dataset.axisProgramPath })).catch(() => null); await writeFile(new URL(production ? "browser-production.report.json" : "browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }
console.log(JSON.stringify(report));