Files
FreeCADWasmDev/tools/part-primitive-container-business-cases.mjs

249 lines
22 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export async function runPrimitiveContainerCases(ids) {
const capabilityId = 'Part:desktop:Part_Primitives';
if (!ids.includes(capabilityId)) return [];
const directory = path.resolve('docs/current/part-primitive-container-business', new Date().toISOString().replace(/[:.]/g, '-'));
fs.mkdirSync(directory, { recursive: true });
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'primitive-container-'));
const result = { capabilityId, status: 'failed', cases: [], stages: [], evidenceFiles: {},
remaining: ['Cylinder skew and circle-from-three-points interaction', 'Desktop expression binding and live task-lifecycle parity',
'Transformed active-container viewport geometry'],
deferredCases: ['runLegacyPrimitiveTaskCases', 'runAdditionalPrimitiveCases', 'runWedgePrimitiveCases', 'runHelixSpiralCases', 'runPrimitivePlacementCases'] };
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
const stage = name => { result.stages.push({ name, at: new Date().toISOString() }); process.stderr.write(`Primitive container: ${name}\n`); };
const object = (state, name) => { const value = state.objects.find(item => item.name === name); assert.ok(value, name); return value; };
const signature = state => ({ objects: state.objects, undoCount: state.undoCount, redoCount: state.redoCount });
const near = (actual, expected) => {
assert.equal(actual.length, expected.length);
actual.forEach((value, index) => assert.ok(Number.isFinite(value) && Math.abs(value - expected[index]) < 1e-8,
`${actual} != ${expected}`));
};
const checkDisplay = (state, name) => {
const item = object(state, name);
near(item.mesh.bounds, [10, 22, 31, 14, 24, 34]);
const points = item.lines.flat();
assert.ok(points.length > 0);
near([0, 1, 2].map(axis => Math.min(...points.map(p => p[axis]))), [10, 22, 31]);
near([0, 1, 2].map(axis => Math.max(...points.map(p => p[axis]))), [14, 24, 34]);
for (let index = 0; index < item.mesh.normals.length; index += 3)
near([Math.hypot(...item.mesh.normals.slice(index, index + 3))], [1]);
near(item.primitivePlacement.position, [0, 0, 0]);
};
const persistedEqual = (actual, expected) => {
if (typeof expected === 'number') {
assert.equal(typeof actual, 'number'); assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`);
} else if (expected && typeof expected === 'object') {
assert.equal(Array.isArray(actual), Array.isArray(expected)); assert.deepEqual(Object.keys(actual), Object.keys(expected));
for (const key of Object.keys(expected)) persistedEqual(actual[key], expected[key]);
} else assert.equal(actual, expected);
};
let kernel, browser;
try {
for (const file of ['tools/part-primitive-container-business-cases.mjs', 'web/src/part-primitives-task.tsx',
'web/src/part-desktop-adapter.ts', 'web/src/part-document-facade.ts', 'web/src/workbench-shell.tsx',
'freecad-source/src/WebAssembly/FreeCADCoreProbe/DocumentProbe.cpp',
...['js', 'wasm', 'worker.js'].map(ext => `web/public/kernel/FreeCADWebDocumentProbe.${ext}`)])
result.evidenceFiles[file] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
execFileSync('web/node_modules/.bin/tsc', ['--target', 'ES2022', '--module', 'ESNext', '--moduleResolution', 'Bundler',
'--strict', '--skipLibCheck', '--outDir', temporary, 'web/node_modules/vite/client.d.ts', 'web/src/part-desktop-adapter.ts'], { stdio: 'pipe' });
const facade = await import(pathToFileURL(path.join(temporary, 'part-document-facade.js')));
const adapter = await import(pathToFileURL(path.join(temporary, 'part-desktop-adapter.js')));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(temporary, 'facade-envelope.js')));
stage('native-load-enter'); ({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebDocumentProbe')); stage('native-load-complete');
const document = 'PrimitiveContainer', queue = new FacadeSerialQueue(); let sequence = 0;
const router = { request: (_module, command, payload) => {
assert.equal(command, facade.PART_DOCUMENT_COMMAND);
return facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`container-${++sequence}`, payload), kernel);
} };
const call = async (action, args = {}, owner = document) => {
const reply = await router.request('Part', facade.PART_DOCUMENT_COMMAND, { schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part',
document: owner, action, builderInfo: true, includeMesh: true, ...args });
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
const python = source => {
const reply = JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: 'part.documentViewCommand',
args: { owner: 'DocumentProbe', document, command: 'Std_CommandLine', parameters: { operation: 'submit', source: `exec(${JSON.stringify(source)})` } } })]));
assert.equal(reply.ok, true, JSON.stringify(reply)); assert.equal(reply.result.stderr, '', JSON.stringify(reply));
};
const args = { action: 'accept', primitive: 'Box', object: 'Member', parameters: { Length: 2, Width: 3, Height: 4 }, primitiveContainer: 'Assembly' };
const execute = extra => adapter.executePartFixedBoxDesktop(router, 'Part_Primitives', { ...args, ...extra }, document);
const accepted = async extra => { const reply = await execute(extra); assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result; };
await call('create'); await call('create', {}, 'ContainerOther'); const other = await call('snapshot', {}, 'ContainerOther');
python(`import FreeCAD as App\nd=App.getDocument('${document}')\np=d.addObject('App::Part','Assembly')\nkeep=d.addObject('Part::Box','Existing')\np.addObject(keep)\nnested=d.addObject('App::Part','Nested')\np.addObject(nested)\nd.recompute()`);
python('p.Placement=App.Placement(App.Vector(10,20,30),App.Rotation(App.Vector(0,0,1),90))\nnested.Placement=App.Placement(App.Vector(2,0,1),App.Rotation(App.Vector(1,0,0),90))\nd.recompute()');
const fixture = await call('save', { path: '/tmp/PrimitiveContainerFixture.FCStd' });
stage('native-cases-enter');
const before = await call('snapshot'), created = await accepted();
assert.deepEqual(object(created, 'Assembly').groupMembers, ['Existing', 'Nested', 'Member']);
assert.equal(object(created, 'Member').type, 'Part::Box'); assert.equal(object(created, 'Member').topology.valid, true);
assert.ok(Math.abs(object(created, 'Member').volume - 24) < 1e-8);
assert.deepEqual(object(created, 'Existing'), object(before, 'Existing')); assert.equal(created.undoCount, before.undoCount + 1);
assert.deepEqual((await call('undo')).objects, before.objects); assert.deepEqual((await call('redo')).objects, created.objects);
const duplicate = await accepted(); assert.equal(duplicate.createdObject, 'Member001');
assert.deepEqual(object(duplicate, 'Assembly').groupMembers, ['Existing', 'Nested', 'Member', 'Member001']);
const nested = await accepted({ object: 'NestedMember', primitiveContainer: 'Nested' });
assert.deepEqual(object(nested, 'Nested').groupMembers, ['NestedMember']);
checkDisplay(nested, 'NestedMember');
const root = await accepted({ object: 'RootMember', primitiveContainer: undefined });
assert.ok(!root.objects.some(item => item.groupMembers?.includes('RootMember')));
near(object(root, 'RootMember').mesh.bounds, [0, 0, 0, 2, 3, 4]);
const edited = await accepted({ action: 'edit', primitiveContainer: undefined, parameters: { Length: 5, Width: 3, Height: 4 } });
assert.ok(Math.abs(object(edited, 'Member').volume - 60) < 1e-8);
assert.deepEqual(object(edited, 'Assembly').groupMembers, object(root, 'Assembly').groupMembers);
const stable = await call('snapshot');
for (const invalid of [{ primitiveContainer: 'Missing' }, { primitiveContainer: 'Existing' }, { primitiveContainer: '' },
{ primitiveContainer: null }, { primitiveContainer: { documentId: 'ContainerOther', object: 'Assembly' } },
{ action: 'edit' }, { primitiveContainer: 'Other.Assembly' }]) {
const rejected = await execute(invalid); assert.equal(rejected.kind, 'error', JSON.stringify(rejected));
const rawArgs = { ...args, ...invalid, document, action: 'setPrimitive', primitiveCreate: invalid.action !== 'edit' };
const raw = JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: 'part.document', args: rawArgs })]));
assert.equal(raw.ok, false, JSON.stringify(raw)); assert.deepEqual(signature(await call('snapshot')), signature(stable));
}
await accepted({ action: 'cancel' }); assert.deepEqual(signature(await call('snapshot')), signature(stable));
assert.deepEqual(await call('snapshot', {}, 'ContainerOther'), other);
const saved = await call('save', { path: '/tmp/PrimitiveContainer.FCStd' }); await call('close');
const reopened = await call('open', { path: '/tmp/PrimitiveContainer.FCStd', bytes: saved.bytes }); persistedEqual(reopened.objects, stable.objects);
checkDisplay(reopened, 'NestedMember');
const afterOpen = await accepted({ object: 'AfterOpen', primitiveContainer: 'Nested' });
assert.deepEqual(object(afterOpen, 'Nested').groupMembers, ['NestedMember', 'AfterOpen']);
python("d=App.getDocument('PrimitiveContainer')\nassert d.getObject('AfterOpen') in d.getObject('Nested').Group\nassert d.getObject('Member') in d.getObject('Assembly').Group");
const line = await accepted({ object: 'DisplayLine', primitive: 'Line', primitiveContainer: 'Nested',
parameters: { X1: 0, Y1: 0, Z1: 0, X2: 2, Y2: 3, Z2: 4 } });
const points = object(line, 'DisplayLine').lines.flat();
near(points[0], [10, 22, 31]); near(points.at(-1), [14, 24, 34]);
const vertex = await accepted({ object: 'DisplayVertex', primitive: 'Vertex', primitiveContainer: 'Nested', parameters: { X: 1, Y: 2, Z: 3 } });
near(object(vertex, 'DisplayVertex').primitivePoint, [13, 23, 33]);
const moved = await accepted({ action: 'edit', object: 'NestedMember', primitiveContainer: undefined,
primitivePlacement: { position: [1, 2, 3], axis: [0, 0, 1], angle: 90 } });
near(object(moved, 'NestedMember').mesh.bounds, [13, 20, 33, 17, 23, 35]);
near(object(moved, 'NestedMember').primitivePlacement.position, [1, 2, 3]);
const movedSaved = await call('save', { path: '/tmp/TransformedMembers.FCStd' }); await call('close');
const movedOpened = await call('open', { path: '/tmp/TransformedMembers.FCStd', bytes: movedSaved.bytes });
near(object(movedOpened, 'NestedMember').mesh.bounds, [13, 20, 33, 17, 23, 35]);
near(object(movedOpened, 'DisplayVertex').primitivePoint, [13, 23, 33]);
record({ parentRotation: ['Z', 90], nestedRotation: ['X', 90], localRotation: ['Z', 90] },
{ bounds: object(movedOpened, 'NestedMember').mesh.bounds, point: object(movedOpened, 'DisplayVertex').primitivePoint },
'Native nested Part placements transform faces, normals, edge samples and vertex markers once while retaining local editable placement and FCStd persistence');
record(args, { group: object(created, 'Assembly').groupMembers, nested: object(afterOpen, 'Nested').groupMembers, savedBytes: saved.bytes.byteLength },
'Native Part membership, unique names, root creation, edits, one-step history, isolation and FCStd reopening preserve geometry and grouping; malformed containers reject atomically');
stage('native-cylinder-skew-enter');
const cylinderParameters = { Radius: 2, Height: 5, Angle: 360, FirstAngle: 15, SecondAngle: -10 };
const cylinderArgs = { primitive: 'Cylinder', object: 'SkewCylinder', primitiveContainer: undefined,
parameters: cylinderParameters };
const beforeCylinder = await call('snapshot');
const cylinderState = await accepted(cylinderArgs);
const cylinder = object(cylinderState, 'SkewCylinder');
assert.equal(cylinder.type, 'Part::Cylinder');
assert.equal(cylinder.topology.valid, true);
assert.deepEqual(cylinder.parameters, cylinderParameters);
assert.ok(cylinder.mesh.triangleCount > 0);
assert.ok(Math.abs(cylinder.volume - Math.PI * 20) < 1e-6);
assert.equal(cylinderState.undoCount, beforeCylinder.undoCount + 1);
assert.deepEqual((await call('undo')).objects, beforeCylinder.objects);
assert.deepEqual((await call('redo')).objects, cylinderState.objects);
const straightState = await accepted({ ...cylinderArgs, action: 'edit',
parameters: { ...cylinderParameters, FirstAngle: 0, SecondAngle: 0 } });
const straight = object(straightState, 'SkewCylinder');
near(straight.mesh.bounds, [-2, -2, 0, 2, 2, 5]);
assert.notDeepEqual(cylinder.mesh.bounds, straight.mesh.bounds);
await accepted({ ...cylinderArgs, action: 'edit' });
const skewSaved = await call('save', { path: '/tmp/SkewCylinder.FCStd' });
await call('close');
const skewReopened = await call('open', { path: '/tmp/SkewCylinder.FCStd', bytes: skewSaved.bytes });
persistedEqual(object(skewReopened, 'SkewCylinder'), cylinder);
for (const parameters of [{ ...cylinderParameters, FirstAngle: 90 }, { ...cylinderParameters, SecondAngle: -90 }]) {
const beforeInvalid = await call('snapshot');
assert.equal((await execute({ ...cylinderArgs, action: 'edit', parameters })).kind, 'error');
assert.deepEqual(signature(await call('snapshot')), signature(beforeInvalid));
}
record(cylinderArgs, { parameters: cylinder.parameters, volume: cylinder.volume, bounds: cylinder.mesh.bounds },
'Cylinder skew uses native angle properties, changes the shape while preserving analytic volume, supports atomic history and FCStd persistence, and rejects singular angles without mutation');
stage('native-cylinder-skew-complete');
kernel.PThread?.terminateAllThreads(); kernel = undefined; stage('native-cleanup-complete');
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? '/usr/bin/google-chrome', headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const errors = []; page.on('pageerror', e => errors.push(String(e)));
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=Part_Primitives&document=${document}`);
const panel = page.getByRole('region', { name: 'Part primitives', exact: true }); stage('browser-enter');
await panel.getByLabel('Primitives FCStd file', { exact: true }).setInputFiles({ name: 'PrimitiveContainer.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(fixture.bytes) });
await panel.getByLabel('Primitive active Part', { exact: true }).selectOption('Nested');
await panel.getByLabel('Primitive type', { exact: true }).selectOption('Box');
await panel.getByLabel('Primitive object', { exact: true }).fill('BrowserMember');
for (const [name, value] of Object.entries(args.parameters)) await panel.getByLabel(`Primitive ${name}`, { exact: true }).fill(String(value));
await panel.getByRole('button', { name: 'Create primitive', exact: true }).click();
await page.waitForFunction(() => window.__freecadWorkbenchState?.response?.result?.createdObject === 'BrowserMember');
const state = await page.evaluate(() => window.__freecadWorkbenchState.response.result);
assert.deepEqual(object(state, 'Nested').groupMembers, ['BrowserMember']); assert.ok(Math.abs(object(state, 'BrowserMember').volume - 24) < 1e-8);
checkDisplay(state, 'BrowserMember');
await panel.getByRole('button', { name: 'Primitives undo', exact: true }).click(); await panel.locator('[data-primitive-object="BrowserMember"]').waitFor({ state: 'detached' });
await panel.getByRole('button', { name: 'Primitives redo', exact: true }).click(); await panel.locator('[data-primitive-object="BrowserMember"]').waitFor();
await panel.getByRole('button', { name: 'Save primitives document', exact: true }).click(); await page.getByLabel('Saved primitives file', { exact: true }).waitFor();
const bytes = await page.evaluate(async () => { const { readDocumentFile } = await import('/src/storage.ts'); return [...new Uint8Array(await readDocumentFile('Primitives-PrimitiveContainer.FCStd'))]; });
await page.reload(); await panel.getByLabel('Primitives FCStd file', { exact: true }).setInputFiles({ name: 'PrimitiveContainer.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(bytes) });
await panel.locator('[data-primitive-object="BrowserMember"]').waitFor();
await panel.getByRole('button', { name: 'Read primitives document', exact: true }).click();
await page.waitForFunction(() => window.__freecadWorkbenchState?.response?.result?.objects?.some(o => o.name === 'Nested' && o.groupMembers?.includes('BrowserMember')));
checkDisplay(await page.evaluate(() => window.__freecadWorkbenchState.response.result), 'BrowserMember');
const capture = async name => {
const filename = path.join(directory, name);
const png = await panel.locator('[aria-label="Primitives geometry"] canvas').screenshot({ path: filename });
const colors = await page.evaluate(async encoded => {
const image = new Image(); image.src = `data:image/png;base64,${encoded}`; await image.decode();
const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height;
const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data, colors = new Set();
for (let i = 0; i < pixels.length; i += 4) colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
return colors.size;
}, png.toString('base64'));
assert.ok(colors > 8, `Blank geometry: ${colors} colors`);
result.evidenceFiles[path.relative(process.cwd(), filename)] = createHash('sha256').update(png).digest('hex');
return png;
};
const initialFrame = await capture('desktop-geometry.png');
const canvas = panel.locator('[aria-label="Primitives geometry"] canvas'), rect = await canvas.boundingBox();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); await page.mouse.down();
await page.mouse.move(rect.x + rect.width / 2 + 60, rect.y + rect.height / 2 + 30, { steps: 8 }); await page.mouse.up();
assert.notDeepEqual(await capture('desktop-orbit.png'), initialFrame);
await page.setViewportSize({ width: 390, height: 844 });
await capture('mobile-geometry.png');
const selector = panel.getByLabel('Primitive active Part', { exact: true }); await selector.scrollIntoViewIfNeeded();
const bounds = await selector.boundingBox(); assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= 390);
const screenshot = path.join(directory, 'mobile-container.png'); await page.screenshot({ path: screenshot });
result.evidenceFiles[path.relative(process.cwd(), screenshot)] = createHash('sha256').update(fs.readFileSync(screenshot)).digest('hex');
assert.deepEqual(errors, []);
record({ browser: true, container: 'Nested', actions: ['create', 'undo', 'redo', 'OPFS-save', 'reload', 'open'] }, { savedBytes: bytes.length, group: ['BrowserMember'], selectorBounds: bounds },
'Mounted active-Part selector creates the native member and retains grouping through browser history and OPFS reopen, with mobile controls in bounds');
await panel.getByLabel('Primitive active Part', { exact: true }).selectOption('');
await panel.getByLabel('Primitive type', { exact: true }).selectOption('Cylinder');
await panel.getByLabel('Primitive object', { exact: true }).fill('BrowserSkewCylinder');
for (const [name, value] of Object.entries(cylinderParameters))
await panel.getByLabel(`Primitive ${name}`, { exact: true }).fill(String(value));
await panel.getByRole('button', { name: 'Create primitive', exact: true }).click();
await page.waitForFunction(() => window.__freecadWorkbenchState?.response?.result?.createdObject === 'BrowserSkewCylinder');
const browserCylinder = object(await page.evaluate(() => window.__freecadWorkbenchState.response.result), 'BrowserSkewCylinder');
assert.equal(browserCylinder.type, 'Part::Cylinder');
assert.equal(browserCylinder.topology.valid, true);
assert.deepEqual(browserCylinder.parameters, cylinderParameters);
near(browserCylinder.mesh.bounds, cylinder.mesh.bounds);
assert.ok(Math.abs(browserCylinder.volume - cylinder.volume) < 1e-6);
await capture('mobile-skew-cylinder.png');
assert.deepEqual(errors, []);
record({ browser: true, parameters: cylinderParameters }, { volume: browserCylinder.volume, bounds: browserCylinder.mesh.bounds },
'Mounted primitive controls dispatch both cylinder skew angles and render the same native geometry as the document workflow');
result.remaining = result.remaining.filter(item => item !== 'Transformed active-container viewport geometry');
stage('browser-complete'); result.status = 'not_implemented';
} catch (error) { result.error = [String(error.stack ?? error), error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n'); }
finally { await browser?.close(); kernel?.PThread?.terminateAllThreads(); fs.rmSync(temporary, { recursive: true, force: true });
fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2)); }
return [result];
}