Configure single-DOF machine joints and drive them from WASM axes

This commit is contained in:
2026-09-21 11:24:22 +08:00
parent 989866b3ab
commit d8488641ca
32 changed files with 12058 additions and 215 deletions

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { readFile, mkdir, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
const pw = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? "/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js"));
const browser = await (pw.default ?? pw).chromium.launch({ headless: true, executablePath: "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const output = new URL("../coverage/glb-machine-manager/joint-dof/", import.meta.url); await mkdir(output, { recursive: true });
const bytes = await readFile(new URL("../MILL700-U_1.glb", import.meta.url));
const report = { report_id: "GLB-MGR-E13", status: "running", url: process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5180/", machines: [], browser_errors: [] };
const near = (a, b, label) => { assert.equal(a.length, b.length, label); a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 2e-6, `${label} ${i}: ${v} vs ${b[i]}`)); };
try {
for (const coordinates of (process.env.JOINT_TEST_COORDINATES ?? "XYZAC,XYZBC").split(",")) {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.on("pageerror", (e) => report.browser_errors.push(e.message));
page.on("console", (m) => { if (["error", "warning"].includes(m.type())) report.browser_errors.push(m.text()); });
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
const preview = page.locator("[data-machine-manager-preview]");
const idle = () => page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 60000 });
await page.goto(report.url); await idle();
if (coordinates === "XYZBC") { await c("workspace-case-select").selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); await idle(); }
await c("machine-manager-settings").click(); await c("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: bytes });
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
assert.equal(await c("machine-joint-value").isDisabled(), true);
assert.equal(await c("machine-manager-apply").isDisabled(), true);
assert.equal(await c("machine-manager-translation").count(), 0); assert.equal(await c("machine-manager-rotation").count(), 0);
const choose = async (name) => {
const node = c("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^${name}$`) }) }).first();
const path = await node.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path"); await node.dispatchEvent("click");
await page.waitForFunction((path) => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerSelectedJoint?.endsWith(`:${path}`), path);
return JSON.parse(await preview.getAttribute("data-machine-manager-selected-transform"));
};
const names = { X: "MILL700-A-主轴-JT1", Y: "MILL700-A-主轴-JT2", Z: "MILL700-A-主轴-JT3", [coordinates[3]]: "MILL700-A-工作台-JT1", C: "MILL700-A-工作台-JT2" };
for (const [axis, name] of Object.entries(names)) { await choose(name); await c("machine-tree-axis-role").selectOption(axis); }
await choose("MILL700-A底座"); await c("machine-manager-role").selectOption("base");
assert.equal(await c("machine-manager-apply").isDisabled(), false);
const parent = await choose(names.Y), sibling = await choose("SHS25C100020_Track_TSXXX-0000058522002"), child = await choose("MILL700-A-主轴-JT4"), zeroZ = await choose(names.Z);
await c("machine-joint-direction").selectOption("x"); await c("machine-joint-sign").selectOption("-1"); await c("machine-joint-value").fill("30");
const movedZ = await choose(names.Z); near(movedZ.local, [zeroZ.local[0] - .03, ...zeroZ.local.slice(1)], "single parent X translation"); near(movedZ.localQuaternion, zeroZ.localQuaternion, "locked rotation");
assert.deepEqual(await choose(names.Y), parent); assert.deepEqual(await choose("SHS25C100020_Track_TSXXX-0000058522002"), sibling);
const movedChild = await choose("MILL700-A-主轴-JT4"); near(movedChild.world, [child.world[0] - .03, ...child.world.slice(1)], "child follows only configured DOF");
await choose(names.Z); await c("machine-joint-value").fill("30"); near((await choose(names.Z)).local, movedZ.local, "absolute, no accumulated movement");
await c("machine-joint-value").fill("0"); await c("machine-joint-direction").selectOption("z");
await choose("MILL700-A-主轴-JT4"); await c("machine-tree-axis-role").selectOption("Z"); assert.match(await page.locator(".machine-manager-footer").textContent(), /重复|已绑定/); assert.equal(await c("machine-tree-axis-role").inputValue(), "");
const rotaryZero = await choose(names[coordinates[3]]); await c("machine-joint-value").fill("25"); const rotaryMoved = await choose(names[coordinates[3]]); near(rotaryMoved.local, rotaryZero.local, "rotation pivot fixed");
const axis = coordinates === "XYZAC" ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
near(rotaryMoved.localQuaternion, new THREE.Quaternion().setFromAxisAngle(axis, 25 * Math.PI / 180).multiply(new THREE.Quaternion(...rotaryZero.localQuaternion)).toArray(), "one rotational DOF");
await c("machine-test-reset").click();
const zeroFields = page.locator("fieldset.machine-zero"); await zeroFields.getByLabel("X", { exact: true }).fill("5"); await zeroFields.getByLabel("Z", { exact: true }).fill("7"); await zeroFields.getByLabel(coordinates[3], { exact: true }).fill("12"); await zeroFields.getByLabel("C", { exact: true }).fill("9");
const jsonDownload = page.waitForEvent("download"); await c("machine-manager-export-json").click(); const json = JSON.parse(await readFile(await (await jsonDownload).path(), "utf8"));
assert.equal(json.schema, "linuxcnc.joint-model/1"); assert.equal(json.coordinates, coordinates); assert.deepEqual(Object.keys(json.axisMap).sort(), [...coordinates].sort()); assert.equal(json.nodes.filter((n) => n.joint).length, 5); assert.ok(json.nodes.every((n) => n.joint === null || n.joint.dof === 1));
assert.equal(json.nodes.find((n) => n.id === json.axisMap.Z).joint.sign, -1);
const glbDownload = page.waitForEvent("download"); await c("machine-manager-export").click(); const glb = await readFile(await (await glbDownload).path());
const gltf = JSON.parse(glb.toString("utf8", 20, 20 + glb.readUInt32LE(12)));
assert.deepEqual(gltf.scenes[0].extras.linuxcncJointModel, json);
const bed = gltf.nodes.find((n) => n.name === "MILL700-A底座"); assert.deepEqual(bed.extras.linuxcncJointModel, json); assert.ok(bed.extras.linuxcncMachineGeometry);
await writeFile(new URL(`${coordinates.toLowerCase()}.json`, output), JSON.stringify(json, null, 2) + "\n");
await c("machine-manager-delete").click(); await c("machine-manager-files").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
await choose(names.Z); assert.equal(await c("machine-tree-axis-role").inputValue(), "Z"); assert.equal(await c("machine-joint-sign").inputValue(), "-1"); assert.equal(await zeroFields.getByLabel("Z", { exact: true }).inputValue(), "7");
console.log(`${coordinates}: GLB export and editor roundtrip verified`);
// Standard machine-import route validates exported GLB, JSON hierarchy and zero restoration.
await page.keyboard.press("Escape"); await c("machine-glb-settings").click(); await c("machine-glb-file").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineJointState, null, { timeout: 60000 }); await idle(); await page.keyboard.press("Escape");
console.log(`${coordinates}: standard GLB importer verified`);
// Apply directly from the restored one-file hierarchy as well.
await c("machine-manager-settings").click(); await c("machine-manager-apply").click(); await idle(); assert.match(await page.locator(".machine-manager-footer").textContent(), /已应用/); await page.keyboard.press("Escape");
const program = `M428
G21 G90 G94 G17 G40 G49
G1 X20 Y10 Z10 ${coordinates[3]}20 C30 F120
G1 X-10 Y-5 Z-5 ${coordinates[3]}-10 C-20 F120
M2
`;
await c("program-file-input").setInputFiles({ name: `joint-map-${coordinates.toLowerCase()}.ngc`, mimeType: "text/plain", buffer: Buffer.from(program) }); await idle();
console.log(`${coordinates}: apply verified; starting simultaneous five-axis WASM motion`);
await c("toolbar-power").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisStateMachine === "ON"); await c("manual-home-all").click();
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisHomed === "[true,true,true,true,true]", null, { timeout: 60000 }); await c("toolbar-run").click();
await page.waitForFunction(() => {
const view = document.querySelector(".xyzac-machine-view");
const q = JSON.parse(view?.dataset.machineJointInputs ?? "[]");
return Number(document.querySelector("main.axis-app")?.dataset.axisCurrentLine) > 0 && q.some((v) => Math.abs(v) > 1e-6);
}, null, { timeout: 60000 });
const samples = [];
for (let i = 0; i < 8; i++) {
if (samples.length) await page.waitForFunction((tick) => Number(document.querySelector(".xyzac-machine-view")?.dataset.machineJointTick) > tick, samples.at(-1).tick, { timeout: 60000 });
const sample = await page.locator(".xyzac-machine-view").evaluate((el) => ({ tick: Number(el.dataset.machineJointTick), q: JSON.parse(el.dataset.machineJointInputs), nodes: JSON.parse(el.dataset.machineJointState) }));
for (const [index, axisName] of [...coordinates].entries()) {
const state = sample.nodes[axisName]; const original = json.nodes.find((n) => n.joint?.axis === axisName); const d = original.joint;
const q = d.sign * (sample.q[index] - json.calibration.physicalZero[axisName.toLowerCase()]); const direction = new THREE.Vector3(...["x", "y", "z"].map((a) => a === d.direction ? 1 : 0));
if ("XYZ".includes(axisName)) { near(state.localMm, new THREE.Vector3(...original.zero.positionMm).addScaledVector(direction, q).toArray(), `${axisName} runtime position`); near(state.quaternion, original.zero.quaternion, `${axisName} locked orientation`); }
else { near(state.localMm, original.zero.positionMm, `${axisName} fixed pivot`); near(state.quaternion, new THREE.Quaternion().setFromAxisAngle(direction, q * Math.PI / 180).multiply(new THREE.Quaternion(...original.zero.quaternion)).toArray(), `${axisName} runtime rotation`); }
}
samples.push(sample);
}
for (let axisIndex = 0; axisIndex < 5; axisIndex++) assert.ok(samples.some((s) => Math.abs(s.q[axisIndex] - samples[0].q[axisIndex]) > 1e-6), `${coordinates[axisIndex]} live movement required`);
await c("machine-manager-settings").click(); await c("machine-joint-follow-live").check(); assert.equal(await c("machine-joint-value").isDisabled(), true);
await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-joint-live-state"]')?.textContent.includes("WASM tick"));
const liveSample = await page.evaluate(() => ({ q: JSON.parse(document.querySelector('[data-axis-control="machine-test-state"]').textContent), joints: JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms) }));
const z = json.nodes.find((n) => n.joint?.axis === "Z"); near(liveSample.joints.Z.localMm, [z.zero.positionMm[0], z.zero.positionMm[1], z.zero.positionMm[2] - liveSample.q.z], "dialog follows live Z sign");
await page.screenshot({ path: new URL(`${coordinates.toLowerCase()}-live.png`, output).pathname });
report.machines.push({ coordinates, checks: ["fixed or single DOF", "direction/sign and parent isolation", "pivot and child inheritance", "duplicate axis rejected", "JSON on scene and real bed", "GLB roundtrip restores calibration and bindings", "single-file apply", "WASM runtime axis mapping", "live dialog follows actual joints"], nodeCount: json.nodes.length, samples, liveSample });
await page.close();
}
assert.deepEqual(report.browser_errors, []); report.status = "verified";
} catch (e) { report.status = "failed"; report.error = e.stack; for (const page of browser.contexts().flatMap((c) => c.pages())) { report.pageText = await page.locator("body").innerText().catch(() => ""); await page.screenshot({ path: new URL("failure.png", output).pathname }).catch(() => {}); } throw e; }
finally { 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) => ({ coordinates: m.coordinates, checks: m.checks, samples: m.samples.length })), browser_errors: report.browser_errors }));