Files
linuxcncWebWASM/scripts/test_machine_model_selection.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

132 lines
9.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';
const playwright = process.env.PLAYWRIGHT_MODULE
? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright');
const browser = await (playwright.default ?? playwright).chromium.launch({
executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true,
args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
});
const output = new URL('../coverage/glb-machine-manager/model-selection/', import.meta.url);
await mkdir(output, { recursive: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(15000);
const errors = []; page.on('pageerror', (error) => errors.push(error.message));
const report = { status: 'running', checks: [], errors };
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
const preview = page.locator('[data-machine-manager-preview]');
const rows = () => page.locator('[data-machine-model-id]');
const row = (id) => page.locator(`[data-machine-model-id="${id}"]`);
const modelCount = (n) => page.waitForFunction((n) => document.querySelectorAll('[data-machine-model-id]').length === n && document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false', n);
const selected = (id) => page.waitForFunction((id) => document.querySelector(`[data-machine-model-id="${id}"] [aria-pressed="true"]`) && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerSelectedModel === id, id);
// Small independent GLBs give identical filenames but distinct import identities.
function fixture(x) {
const geometry = new THREE.BoxGeometry(.22, .22, .22).toNonIndexed();
const position = geometry.getAttribute('position');
const binary = Buffer.from(position.array.buffer);
const json = { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }],
nodes: [{ name: 'fixture', translation: [x, 0, .3], children: [1] }, { name: 'solid', mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
materials: [{ doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [.15, .6, .5, 1] } }],
accessors: [{ bufferView: 0, componentType: 5126, count: position.count, type: 'VEC3', min: [-.11, -.11, -.11], max: [.11, .11, .11] }],
bufferViews: [{ buffer: 0, byteLength: binary.length }], buffers: [{ byteLength: binary.length }] };
const raw = Buffer.from(JSON.stringify(json));
const payload = Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4, 32)]);
const header = Buffer.alloc(20); header.writeUInt32LE(0x46546c67, 0); header.writeUInt32LE(2, 4);
header.writeUInt32LE(28 + payload.length + binary.length, 8); header.writeUInt32LE(payload.length, 12); header.writeUInt32LE(0x4e4f534a, 16);
const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(binary.length, 0); binHeader.writeUInt32LE(0x004e4942, 4);
geometry.dispose();
return { name: 'same-name.glb', mimeType: 'model/gltf-binary', buffer: Buffer.concat([header, payload, binHeader, binary]) };
}
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();
assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
await c('machine-manager-files').setInputFiles([fixture(-.35), fixture(.35)]);
await modelCount(2);
const [first, second] = await rows().evaluateAll((items) => items.map((el) => el.dataset.machineModelId));
assert.notEqual(first, second); await selected(first);
await row(second).locator('[data-axis-control="machine-model-select"]').click(); await selected(second);
await c('machine-tree-search').fill('no-matching-node');
await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first);
assert.equal(await c('machine-tree-search').inputValue(), '');
assert.equal(await preview.getAttribute('data-machine-manager-selection-visible'), 'true');
report.checks.push('same filenames retain unique selection; model selection clears search and highlights preview');
const cameraState = JSON.parse(await preview.getAttribute('data-machine-manager-camera'));
const canvas = preview.locator('canvas'), box = await canvas.boundingBox();
const camera = new THREE.PerspectiveCamera(42, box.width / box.height, .005, 100);
camera.position.fromArray(cameraState.position); camera.up.fromArray(cameraState.up);
camera.lookAt(new THREE.Vector3(...cameraState.target)); camera.updateMatrixWorld(true);
const ndc = new THREE.Vector3(.35, 0, .3).project(camera);
const point = { x: box.x + (ndc.x + 1) * box.width / 2, y: box.y + (1 - ndc.y) * box.height / 2 };
await page.mouse.click(point.x, point.y); await selected(second);
await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first);
await page.mouse.move(point.x, point.y); await page.mouse.down();
await page.mouse.move(point.x + 40, point.y + 20, { steps: 4 });
await page.mouse.move(point.x, point.y, { steps: 4 }); await page.mouse.up();
await selected(first);
report.checks.push('viewport click picks correct import; orbit drag returning to start does not select');
await row(second).locator('[data-axis-control="machine-model-delete"]').click();
await modelCount(1); await selected(first);
await c('machine-manager-undo').click(); await modelCount(2); await selected(second);
await c('machine-model-delete-selected').click(); await modelCount(1); await selected(first);
await c('machine-manager-undo').click(); await modelCount(2); await selected(second);
await c('machine-manager-redo').click(); await modelCount(1); await selected(first);
report.checks.push('delete unselected/selected import by ID; undo restores and selects it; redo repairs selection');
await c('machine-manager-undo').click(); await modelCount(2);
// The second imported part was parented to the first by the assembly editor.
await row(first).locator('[data-axis-control="machine-model-select"]').click();
await c('machine-model-delete-selected').click(); await modelCount(1); await selected(second);
assert.equal(await c('machine-manager-parent').inputValue(), '');
await c('machine-manager-undo').click(); await modelCount(2); await selected(first);
await row(second).locator('[data-axis-control="machine-model-select"]').click();
assert.equal(await c('machine-manager-parent').inputValue(), first);
report.checks.push('deleting parent import preserves separately imported child; undo restores parent link');
await c('machine-model-delete-selected').click(); await modelCount(1);
await c('machine-model-delete-selected').click(); await modelCount(0);
assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
assert.equal(await page.locator('.machine-preview-empty').isVisible(), true);
await c('machine-manager-undo').click(); await modelCount(1); await selected(first);
await c('machine-manager-redo').click(); await modelCount(0);
report.checks.push('last deletion clears preview; undo restores selection from empty state');
const m1 = fileURLToPath(new URL('../glb/m1.glb', import.meta.url));
const bytes = await readFile(m1);
await c('machine-manager-files').setInputFiles({ name: 'm1.glb', mimeType: 'model/gltf-binary', buffer: bytes });
await modelCount(1); const m1id = await rows().first().getAttribute('data-machine-model-id');
await selected(m1id); assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
await c('machine-model-delete-selected').click(); await modelCount(0);
await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id);
assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').check();
assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
await c('machine-manager-tab-structure').click(); assert.equal(await c('machine-model-delete').isDisabled(), true);
await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').uncheck();
await c('machine-manager-tab-structure').click();
report.checks.push('real m1 deletion/undo preserves all five bindings; live follow locks deletion');
await page.screenshot({ path: fileURLToPath(new URL('desktop.png', output)) });
for (const [width, height] of [[1154, 520], [720, 900]]) {
await page.setViewportSize({ width, height });
await c('machine-model-select').click(); await selected(m1id);
await c('machine-model-delete').click(); await modelCount(0);
await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id);
}
assert.deepEqual(errors, []); report.checks.push('selection and deletion reachable on short and narrow viewports');
report.status = 'verified';
} catch (error) {
report.status = 'failed'; report.error = String(error.stack ?? error);
await page.screenshot({ path: fileURLToPath(new URL('failure.png', output)) }).catch(() => {});
throw error;
} finally {
await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n');
await browser.close();
}
console.log(JSON.stringify(report));