Files
linuxcncWebWASM/scripts/test_machine_material_rendering.mjs
wangdequan db29ae625f 按关节槽位驱动 13 个五轴案例的三维模型
关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 06:09:12 +08:00

80 lines
6.4 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
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 pw = process.env.PLAYWRIGHT_MODULE ? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright');
const browser = await (pw.default ?? pw).chromium.launch({ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const baseline = process.env.RENDER_BASELINE === '1';
const output = new URL(`../coverage/glb-machine-manager/material-rendering/${baseline ? 'before' : 'after'}/`, import.meta.url);
await mkdir(output, { recursive: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1100 } });
page.setDefaultTimeout(30000);
const report = { status: 'running', baseline, checks: [], errors: [] };
page.on('pageerror', (e) => report.errors.push(e.message));
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
const preview = page.locator('[data-machine-manager-preview]');
async function importAndCapture(file, stem) {
await c('machine-manager-files').setInputFiles(file);
await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false' && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '1');
await c('machine-view-fit').click();
await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerView === 'fit' && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerRenderState === 'rendered');
await preview.screenshot({ path: fileURLToPath(new URL(`${stem}.png`, output)) });
}
try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5173/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await c('machine-manager-settings').click();
await importAndCapture(fileURLToPath(new URL('../glb/m1.glb', import.meta.url)), 'm1');
report.manager = await preview.evaluate((el) => ({ lighting: el.dataset.machineManagerLighting, toneMapping: el.dataset.machineManagerToneMapping, exposure: el.dataset.machineManagerExposure, meshes: Number(el.dataset.machineManagerSceneMeshes) }));
assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
if (!baseline) {
assert.equal(report.manager.toneMapping, 'AgX');
assert.ok(Number(report.manager.exposure) <= 1);
// Exercise export from the actual manager: render settings must not recolor saved materials.
const downloadPromise = page.waitForEvent('download'); await c('machine-manager-export').click();
const saved = await readFile(await (await downloadPromise).path());
const original = await readFile(new URL('../glb/m1.glb', import.meta.url));
const json = (buffer) => JSON.parse(buffer.toString('utf8', 20, 20 + buffer.readUInt32LE(12)));
const materials = json(saved).materials, sourceMaterials = json(original).materials;
for (const source of sourceMaterials) {
const target = materials.find((m) => m.name === source.name); assert.ok(target, source.name);
const a = source.pbrMetallicRoughness ?? {}, b = target.pbrMetallicRoughness ?? {};
for (const key of ['metallicFactor', 'roughnessFactor']) assert.ok(Math.abs((a[key] ?? 1) - (b[key] ?? 1)) < 1e-6, `${source.name}/${key}`);
const color = a.baseColorFactor ?? [1, 1, 1, 1], savedColor = b.baseColorFactor ?? [1, 1, 1, 1];
color.forEach((value, i) => assert.ok(Math.abs(value - savedColor[i]) < 1e-6, `${source.name}/color${i}`));
if (source.normalTexture) assert.ok(target.normalTexture, `${source.name}/normal map`);
}
report.checks.push('manager export retains source PBR colors, roughness, metalness and normal maps');
}
await c('machine-document-new').click();
const fixture = new THREE.Scene();
const swatches = [0xff0000, 0x00ff00, 0x0000ff, 0xbdbdbd, 0xffffff, 0x222222];
for (let i = 0; i < swatches.length; i++) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(.13, 48, 24), new THREE.MeshStandardMaterial({ color: swatches[i], roughness: i === 3 ? .22 : .38, metalness: i === 3 ? 1 : 0 }));
mesh.name = `material-probe-${i}`; mesh.position.set((i % 3 - 1) * .35, i < 3 ? 0 : .35, .2); fixture.add(mesh);
}
const buffer = Buffer.from(await new GLTFExporter().parseAsync(fixture, { binary: true }));
await importAndCapture({ name: 'rgb-material-probes.glb', mimeType: 'model/gltf-binary', buffer }, 'rgb-materials');
fixture.traverse((object) => { object.geometry?.dispose(); object.material?.dispose(); });
report.checks.push('real m1 and saturated RGB / white / metal / dark material probes render without browser exceptions');
if (!baseline) {
await page.keyboard.press('Escape'); await c('machine-glb-settings').click();
await c('machine-glb-file').setInputFiles(fileURLToPath(new URL('../glb/m1.glb', import.meta.url)));
await page.waitForFunction(() => document.querySelector('.xyzac-machine-view')?.dataset.machineGlb && document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await page.keyboard.press('Escape');
const viewer = page.locator('.xyzac-machine-view');
assert.equal(await viewer.locator('canvas').getAttribute('data-machine-tone-mapping'), 'AgX');
await viewer.screenshot({ path: fileURLToPath(new URL('controller-m1.png', output)) });
report.checks.push('control panel uses the same AgX neutral studio for imported GLBs');
}
assert.deepEqual(report.errors, []); report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
finally { await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2)); await browser.close(); }
console.log(JSON.stringify(report));