213 lines
18 KiB
JavaScript
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];
|
|
}
|