import assert from "node:assert/strict"; import { pathToFileURL } from "node:url"; import { mkdir, readFile, 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 (["warning", "error"].includes(message.type())) errors.push(`console:${message.type()}:${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('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1.45"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1"); report.checks.push("eight GLB parts rendered with studio PMREM lighting and calibrated exposure"); const roots = page.locator('[data-machine-tree-path="scene"]'); const sourceRoot = roots.nth(7), targetRoot = roots.nth(6), sourcePart = await sourceRoot.getAttribute("data-machine-tree-part"), targetPart = await targetRoot.getAttribute("data-machine-tree-part"), transfer = await page.evaluateHandle(() => new DataTransfer()); await sourceRoot.locator("button").first().dispatchEvent("dragstart", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("dragover", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("drop", { dataTransfer: transfer }); await page.waitForFunction(({ sourcePart, targetPart }) => document.querySelector(`[data-machine-tree-part="${sourcePart}"][data-machine-tree-path="scene"]`)?.getAttribute("data-machine-tree-parent") === targetPart, { sourcePart, targetPart }); report.checks.push("dragging GLB root tree nodes updates component parent hierarchy"); await control("machine-manager-show-origin").uncheck(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "7"); const beforePartOrigin = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-origin")); const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-origin") ?? "[]"); return current.length === 3 && Math.abs(current[0] - before[0] - .01) < 1e-7; }, { before: beforePartOrigin }); const beforePartQuaternion = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-quaternion")); const rotation = page.locator("fieldset.machine-vector").filter({ hasText: "部件旋转" }); await rotation.getByLabel("Z", { exact: true }).fill("15"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-quaternion") ?? "[]"); return current.length === 4 && Math.abs(current[3] - before[3]) > 1e-4; }, { before: beforePartQuaternion }); await control("machine-manager-rotate").click(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-transform-mode"), "rotate"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1"); 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"); // Editing an imported mesh must preserve the enclosing GLB placement. const meshNode = control("machine-tree-node").filter({ hasText: "mesh_0" }).first(); const meshPath = await meshNode.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path"); await meshNode.dispatchEvent("click"); await control("machine-manager-translation").getByLabel("X", { exact: true }).fill("35"); await control("machine-manager-rotation").getByLabel("Z", { exact: true }).fill("30"); const baseRoot = page.locator('[data-machine-tree-path="scene"]').first(); await baseRoot.locator('[data-axis-control="machine-tree-node"]').first().dispatchEvent("click"); assert.equal(await translation.getByLabel("X", { exact: true }).inputValue(), "10"); assert.equal(await rotation.getByLabel("Z", { exact: true }).inputValue(), "15"); 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); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb"); const exportedBytes = await readFile(await download.path()); const exported = JSON.parse(exportedBytes.toString("utf8", 20, 20 + exportedBytes.readUInt32LE(12))); const exportedMesh = exported.nodes.find((node) => node.name === "mesh_0"); assert.ok(Math.abs(exportedMesh.translation[0] - .035) < 1e-7); assert.ok(Math.abs(exportedMesh.rotation[2] - Math.sin(Math.PI / 12)) < 1e-7); report.checks.push("internal mesh parent-relative position and rotation survive GLB export without moving its parent"); 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.equal(geometry.parts[0].rotationDeg[2], 15); assert.deepEqual(geometry.parts[0].nodeTransforms[meshPath], { translationMm: [35, 0, 0], rotationDeg: [0, 0, 30] }); 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], nodeParents: {}, nodeRoles: {}, nodeTransforms: {} }; }); 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));