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

201 lines
19 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';
const firstPlacement = { position: [10, -4, 2], axis: [0, 0, 2], angle: 90 };
const secondPlacement = { position: [2, 3, 4], axis: [1, 1, 1], angle: 120 };
const fixtures = [
{ kind: 'Box', parameters: { Length: 2, Width: 3, Height: 4 }, edited: { Length: 5, Width: 3, Height: 4 } },
{ kind: 'Line', parameters: { X1: 1, Y1: 2, Z1: 3, X2: 3, Y2: 5, Z2: 7 }, edited: { X1: 1, Y1: 2, Z1: 3, X2: 4, Y2: 5, Z2: 7 } },
{ kind: 'Vertex', parameters: { X: 1, Y: -2, Z: 3 }, edited: { X: 4, Y: -2, Z: 3 } },
];
function transform(point, placement) {
const magnitude = Math.hypot(...placement.axis), axis = placement.axis.map(v => magnitude ? v / magnitude : 0);
const angle = placement.angle * (Math.PI / 180), c = Math.cos(angle), s = Math.sin(angle);
const dot = point.reduce((sum, v, i) => sum + v * axis[i], 0);
const cross = [axis[1] * point[2] - axis[2] * point[1], axis[2] * point[0] - axis[0] * point[2], axis[0] * point[1] - axis[1] * point[0]];
return point.map((v, i) => placement.position[i] + v * c + cross[i] * s + axis[i] * dot * (1 - c));
}
export async function runPrimitivePlacementCases(ids) {
const capabilityId = 'Part:desktop:Part_Primitives';
if (!ids.includes(capabilityId)) return [];
const directory = path.resolve('docs/current/part-primitive-placement-business', new Date().toISOString().replace(/[:.]/g, '-'));
fs.mkdirSync(directory, { recursive: true });
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'primitive-placement-'));
const result = { capabilityId, status: 'failed', cases: [], evidenceFiles: {},
remaining: ['Active-container controls', 'Cylinder skew and circle-from-three-points interaction', 'Desktop expression binding and live task-lifecycle parity'],
deferredCases: ['runLegacyPrimitiveTaskCases', 'runAdditionalPrimitiveCases', 'runWedgePrimitiveCases', 'runHelixSpiralCases'] };
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-7, `${a} != ${b}`);
const vector = (a, b) => { assert.equal(a.length, b.length); a.forEach((v, i) => near(v, b[i])); };
const objects = state => state.objects.map(({ samplingSettings, ...object }) => object);
const signature = state => ({ objects: objects(state), undoCount: state.undoCount, redoCount: state.redoCount });
const check = (state, fixture, parameters, placement) => {
const object = state.objects.find(o => o.name === fixture.kind);
assert.equal(object.type, `Part::${fixture.kind}`); assert.deepEqual(object.parameters, parameters);
assert.equal(object.topology.valid, true); vector(object.position, placement.position);
vector(object.primitivePlacement.position, placement.position);
for (const axis of [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) vector(transform(axis, object.primitivePlacement), transform(axis, placement));
const local = fixture.kind === 'Box'
? [0, parameters.Length].flatMap(x => [0, parameters.Width].flatMap(y => [0, parameters.Height].map(z => [x, y, z])))
: fixture.kind === 'Line' ? [[parameters.X1, parameters.Y1, parameters.Z1], [parameters.X2, parameters.Y2, parameters.Z2]]
: [[parameters.X, parameters.Y, parameters.Z]];
const expected = local.map(p => transform(p, placement));
const rendered = fixture.kind === 'Box' ? Array.from({ length: object.mesh.positions.length / 3 }, (_, i) => object.mesh.positions.slice(i * 3, i * 3 + 3))
: fixture.kind === 'Line' ? object.lines.flat() : [object.primitivePoint];
for (const p of expected) assert.ok(rendered.some(v => Math.hypot(...v.map((n, i) => n - p[i])) < 1e-7), `Missing transformed point ${p}`);
for (const p of rendered) assert.ok(expected.some(v => Math.hypot(...v.map((n, i) => n - p[i])) < 1e-7), `Unexpected transformed point ${p}`);
if (fixture.kind === 'Box') near(object.volume, parameters.Length * parameters.Width * parameters.Height);
return { placement: object.primitivePlacement, points: expected, volume: object.volume };
};
let kernel, browser;
try {
vector(transform([1, 0, 0], { position: [0, 0, 0], axis: [1, 1, 1], angle: 120 }), [0, 1, 0]);
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 document = 'PrimitivePlacement', queue = new FacadeSerialQueue(); let sequence = 0;
const router = { request: (_module, _command, payload) => facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`placement-${++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 execute = (fixture, action, parameters, primitivePlacement) => adapter.executePartFixedBoxDesktop(router, 'Part_Primitives',
{ action, primitive: fixture.kind, object: fixture.kind, parameters, primitivePlacement }, document);
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('create', {}, 'PlacementOther'); const other = await call('snapshot', {}, 'PlacementOther');
for (const fixture of fixtures) {
const before = await call('snapshot'), created = await accepted(fixture, 'accept', fixture.parameters, firstPlacement);
const original = check(created, fixture, fixture.parameters, firstPlacement); assert.equal(created.undoCount, before.undoCount + 1);
assert.deepEqual(objects(await call('undo')), objects(before)); check(await call('redo'), fixture, fixture.parameters, firstPlacement);
const changed = await accepted(fixture, 'edit', fixture.edited, secondPlacement);
const edited = check(changed, fixture, fixture.edited, secondPlacement); assert.equal(changed.undoCount, created.undoCount + 1);
check(await call('undo'), fixture, fixture.parameters, firstPlacement); check(await call('redo'), fixture, fixture.edited, secondPlacement);
// Older callers can change dimensions while preserving the saved placement.
check(await accepted(fixture, 'edit', fixture.parameters), fixture, fixture.parameters, secondPlacement);
check(await call('undo'), fixture, fixture.edited, secondPlacement);
const stable = await call('snapshot');
for (const primitivePlacement of [null, {}, { ...secondPlacement, axis: [0, 0, 0] }, { ...secondPlacement, axis: [1.7e308, 1.7e308, 1.7e308] },
{ ...secondPlacement, position: [0, 1] }, { ...secondPlacement, position: [0, '1', 2] }, { ...secondPlacement, angle: null }, { ...secondPlacement, angle: '90' }]) {
assert.equal((await execute(fixture, 'edit', fixture.edited, primitivePlacement)).kind, 'error');
const raw = JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: 'part.document',
args: { document, action: 'setPrimitive', primitive: fixture.kind, object: fixture.kind, parameters: fixture.edited, primitivePlacement } })]));
assert.equal(raw.ok, false, JSON.stringify(raw)); assert.deepEqual(signature(await call('snapshot')), signature(stable));
}
assert.deepEqual(await call('snapshot', {}, 'PlacementOther'), other);
record({ primitive: fixture.kind, firstPlacement, secondPlacement }, { original, edited },
'Native and rendered points rotate and translate exactly; dimension/placement edits share one reversible transaction and invalid poses preserve state');
}
const fixture = fixtures[0];
for (const placement of [{ position: [-3, 4, -5], axis: [0, 2, 0], angle: -90 }, { position: [2, -1, 0], axis: [0, 0, 0], angle: 0 }]) {
const state = await accepted(fixture, 'edit', fixture.edited, placement); check(state, fixture, fixture.edited, placement);
check(await call('undo'), fixture, fixture.edited, secondPlacement);
record({ placement }, { restored: true }, 'Signed rotations and zero-angle identity preserve exact native placement');
}
const stable = await call('snapshot');
assert.equal((await execute(fixture, 'accept', fixture.parameters, { ...secondPlacement, axis: [0, 0, 0] })).kind, 'error');
assert.deepEqual(signature(await call('snapshot')), signature(stable));
const saved = await call('save', { path: '/tmp/PrimitivePlacement.FCStd' }); await call('close');
const restored = await call('open', { path: '/tmp/PrimitivePlacement.FCStd', bytes: saved.bytes });
for (const f of fixtures) { check(restored, f, f.edited, secondPlacement); check(await accepted(f, 'edit', f.edited, firstPlacement), f, f.edited, firstPlacement); }
record({ workflow: ['save', 'close', 'open', 'edit-placement'] }, { bytes: saved.bytes.byteLength }, 'FCStd retains native solid, curve and point placements and subsequent editing');
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 JSON.parse(reply.result.stdout.trim());
};
python(`import FreeCAD as App, json\nd=App.getDocument('${document}')\nd.Box.setExpression('Placement.Base.x','7 mm')\nd.recompute()\nprint(json.dumps(True))`);
const constrained = await call('snapshot');
const originalBindings = python(`print(json.dumps([(str(p), str(e)) for p,e in d.Box.ExpressionEngine]))`);
assert.equal(originalBindings.length, 1); near(constrained.objects.find(o => o.name === 'Box').position[0], 7);
for (const route of ['facade', 'native']) {
const args = { document, action: 'setPrimitive', primitive: 'Box', object: 'Box', parameters: { ...fixture.edited, Height: 11 }, primitivePlacement: secondPlacement };
const reply = route === 'facade' ? await execute(fixture, 'edit', args.parameters, secondPlacement)
: JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: 'part.document', args })]));
assert.equal(route === 'facade' ? reply.kind : reply.ok, route === 'facade' ? 'error' : false, JSON.stringify(reply));
assert.match(JSON.stringify(reply), /controlled by an attachment or expression/);
assert.deepEqual(signature(await call('snapshot')), signature(constrained));
}
const bindings = python(`print(json.dumps([(str(p), str(e)) for p,e in d.Box.ExpressionEngine]))`);
assert.deepEqual(bindings, originalBindings);
record({ constraint: 'Placement.Base.x = 7 mm', attemptedHeight: 11, attemptedPlacement: secondPlacement }, { bindings },
'Placement overridden during native recompute aborts the entire dimension/placement transaction and preserves expressions and history');
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 }, deviceScaleFactor: 1 }), 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_Primitives&document=PlacementUI`);
const panel = page.getByRole('region', { name: 'Part primitives', exact: true });
await panel.getByLabel('Primitives document', { exact: true }).fill('PlacementUI'); await panel.getByRole('button', { name: 'New document', exact: true }).click();
const fillPlacement = async placement => {
for (const key of ['position', 'axis']) for (const [i, axis] of ['X', 'Y', 'Z'].entries())
await panel.getByLabel(`Primitive ${key} ${axis}`, { exact: true }).fill(String(placement[key][i]));
await panel.getByLabel('Primitive rotation angle', { exact: true }).fill(String(placement.angle));
};
const wait = async (kind, p) => page.waitForFunction(({ kind, p }) => {
const object = window.__freecadWorkbenchState?.response?.result?.objects?.find(o => o.name === kind);
return object && object.primitivePlacement && object.primitivePlacement.position.every((v, i) => Math.abs(v - p.position[i]) < 1e-8)
&& Math.abs(object.primitivePlacement.angle - p.angle) < 1e-8;
}, { kind, p });
for (const f of fixtures) {
await panel.getByLabel('Primitive type', { exact: true }).selectOption(f.kind);
for (const [key, value] of Object.entries(f.parameters)) await panel.getByLabel(`Primitive ${key}`, { exact: true }).fill(String(value));
await fillPlacement(firstPlacement); await panel.getByRole('button', { name: 'Create primitive', exact: true }).click(); await wait(f.kind, firstPlacement);
check(await page.evaluate(() => window.__freecadWorkbenchState.response.result), f, f.parameters, firstPlacement);
await fillPlacement(secondPlacement); await panel.getByRole('button', { name: 'Apply primitive', exact: true }).click(); await wait(f.kind, secondPlacement);
check(await page.evaluate(() => window.__freecadWorkbenchState.response.result), f, f.parameters, secondPlacement);
}
await panel.getByLabel('Existing primitive', { exact: true }).selectOption('Box');
await panel.getByRole('checkbox', { name: 'Isolate primitive', exact: true }).check();
const capture = async name => {
const png = await panel.locator('[aria-label="Primitives geometry"] 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 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 > 1, `Blank ${name}`);
const hash = createHash('sha256').update(png).digest('hex'); result.evidenceFiles[path.relative('.', path.join(directory, name))] = hash; return hash;
};
const first = await capture('desktop.png'), rect = await panel.locator('[aria-label="Primitives 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 + 65, rect.y + rect.height / 2 + 30, { steps: 8 }); await page.mouse.up(); assert.notEqual(await capture('rotated.png'), first);
await fillPlacement({ ...secondPlacement, axis: [0, 0, 0] }); await panel.getByRole('button', { name: 'Apply primitive', exact: true }).click();
await panel.getByRole('alert').waitFor(); await panel.getByRole('button', { name: 'Cancel', exact: true }).click(); await wait('Box', secondPlacement);
await panel.getByRole('button', { name: 'Reset primitive placement', exact: true }).click();
await panel.getByRole('button', { name: 'Apply primitive', exact: true }).click();
await wait('Box', { position: [0, 0, 0], angle: 0 });
await panel.getByRole('button', { name: 'Primitives undo', exact: true }).click();
await page.waitForFunction(() => Math.abs(Number(document.querySelector('[aria-label="Primitive rotation angle"]')?.value) - 120) < 1e-7);
await panel.getByRole('button', { name: 'Primitives redo', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Primitive rotation angle"]')?.value === '0');
await panel.getByRole('button', { name: 'Save primitives document', exact: true }).click(); await panel.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-PlacementUI.FCStd'))]; });
await page.reload(); await panel.getByLabel('Primitives document', { exact: true }).fill('PlacementUI');
await panel.getByLabel('Primitives FCStd file', { exact: true }).setInputFiles({ name: 'PlacementUI.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(bytes) });
await panel.locator('[data-primitive-object="Box"]').waitFor(); await panel.getByLabel('Existing primitive', { exact: true }).selectOption('Box');
await fillPlacement(firstPlacement); await panel.getByRole('button', { name: 'Apply primitive', exact: true }).click(); await wait('Box', firstPlacement);
check(await page.evaluate(() => window.__freecadWorkbenchState.response.result), fixtures[0], fixtures[0].parameters, firstPlacement);
await page.setViewportSize({ width: 390, height: 844 }); await capture('mobile.png');
assert.ok(await panel.evaluate(element => element.scrollWidth <= element.clientWidth + 1));
await panel.screenshot({ path: path.join(directory, 'mobile-task.png') }); assert.deepEqual(errors, []);
record({ workflow: ['placed-create', 'placed-edit', 'invalid-axis', 'cancel', 'reset', 'undo', 'redo', 'OPFS-save', 'reload', 'open', 'edit'], widths: [1440, 390] },
{ bytes: bytes.length }, 'Mounted placement controls, transformed render geometry and persisted history work for solids, lines and points');
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];
}