Files
FreeCADWasmDev/tools/part-make-solid-business-cases.mjs

191 lines
12 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 { pathToFileURL } from 'node:url';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export const makeSolidId = 'Part:desktop:Part_MakeSolid';
export async function runMakeSolidCases(ids, placementCase) {
if (!ids.includes(makeSolidId)) return [];
const result = { capabilityId: makeSolidId, status: 'failed', cases: [] };
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-make-solid-'));
let kernel, browser;
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
try {
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')));
({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebDocumentProbe'));
const queue = new FacadeSerialQueue();
let sequence = 0;
const document = 'MakeSolidBusiness';
const router = { request: (_module, command, payload) => {
assert.equal(command, facade.PART_DOCUMENT_COMMAND);
return facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`solid-${++sequence}`, payload), kernel);
} };
const call = async (action, args = {}, name = document) => {
const reply = await router.request('Part', facade.PART_DOCUMENT_COMMAND, {
schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: name, 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));
return reply;
};
const selected = names => names.map(object => ({ documentId: document, object }));
const convert = async names => {
const reply = await adapter.executePartFixedBoxDesktop(router, 'Part_MakeSolid', { selection: selected(names) }, document);
assert.equal(reply.kind, 'result', JSON.stringify(reply));
return reply.result;
};
const object = (state, name) => {
const found = state.objects.find(item => item.name === name);
assert.ok(found, `Missing ${name}`);
return found;
};
const solid = (state, name, volume) => {
const found = object(state, name);
assert.equal(found.type, 'Part::Feature');
assert.equal(found.topology.shapeType, 'Solid');
assert.equal(found.topology.valid, true);
assert.ok(Math.abs(found.volume - volume) < 1e-7, JSON.stringify(found));
};
await call('create');
await call('create', {}, 'MakeSolidOther');
await call('setBox', { object: 'Box', dimensions: [2, 3, 4] }, 'MakeSolidOther');
const other = await call('snapshot', {}, 'MakeSolidOther');
python(`import FreeCAD as App, Part
d=App.getDocument('${document}')
box=Part.makeBox(2,3,4)
for name,shape in [('Shell',box.Shells[0]),('Compound',Part.makeCompound(box.Faces)),('Solid',box),('Face',box.Faces[0])]:
o=d.addObject('Part::Feature',name)
o.Shape=shape
d.Shell.Label='Shell source'
d.recompute()
`);
const before = await call('snapshot');
const created = await convert(['Shell', 'Compound', 'Solid', 'Face', 'Shell']);
assert.deepEqual(created.makeSolidResults.map(item => item.status), ['created', 'created', 'skipped', 'skipped']);
assert.equal(created.makeSolidResults[2].reason, 'already-solid');
assert.equal(created.makeSolidResults[3].reason, 'unsupported-shape');
solid(created, 'Shell_solid', 24);
solid(created, 'Compound_solid', 24);
assert.equal(object(created, 'Shell_solid').label, 'Shell source (Solid)');
for (const source of before.objects) assert.deepEqual(object(created, source.name), source);
assert.deepEqual((await call('snapshot', {}, 'MakeSolidOther')).objects, other.objects);
record({ selection: ['Shell', 'Compound', 'Solid', 'Face', 'Shell'] }, created.makeSolidResults,
'Desktop conversion creates independent native solids, deduplicates sources, skips unsupported shapes and preserves source and other-document objects');
assert.deepEqual((await call('undo')).objects, before.objects);
const redone = await call('redo');
solid(redone, 'Shell_solid', 24);
solid(redone, 'Compound_solid', 24);
record({ action: 'undo-redo' }, { objects: redone.objects.map(item => item.name) }, 'One undo removes both converted objects and redo restores both native solids');
for (const args of [
{ selection: [] }, { selection: [{ documentId: 'MakeSolidOther', object: 'Box' }] },
{ selection: selected(['Shell']), activeTask: true }, { selection: selected(['Shell']), shapes: ['fake'] },
{ selection: selected(['Shell', 'Missing']) },
]) {
const reply = await adapter.executePartFixedBoxDesktop(router, 'Part_MakeSolid', args, document);
assert.notEqual(reply.kind, 'result', JSON.stringify(reply));
assert.deepEqual((await call('snapshot')).objects, redone.objects);
}
record({ invalid: 'empty, foreign, busy, BREP injection, missing object after valid object' }, { preserved: true },
'Rejected requests do not create partial conversion objects');
const skipped = await convert(['Solid', 'Face']);
assert.equal(skipped.documentChanged, false);
assert.deepEqual(skipped.objects, redone.objects);
const repeated = await convert(['Shell']);
const unique = repeated.makeSolidResults[0].object;
assert.notEqual(unique, 'Shell_solid');
solid(repeated, unique, 24);
await call('save', { path: '/tmp/make-solid.FCStd' });
await call('close');
const reopened = await call('open', { path: '/tmp/make-solid.FCStd' });
solid(reopened, 'Shell_solid', 24);
solid(reopened, 'Compound_solid', 24);
solid(reopened, unique, 24);
const relabeled = await call('setLabel', { object: 'Shell_solid', label: 'Restored solid' });
assert.equal(object(relabeled, 'Shell_solid').label, 'Restored solid');
assert.equal(object(await call('undo'), 'Shell_solid').label, 'Shell source (Solid)');
record({ action: 'repeat-save-reopen-edit-undo' }, { unique, volume: 24 },
'Repeated conversions use unique names and FCStd restores editable native solids with working history');
python(`d=App.getDocument('${document}')
d.Shell.Shape=Part.makeBox(${placementCase.dimensions.join(',')}).Shells[0]
d.Shell.Placement.Base=App.Vector(${placementCase.sourcePosition.join(',')})
link=d.addObject('App::Link','ShellLink')
link.setLink(d.Shell)
link.LinkPlacement.Base=App.Vector(${placementCase.linkPosition.join(',')})
d.recompute()`);
const edited = await call('snapshot');
solid(edited, 'Shell_solid', 24);
solid(edited, 'Compound_solid', 24);
const transformed = await convert(['Shell', 'ShellLink']);
assert.deepEqual(transformed.makeSolidResults.map(item => item.status), ['created', 'created']);
const names = transformed.makeSolidResults.map(item => item.object);
for (const name of names) solid(transformed, name, placementCase.expectedVolume);
const measured = JSON.parse(python(`import json
names=${JSON.stringify(names)}
print(json.dumps([dict(name=n, bounds=[getattr(d.getObject(n).Shape.BoundBox,k) for k in ['XMin','YMin','ZMin','XMax','YMax','ZMax']], dependencies=[o.Name for o in d.getObject(n).OutList]) for n in names]))`).result.stdout.trim());
assert.deepEqual(measured.map(item => item.bounds), placementCase.expectedBounds);
assert.deepEqual(measured.map(item => item.dependencies), [[], []]);
for (const source of edited.objects) assert.deepEqual(object(transformed, source.name), source);
assert.deepEqual((await call('undo')).objects, edited.objects);
const transformedRedo = await call('redo');
for (const name of names) solid(transformedRedo, name, placementCase.expectedVolume);
record({ action: 'edit-source-convert-transformed-link', selection: ['Shell', 'ShellLink'], ...placementCase }, measured,
'Source edits preserve independent converted solids; object and link placements produce native solids at their actual bounds without source dependencies');
kernel.PThread?.terminateAllThreads();
kernel = null;
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', error => errors.push(String(error)));
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=part.document&document=SolidBrowser`);
const run = async payload => {
await page.locator('#payload-editor').fill(JSON.stringify(payload));
await page.evaluate(() => { if (window.__freecadWorkbenchState) window.__freecadWorkbenchState.response = null; });
await page.getByRole('button', { name: '\u6267\u884c\u9009\u4e2d\u547d\u4ee4', exact: true }).click();
await page.waitForFunction(() => !!window.__freecadWorkbenchState?.response && !document.querySelector('button.run')?.disabled);
const reply = await page.evaluate(() => window.__freecadWorkbenchState.response);
assert.equal(reply.kind, 'result', JSON.stringify(reply));
return reply.result;
};
await run({ schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: 'SolidBrowser', action: 'create' });
await page.locator('aside:nth-child(2) > button.command').filter({ has: page.getByText('part.documentViewCommand', { exact: true }) }).click();
const seeded = await run({ schema: 'freecad-web/document-view/v1', owner: 'DocumentProbe', document: 'SolidBrowser', command: 'Std_CommandLine',
parameters: { operation: 'submit', source: "import FreeCAD as App, Part; d=App.getDocument('SolidBrowser'); o=d.addObject('Part::Feature','Shell'); o.Shape=Part.makeBox(5,7,9).Shells[0]; d.recompute()" } });
assert.equal(seeded.stderr, '');
await page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: /^Part_MakeSolid$/ }) }).click();
const converted = await run({ documentId: 'SolidBrowser', selection: [{ documentId: 'SolidBrowser', object: 'Shell' }] });
solid(converted, 'Shell_solid', 315);
assert.equal(converted.document, 'SolidBrowser');
assert.equal(object(converted, 'Shell').topology.shapeType, 'Shell');
assert.ok(object(converted, 'Shell_solid').mesh.triangleCount > 0);
assert.deepEqual(errors, []);
record({ browser: 'desktop command dispatcher', dimensions: [5, 7, 9] }, { volume: 315, results: converted.makeSolidResults },
'Mounted command execution traverses the browser dispatcher, facade and Worker to create a native solid in the selected document');
result.status = 'passed';
} catch (error) {
result.error = `${error.stack ?? error}\n${error.stdout?.toString() ?? ''}\n${error.stderr?.toString() ?? ''}`;
process.stderr.write(`${result.error}\n`);
} finally {
await browser?.close();
kernel?.PThread?.terminateAllThreads();
fs.rmSync(temporary, { recursive: true, force: true });
}
return [result];
}