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