Files
FreeCADWasmDev/tools/part-ruled-surface-business-cases.mjs

213 lines
18 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 const ruledSurfaceId = 'Part:desktop:Part_RuledSurface';
export async function runRuledSurfaceCases(ids) {
if (!ids.includes(ruledSurfaceId)) return [];
const root = path.resolve('.');
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'ruled-surface-business-'));
const directory = path.join(root, 'docs/current/part-ruled-surface-business', new Date().toISOString().replace(/[:.]/g, '-'));
fs.mkdirSync(directory, { recursive: true });
const result = { capabilityId: ruledSurfaceId, status: 'failed', cases: [], evidenceFiles: {} };
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
const near = (a, b) => assert.ok(Number.isFinite(a) && Math.abs(a - b) < 1e-6, `${a} != ${b}`);
const stage = name => process.stderr.write(`Part ruled surface: ${name}\n`);
let kernel, browser;
try {
execFileSync(path.join(root, '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'], { cwd: root, 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('load once');
({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebDocumentProbe'));
const 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(`ruled-${++sequence}`, payload), kernel);
} };
const document = 'RuledBusiness';
const request = (action, args = {}, name = document) => router.request('Part', facade.PART_DOCUMENT_COMMAND, {
schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: name, action, builderInfo: true, includeMesh: true, ...args });
const call = async (action, args = {}, name = document) => {
const reply = await request(action, args, name); 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.result.stdout.trim() ? JSON.parse(reply.result.stdout.trim()) : null;
};
const curve = (object, ...subelements) => ({ documentId: document, object, subelements });
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_RuledSurface', args, document);
const feature = (state, name = state.createdObject) => state.objects.find(o => o.name === name);
const accepted = async args => {
const reply = await execute(args); assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
await call('create'); await call('setBox', { object: 'Box', dimensions: [10, 8, 6] });
python(`import FreeCAD as App, Part, json
d=App.getDocument('${document}')
def add(name, shape):
obj=d.addObject('Part::Feature',name)
obj.Shape=shape
return obj
a=add('EdgeA',Part.makeLine(App.Vector(0,0,0),App.Vector(10,0,0)))
b=add('EdgeB',Part.makeLine(App.Vector(0,5,0),App.Vector(10,5,0)))
add('Skew',Part.makeLine(App.Vector(0,5,2),App.Vector(8,6,4)))
add('Pair',Part.makeCompound([a.Shape,b.Shape]))
wire=Part.makePolygon([App.Vector(0,0,0),App.Vector(10,0,0),App.Vector(10,6,0),App.Vector(0,6,0),App.Vector(0,0,0)])
add('WireA',wire)
other=wire.copy(); other.translate(App.Vector(0,0,5)); add('WireB',other)
link=d.addObject('App::Link','CurveLink'); link.setLink(b); link.Placement.Base=App.Vector(0,5,0)
d.addObject('App::FeaturePython','NoShape')
d.recompute()`);
const fixture = await call('save', { path: '/tmp/RuledBusiness.FCStd' });
await call('create', {}, 'RuledOther'); await call('setBox', { object: 'Box', dimensions: [2, 3, 4] }, 'RuledOther');
const isolated = await call('readRuledSurface', {}, 'RuledOther');
stage('native document workflows');
const selections = [
[[curve('EdgeA'), curve('EdgeB')], 50, 1],
[[curve('Pair', 'Edge1', 'Edge2')], 50, 1],
[[curve('Pair', 'Edge1'), curve('Pair', 'Edge2')], 50, 1],
[[curve('WireA'), curve('WireB')], 160, 4],
[[curve('WireA', 'Wire1'), curve('WireB', 'Wire1')], 160, 4],
[[curve('EdgeA'), curve('CurveLink')], 100, 1],
];
let firstName;
for (const [selection, area, faces] of selections) {
const before = await call('readRuledSurface'); const state = await accepted({ selection }); const item = feature(state);
firstName ??= item.name;
assert.equal(item.type, 'Part::RuledSurface'); assert.equal(item.topology.valid, true); assert.equal(item.topology.Face, faces); near(item.topology.area, area);
assert.equal(state.undoCount, before.undoCount + 1);
for (const old of before.objects) assert.deepEqual(feature(state, old.name), old);
assert.deepEqual((await call('undo')).objects, before.objects);
assert.deepEqual((await call('redo')).objects, state.objects);
assert.deepEqual(await call('readRuledSurface', {}, 'RuledOther'), isolated);
record({ selection }, { object: item.name, topology: item.topology, links: item.ruledSurface },
'Native ruled geometry matches analytic area and face count, retains source links and supports isolated one-step undo/redo');
}
const meshes = [];
for (const orientation of ['Automatic', 'Forward', 'Reversed']) {
const state = await accepted({ object: firstName, action: 'edit', selection: [curve('EdgeA'), curve('Skew')], orientation });
const item = feature(state); assert.equal(item.ruledSurface.orientation, orientation); assert.equal(item.topology.valid, true);
assert.equal(item.ruledSurface.curves[1].object, 'Skew'); assert.ok(item.topology.area > 0); meshes.push(item.mesh.positions);
record({ orientation, action: 'edit' }, { links: item.ruledSurface, area: item.topology.area }, 'Native orientation edits preserve the feature and update its second curve dependency');
}
assert.notDeepEqual(meshes[1], meshes[2]);
await accepted({ action: 'edit', object: firstName, selection: [curve('EdgeA'), curve('EdgeB')] });
let moved = await call('setPosition', { object: 'EdgeB', position: [0, 5, 0] }); near(feature(moved, firstName).topology.area, 100);
near(feature(await call('undo'), firstName).topology.area, 50); near(feature(await call('redo'), firstName).topology.area, 100);
record({ operation: 'move-source', position: [0, 5, 0] }, { area: 100 }, 'Source placement recomputes the native ruled surface and participates in document history');
python(`dependent=d.addObject('Part::Compound','Dependent'); dependent.Links=[d.${firstName}]; d.recompute()`);
const before = await call('readRuledSurface');
const invalid = [
{ selection: [] }, { selection: [curve('EdgeA')] }, { selection: [curve('EdgeA'), curve('EdgeA')] },
{ selection: [curve('Box'), curve('EdgeB')] }, { selection: [curve('Missing'), curve('EdgeB')] },
{ selection: [curve('NoShape'), curve('EdgeB')] }, { selection: [curve('Pair', 'Edge99'), curve('EdgeB')] },
{ selection: [curve('Box', 'Face1'), curve('EdgeB')] }, { selection: [{ documentId: 'RuledOther', object: 'Box' }, curve('EdgeB')] },
{ object: 'Box' }, { action: 'edit', object: 'Box' }, { orientation: 'Sideways' }, { orientation: null },
{ shapes: ['BREP'] }, { activeTask: true }, { document: 'RuledOther' },
{ action: 'edit', object: firstName, selection: [curve(firstName, 'Edge1'), curve('EdgeB')] },
{ action: 'edit', object: firstName, selection: [curve('Dependent', 'Edge1'), curve('EdgeB')] },
];
for (const args of invalid) {
const reply = await execute({ selection: [curve('EdgeA'), curve('EdgeB')], ...args }); assert.equal(reply.kind, 'error', JSON.stringify(reply));
assert.deepEqual(await call('readRuledSurface'), before); assert.deepEqual(await call('readRuledSurface', {}, 'RuledOther'), isolated);
record(args, reply.error, 'Invalid inputs and cyclic dependencies reject without geometry or history changes');
}
// Direct facade callers must not bypass the native source and cycle checks.
const direct = await request('editRuledSurface', { object: firstName, selection: [curve('Dependent', 'Edge1'), curve('EdgeB')] });
assert.equal(direct.kind, 'error'); assert.deepEqual(await call('readRuledSurface'), before);
python(`d.EdgeB.Placement.Base=App.Vector(0,7,0)`);
const dirty = python(`print(json.dumps(dict(touched=d.EdgeB.State, ruled=d.${firstName}.State, area=d.${firstName}.Shape.Area, undo=d.UndoCount, redo=d.RedoCount)))`);
const read = await accepted({ action: 'read' }); const cancel = await accepted({ action: 'cancel' });
assert.equal(read.documentChanged, false); assert.equal(cancel.cancelled, true);
assert.deepEqual(python(`print(json.dumps(dict(touched=d.EdgeB.State, ruled=d.${firstName}.State, area=d.${firstName}.Shape.Area, undo=d.UndoCount, redo=d.RedoCount)))`), dirty);
python('d.recompute()'); near(feature(await call('readRuledSurface'), firstName).topology.area, 120);
record({ actions: ['native-invalid-cycle', 'read-dirty', 'cancel-dirty'] }, { dirtyPreserved: true }, 'Read and cancel preserve pending geometry and history, and direct native actions reject cycles');
const saved = await call('save', { path: '/tmp/RuledBusiness.FCStd' }); await call('close');
const restored = await call('open', { path: '/tmp/RuledBusiness.FCStd', bytes: saved.bytes });
assert.deepEqual(feature(restored, firstName).ruledSurface, feature(before, firstName).ruledSurface); near(feature(restored, firstName).topology.area, 120);
moved = await call('setPosition', { object: 'EdgeB', position: [0, 9, 0] }); near(feature(moved, firstName).topology.area, 140);
record({ actions: ['FCStd-save', 'close', 'open', 'source-edit'] }, { bytes: saved.bytes.byteLength, area: 140 }, 'FCStd restores native curve links and further edits recompute the ruled feature');
kernel.PThread?.terminateAllThreads(); kernel = null;
stage('mounted browser workflow');
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 }, deviceScaleFactor: 1 });
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_RuledSurface&document=${document}`);
await page.getByLabel('Ruled surface document', { exact: true }).fill(document);
await page.getByLabel('Ruled surface FCStd file', { exact: true }).setInputFiles({ name: 'RuledBusiness.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(fixture.bytes) });
await page.locator('[data-ruled-object="EdgeA"]').waitFor();
for (const [i, name] of [[1, 'EdgeA'], [2, 'EdgeB']]) await page.getByLabel(`Ruled curve ${i} object`, { exact: true }).selectOption(name);
await page.getByRole('button', { name: 'Create ruled surface', exact: true }).click();
await page.locator('[data-ruled-object="RuledSurface"]').filter({ hasText: '50.000' }).waitFor();
await page.getByLabel('Ruled curve 2 object', { exact: true }).selectOption('Skew');
await page.getByLabel('Ruled surface orientation', { exact: true }).selectOption('Reversed');
await page.getByRole('button', { name: 'Apply ruled surface', exact: true }).click();
await page.locator('[data-ruled-object="RuledSurface"]').filter({ hasText: 'Skew' }).waitFor();
await page.getByRole('button', { name: 'Ruled surface undo', exact: true }).click();
await page.locator('[data-ruled-object="RuledSurface"]').filter({ hasText: '50.000' }).waitFor();
await page.getByRole('button', { name: 'Ruled surface redo', exact: true }).click();
await page.locator('[data-ruled-object="RuledSurface"]').filter({ hasText: 'Skew' }).waitFor();
await page.getByLabel('Ruled surface feature', { exact: true }).selectOption('');
for (const i of [1, 2]) {
await page.getByLabel(`Ruled curve ${i} object`, { exact: true }).selectOption('Pair');
await page.getByLabel(`Ruled curve ${i} subelement`, { exact: true }).selectOption(`Edge${i}`);
}
await page.getByRole('button', { name: 'Swap ruled curves', exact: true }).click();
assert.equal(await page.getByLabel('Ruled curve 1 subelement', { exact: true }).inputValue(), 'Edge2');
await page.getByLabel('Ruled surface orientation', { exact: true }).selectOption('Automatic');
await page.getByRole('button', { name: 'Create ruled surface', exact: true }).click();
await page.locator('[data-ruled-object="RuledSurface001"]').filter({ hasText: '50.000' }).waitFor();
const capture = async name => {
const canvas = page.locator('[aria-label="Ruled surface geometry"] canvas'); const png = await canvas.screenshot({ path: path.join(directory, name) });
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 ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0); const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const colors = new Set(); for (let i = 0; i < data.length; i += 4) colors.add(`${data[i]},${data[i+1]},${data[i+2]}`); return colors.size;
}, png.toString('base64')); assert.ok(colors > 8); return createHash('sha256').update(png).digest('hex');
};
const initial = await capture('desktop-geometry.png'); const rect = await page.locator('[aria-label="Ruled surface geometry"] 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 + 70, rect.y + rect.height / 2 + 30, { steps: 8 }); await page.mouse.up();
assert.notEqual(await capture('rotated-geometry.png'), initial);
await page.getByRole('button', { name: 'Save ruled surface document', exact: true }).click(); await page.getByRole('link', { name: 'Open ruled surface in project', exact: true }).waitFor();
const bytes = await page.evaluate(async () => { const { readDocumentFile } = await import('/src/storage.ts'); return [...new Uint8Array(await readDocumentFile('RuledSurface-RuledBusiness.FCStd'))]; });
await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
await page.getByRole('button', { name: 'Close ruled surface task', exact: true }).click(); await page.getByRole('button', { name: 'Apply ruled surface', exact: true }).waitFor({ state: 'detached' });
await page.reload(); await page.getByLabel('Ruled surface document', { exact: true }).fill(document);
await page.getByLabel('Ruled surface FCStd file', { exact: true }).setInputFiles({ name: 'RuledBusiness.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(bytes) });
await page.locator('[data-ruled-object="RuledSurface001"]').waitFor();
await page.getByLabel('Ruled surface feature', { exact: true }).selectOption('RuledSurface');
assert.equal(await page.getByLabel('Ruled surface orientation', { exact: true }).inputValue(), 'Reversed');
assert.equal(await page.getByLabel('Ruled curve 2 object', { exact: true }).inputValue(), 'Skew');
await page.getByLabel('Ruled surface orientation', { exact: true }).selectOption('Forward');
await page.getByRole('button', { name: 'Apply ruled surface', exact: true }).click();
await page.waitForFunction(() => window.__freecadWorkbenchState?.response?.result?.objects?.find(o => o.name === 'RuledSurface')?.ruledSurface?.orientation === 'Forward');
await page.setViewportSize({ width: 390, height: 844 }); await capture('mobile-geometry.png'); await page.screenshot({ path: path.join(directory, 'mobile.png'), fullPage: true });
const layout = await page.getByRole('region', { name: 'Ruled surface task', exact: true }).evaluate(e => ({ width: e.clientWidth, scroll: e.scrollWidth }));
assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout)); assert.deepEqual(errors, []);
for (const name of ['desktop.png', 'desktop-geometry.png', 'rotated-geometry.png', 'mobile.png', 'mobile-geometry.png']) {
const file = path.join(directory, name); result.evidenceFiles[path.relative(root, file)] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
record({ operations: ['FCStd-open', 'create', 'edit-orientation', 'undo', 'redo', 'subedges', 'swap-curves', 'rotate', 'OPFS-save', 'close', 'reload', 'FCStd-open', 'edit'], viewports: [[1440, 900], [390, 844]] },
{ savedBytes: bytes.length, layout, screenshots: Object.keys(result.evidenceFiles) }, 'Mounted controls, visible native geometry and persistent edits work on desktop and mobile');
result.status = 'passed';
} 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];
}