关节契约扩为槽位制:以 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>
80 lines
6.4 KiB
JavaScript
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));
|