191 lines
12 KiB
JavaScript
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];
|
|
}
|