Files
FreeCADWasmDev/tools/bim-nudge-business-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

186 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 { fileURLToPath, pathToFileURL } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const nudgeIds = ['Up', 'Down', 'Left', 'Right', 'Extend', 'Shrink', 'RotateLeft', 'RotateRight', 'Switch']
.map(mode => `BIM:desktop:BIM_Nudge_${mode}`);
const near = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-6, `${actual} != ${expected}`);
const vectorNear = (actual, expected) => { assert.equal(actual.length, expected.length); actual.forEach((v, i) => near(v, expected[i])); };
export async function runNudgeCases(kernel, ids) {
if (!ids.length) return [];
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'freecad-bim-nudge-'));
const results = [];
const started = performance.now();
const python = script => {
const reply = JSON.parse(kernel.ccall('fc_web_bim_test_python', 'string', ['string'], [`import FreeCAD as App, Part, json\n${script}`]));
if (reply?.ok === false) throw new Error(reply.error || JSON.stringify(reply));
return reply;
};
try {
execFileSync(path.join(root, 'web/node_modules/.bin/tsc'), [
'--target', 'ES2022', '--module', 'ESNext', '--moduleResolution', 'Bundler', '--strict', '--skipLibCheck',
'--outDir', out, path.join(root, 'web/src/bim-python-family-batch-facade.ts'), path.join(root, 'web/src/bim-desktop-adapter.ts'),
], { cwd: root, stdio: 'pipe' });
const facade = await import(pathToFileURL(path.join(out, 'bim-python-family-batch-facade.js')));
const adapter = await import(pathToFileURL(path.join(out, 'bim-desktop-adapter.js')));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, 'facade-envelope.js')));
const queue = new FacadeSerialQueue();
const compileSeconds = (performance.now() - started) / 1000;
for (const capabilityId of ids) {
const row = { capabilityId, status: 'failed', cases: [], timing: { sharedTranspileSeconds: compileSeconds } };
results.push(row);
const start = performance.now();
const command = capabilityId.split(':').at(-1), mode = command.replace('BIM_Nudge_', '');
const document = `Nudge_${mode}`, other = `${document}_Other`, restored = `${document}_Restored`;
const savedPath = `/tmp/${document}.FCStd`;
const record = { id: capabilityId, module: 'BIM', command, sourcePath: 'src/Mod/BIM/bimcommands/BimNudge.py', sourceLine: 1 };
let sequence = 0;
const baseArgs = { documentId: document, selection: [{ object: 'First' }, { object: 'Second' }], distance: 2 };
const invoke = async overrides => {
const args = adapter.prepareBimDesktopArgs(record, { ...baseArgs, ...overrides });
const request = facade.createBimPythonFamilyBatchRequest(`nudge-${mode}-${++sequence}`, [{
apiId: capabilityId, name: command, family: 'desktop-command', sourcePath: record.sourcePath,
sourceLine: record.sourceLine, args,
}], 'tier-2-pure');
const response = await facade.dispatchBimPythonFamilyBatch(queue, request, kernel);
return adapter.validateBimDesktopResponse(record, response, capabilityId);
};
const ok = async args => { const reply = await invoke(args); assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result.records[0].result; };
const snapshot = (name = document) => python(`
d = App.getDocument(${JSON.stringify(name)})
result = json.dumps({'active': App.ActiveDocument.Name, 'objects': [
{'name': o.Name, 'position': list(o.Placement.Base), 'rotation': list(o.Placement.Rotation.Q),
'height': o.Height.Value, 'volume': o.Shape.Volume, 'valid': o.Shape.isValid(),
'bounds': [o.Shape.BoundBox.XMin, o.Shape.BoundBox.YMin, o.Shape.BoundBox.ZMin,
o.Shape.BoundBox.XMax, o.Shape.BoundBox.YMax, o.Shape.BoundBox.ZMax]}
for o in d.Objects]})`);
const add = (input, actual, name) => row.cases.push({ input, actual, assertions: [{ name, passed: true }] });
try {
assert.ok(nudgeIds.includes(capabilityId));
python(`
for name in ${JSON.stringify([document, other, restored])}:
if name in App.listDocuments(): App.closeDocument(name)
d = App.newDocument(${JSON.stringify(document)})
d.UndoMode = 1
for name, dims, position in [('First', [4, 6, 10], [10, 20, 30]), ('Second', [2, 3, 8], [-7, 2, 3]), ('Unselected', [1, 2, 3], [80, 80, 80])]:
obj = d.addObject('Part::Box', name)
obj.Length, obj.Width, obj.Height = dims
obj.Placement.Base = App.Vector(*position)
d.recompute()
other = App.newDocument(${JSON.stringify(other)})
obj = other.addObject('Part::Box', 'Independent')
obj.Length, obj.Width, obj.Height = [7, 8, 9]
other.recompute()
App.setActiveDocument(other.Name)
result = json.dumps({'created': d.Name, 'other': other.Name})`);
const isolated = snapshot(other);
const rotating = mode.startsWith('Rotate'), resizing = ['Extend', 'Shrink'].includes(mode);
const variants = mode === 'Switch' ? [{ nudge: '5 mm', selection: [] }, { nudge: 'Auto', viewWidth: 600, selection: [] }]
: rotating ? [{}, { workingPlane: { u: [0, 1, 0], v: [0, 0, 1] } }]
: [{ distance: 2 }, { distance: '0.5 cm', workingPlane: { u: [0, 1, 0], v: [0, 0, 1] } }, { nudge: 'Auto', viewWidth: 600 }];
for (const input of variants) {
const before = snapshot(), actual = await ok(input), after = snapshot();
assert.equal(actual.documentId, document); assert.equal(after.active, before.active);
assert.deepEqual(after.objects[2], before.objects[2]); assert.deepEqual(snapshot(other), isolated);
if (mode === 'Switch') {
assert.equal(actual.nudge.toLowerCase().includes('auto'), input.nudge !== 'Auto');
assert.deepEqual(after, before);
} else {
assert.equal(actual.objects.length, 2);
const distance = input.distance === 2 ? 2 : 5;
for (let i = 0; i < 2; i++) {
const previous = before.objects[i], next = after.objects[i];
assert.equal(next.valid, true); vectorNear(actual.objects[i].placement, next.position);
if (resizing) {
near(next.height, previous.height + (mode === 'Extend' ? distance : -distance));
near(next.volume, previous.volume / previous.height * next.height); vectorNear(next.position, previous.position);
} else if (rotating) {
const center = [0, 1, 2].map(axis => (Math.min(...before.objects.slice(0, 2).map(o => o.bounds[axis]))
+ Math.max(...before.objects.slice(0, 2).map(o => o.bounds[axis + 3]))) / 2);
const angle = (mode === 'RotateLeft' ? 1 : -1) * Math.PI / 4, c = Math.cos(angle), s = Math.sin(angle);
const rel = previous.position.map((v, axis) => v - center[axis]);
const expected = input.workingPlane ? [rel[0], c * rel[1] - s * rel[2], s * rel[1] + c * rel[2]]
: [c * rel[0] - s * rel[1], s * rel[0] + c * rel[1], rel[2]];
vectorNear(next.position, expected.map((v, axis) => v + center[axis])); near(next.volume, previous.volume);
} else {
const plane = input.workingPlane || { u: [1, 0, 0], v: [0, 1, 0] };
const axis = ['Up', 'Down'].includes(mode) ? plane.v : plane.u;
const sign = ['Down', 'Left'].includes(mode) ? -1 : 1;
vectorNear(next.position, previous.position.map((v, i) => v + sign * distance * axis[i])); near(next.volume, previous.volume);
}
}
python(`App.getDocument(${JSON.stringify(document)}).undo()\nresult = json.dumps({'undone': True})`);
assert.deepEqual(snapshot(), before);
python(`App.getDocument(${JSON.stringify(document)}).redo()\nresult = json.dumps({'redone': True})`);
assert.deepEqual(snapshot(), after);
// Keep each parameter variant independent, including shrink's positive heights.
python(`App.getDocument(${JSON.stringify(document)}).undo()\nresult = json.dumps({'reset': True})`);
}
add({ command, ...baseArgs, ...input }, { actual, before, after },
'production facade accepts native result; supplied parameters change selected geometry with undo/redo and document isolation');
}
const invalidInputs = [
{ activeTask: true }, { documentId: 'MissingNudgeDocument' },
{ selection: [{ object: 'First' }, { object: 'Missing' }] },
{ selection: [{ object: 'First', documentId: other }] },
{ workingPlane: { u: [2, 0, 0], v: [0, 1, 0] } },
{ workingPlane: { u: [1, 0, 0], v: [1, 0, 0] } },
...(!rotating ? [{ distance: 0 }, { distance: 'invalid unit' }] : []),
...(mode === 'Switch' ? [] : [{ selection: [] }]),
];
for (const input of invalidInputs) {
const before = snapshot(), rejected = await invoke(input);
assert.equal(rejected.kind, 'error', JSON.stringify(rejected));
assert.notEqual(rejected.error.code, 'native_protocol_error', JSON.stringify(rejected));
assert.deepEqual(snapshot(), before); assert.deepEqual(snapshot(other), isolated);
add({ command, ...baseArgs, ...input }, rejected, 'invalid inputs return business errors without changing either document');
}
const recovered = await ok(mode === 'Switch' ? { nudge: '5 mm', selection: [] } : {}), saved = snapshot();
const restoredState = python(`
import os, shutil
d = App.getDocument(${JSON.stringify(document)})
d.saveAs(${JSON.stringify(savedPath)})
copy = ${JSON.stringify(savedPath + '.copy.FCStd')}
shutil.copyfile(${JSON.stringify(savedPath)}, copy)
r = App.openDocument(copy)
r.recompute()
result = json.dumps({'size': os.path.getsize(copy), 'objects': [
{'name': o.Name, 'position': list(o.Placement.Base), 'rotation': list(o.Placement.Rotation.Q),
'height': o.Height.Value, 'volume': o.Shape.Volume} for o in r.Objects]})
App.closeDocument(r.Name)
App.setActiveDocument(${JSON.stringify(other)})
os.remove(copy)`);
assert.ok(restoredState.size > 0);
assert.equal(restoredState.objects.length, saved.objects.length);
restoredState.objects.forEach((o, i) => {
assert.equal(o.name, saved.objects[i].name); vectorNear(o.position, saved.objects[i].position);
vectorNear(o.rotation, saved.objects[i].rotation); near(o.height, saved.objects[i].height); near(o.volume, saved.objects[i].volume);
});
add({ command, phase: 'recovery-and-FCStd-reopen', path: savedPath }, { recovered, restoredState },
'valid execution recovers after rejected input and saved FCStd preserves current native geometry');
row.status = 'passed';
} catch (error) {
row.error = String(error.stack || error);
row.cases.push({ input: { command, phase: 'execution' }, assertions: [{ name: 'workflow completed', passed: false, actual: row.error }] });
} finally {
try {
python(`
import os
for name in ${JSON.stringify([document, other, restored])}:
if name in App.listDocuments(): App.closeDocument(name)
for file in ${JSON.stringify([savedPath, savedPath + '1', savedPath + '.copy.FCStd'])}:
if os.path.isfile(file): os.remove(file)
result = json.dumps({'closed': True})`);
} catch (error) { row.status = 'failed'; row.error = `${row.error || ''}\ncleanup: ${error}`; }
row.timing.nativeAndFacadeSeconds = (performance.now() - start) / 1000;
}
}
} finally { fs.rmSync(out, { recursive: true, force: true }); }
return results;
}