Files
FreeCADWasmDev/tools/partdesign-subtractive-business-cases.mjs

182 lines
18 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
import { playwright } from "./part-primitive-business-cases.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const subtractiveIds = ["SubtractiveLoft", "SubtractivePipe", "SubtractiveHelix", "Thickness"].map(type => `PartDesign:desktop:PartDesign_${type}`);
const checkVolume = (value, expected, tolerance) => assert.ok(Number.isFinite(value) && Math.abs(value - expected) < tolerance, `${value} != ${expected}`);
const compact = state => ({ ...state, objects: state.objects?.map(({ mesh, ...object }) => ({ ...object,
...(mesh ? { mesh: { triangleCount: mesh.triangleCount, bounds: mesh.bounds } } : {}) })) });
export async function runSubtractiveCases(ids, loadWorker) {
if (!ids.length) return [];
const results = ids.map(capabilityId => ({ capabilityId, status: "failed", cases: [], timing: [] }));
let browser, kernel;
try {
const started = performance.now();
({ kernel } = await loadWasmProbe(process.env.FREECAD_PARTDESIGN_PROFILE_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebPartDesignRuntimeProbe"));
const loadSeconds = (performance.now() - started) / 1000;
const worker = loadWorker(kernel); let serial = 0;
const router = { request: (_module, cmd, args, document) => worker.send(worker.request(`subtractive-${++serial}`, cmd, args, document)) };
const raw = (document, args) => JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: "partdesign.document", args: { document, ...args } })]));
const ok = (document, args) => { const response = raw(document, args); assert.equal(response.ok, true, JSON.stringify(response)); return response.result; };
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
for (const result of results) {
const type = result.capabilityId.split(":").at(-1).replace("PartDesign_", ""), loft = type.endsWith("Loft"), helix = type.endsWith("Helix");
const thickness = type === "Thickness";
const baseVolume = helix ? 1728 : 1000, baseGrowth = helix ? 288 : thickness ? 104 : 200;
// OCCT approximates the swept helix with spline surfaces; allow 0.0001 mm^3 integration error.
const near = (value, expected) => checkVolume(value, expected, helix ? 1e-4 : 1e-5);
const document = `Business${type}`, other = `${document}Other`, filename = `/tmp/${document}.FCStd`;
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); context.setDefaultTimeout(15000);
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
const feature = state => state.objects.find(value => value.name === "Result");
const sketch = (name, position, parameters, profileType = "Rectangle", doc = document) => ok(doc, { action: "setSketch", body: "Body", object: name, profileType, parameters, position });
try {
let start = performance.now();
ok(document, { action: "create" }); ok(document, { action: "createBody", object: "Body" });
sketch("BaseProfile", helix ? [-6, 0, -6] : [0, 0, 0], helix ? { Width: 12, Height: 12 } : { Width: 10, Height: 10 });
ok(document, { action: "setFeature", body: "Body", object: "Base", featureType: "Pad", profile: "BaseProfile", length: helix ? 12 : 10 });
if (loft) {
sketch("Profile", [2, 2, 10], { Width: 2, Height: 3 });
sketch("Section", [2, 2, 5], { Width: 2, Height: 3 });
sketch("Section2", [2, 2, 2], { Width: 2, Height: 3 });
} else if (helix) sketch("Profile", [4, 0, 0], { Width: 1, Height: 1 });
else sketch("Profile", [0, 0, 0], { Radius: 1 }, "Circle");
ok(other, { action: "create" }); ok(other, { action: "createBody", object: "Body" });
sketch("Foreign", [0, 0, 5], { Width: 2, Height: 3 }, "Rectangle", other);
const isolated = ok(other, { action: "snapshot" });
ok(document, { action: "save", path: filename }); const seed = Buffer.from(kernel.FS.readFile(filename));
const args = { body: "Body", object: "Result", includeMesh: true,
...(thickness ? { base: "Base", faces: ["Face6"], size: 1, reversed: true,
selection: [{ documentId: document, object: "Base", subelements: ["Face6"] }] } : { profile: "Profile",
...(loft ? { sections: ["Section"], ruled: true } : helix ? { axis: "Y_Axis", pitch: 3, height: 6 } : { spine: "Base", spineEdges: ["Edge1"] }) }) };
const expected = thickness ? 424 : loft ? 970 : helix ? baseVolume - 18 * Math.PI : 1000 - 10 * Math.PI / 4;
const editedExpected = thickness ? 712 : loft ? 952 : helix ? baseVolume - 27 * Math.PI : 1000 - 10 * Math.PI;
const selections = [];
const execute = async details => {
const reply = await worker.desktop(router, `PartDesign_${type}`, details, document, (doc, selected) => selections.push({ doc, selected }));
assert.equal(reply.kind, "result", JSON.stringify(reply)); return reply.result;
};
const initial = await execute(args), solid = feature(initial);
assert.equal(solid.type, `PartDesign::${type}`); assert.equal(solid.base, "Base"); near(solid.volume, expected);
assert.ok(solid.mesh.triangleCount > 0); assert.equal(solid.mesh.positions.length, solid.mesh.triangleCount * 9);
assert.equal(initial.objects.find(value => value.name === "Body").tip, "Result");
assert.equal(selections[0].selected[0].object, "Result"); near(initial.objects.find(value => value.name === "Base").volume, baseVolume);
if (helix) { assert.equal(solid.axis, "Y_Axis"); assert.equal(solid.pitch, 3); assert.equal(solid.height, 6); assert.equal(solid.leftHanded, false); }
if (thickness) { assert.deepEqual(Array.from(solid.faces), ["Face6"]); assert.equal(solid.size, 1); assert.equal(solid.reversed, true); }
assert.deepEqual(ok(other, { action: "snapshot" }), isolated);
record({ command: type, document, ...args }, compact(initial), "native subtractive solid has expected removed volume, base link, Body tip, mesh and isolated document ownership");
assert.equal(feature(ok(document, { action: "undo" })), undefined);
near(feature(ok(document, { action: "redo" })).volume, expected);
const editedArgs = thickness ? { ...args, size: 2, selection: [{ documentId: document, object: "Result" }] } : loft ? { ...args, sections: ["Section2"] } : helix ? { ...args, height: 9, leftHanded: true } : args;
const edited = thickness || loft || helix ? await execute(editedArgs) : sketch("Profile", [0, 0, 0], { Radius: 2 }, "Circle");
near(feature(edited).volume, editedExpected);
if (helix) { assert.equal(feature(edited).height, 9); assert.equal(feature(edited).leftHanded, true); }
near(feature(ok(document, { action: "undo" })).volume, expected);
near(feature(ok(document, { action: "redo" })).volume, editedExpected);
record({ args: editedArgs, radius: loft ? undefined : 2 }, compact(edited), "changed section or linked profile recomputes the native cut and undo/redo restores both volumes");
if (helix) {
const resizedProfile = sketch("Profile", [4, 0, 0], { Width: 1.5, Height: 1 });
near(feature(resizedProfile).volume, baseVolume - 42.75 * Math.PI);
near(feature(ok(document, { action: "undo" })).volume, editedExpected);
near(feature(ok(document, { action: "redo" })).volume, baseVolume - 42.75 * Math.PI);
near(feature(sketch("Profile", [4, 0, 0], { Width: 1, Height: 1 })).volume, editedExpected);
record({ object: "Profile", width: 1.5 }, compact(resizedProfile), "linked profile edit changes analytic helical removal and native undo restores the original cut");
}
for (const invalid of [{ [thickness ? "base" : "profile"]: "Missing" }, { [thickness ? "base" : "profile"]: "Foreign" }, { body: "Missing" }, { activeTask: true }, { action: "cancel" },
...(thickness ? [{ faces: [] }, { faces: ["Edge1"] }, { faces: ["Face999"] }, { faces: ["Face6", "Face6"] }, { size: 0 }, { size: 100 }, { base: "Result" }, { selection: [{ documentId: other, object: "Base", subelements: ["Face6"] }] }]
: loft ? [{ sections: [] }, { sections: ["Profile"] }, { sections: ["Foreign"] }, { sections: ["Section", "Section"] }]
: helix ? [{ pitch: 0 }, { height: -1 }, { axis: "Foreign" }, { axis: "Base" }, { leftHanded: "true" }]
: [{ spine: "Result" }, { spine: "Foreign" }, { spineEdges: ["Edge99999"] }, { spineEdges: ["bad"] }])]) {
const before = ok(document, { action: "snapshot" });
const rejected = await worker.desktop(router, `PartDesign_${type}`, { ...editedArgs, ...invalid }, document);
assert.equal(rejected.kind, "error", JSON.stringify(rejected)); assert.deepEqual(ok(document, { action: "snapshot" }), before);
record({ ...editedArgs, ...invalid }, rejected, "invalid feature links and parameters preserve the document and transaction history");
}
const missingBase = raw(other, { action: thickness ? "setDressUp" : "setFeature", body: "Body", object: "Result", profile: "Foreign", featureType: type,
...(thickness ? { dressUpType: type, base: "Foreign", faces: ["Face6"], size: 1 } : {}),
...(loft ? { sections: ["Foreign"] } : helix ? { axis: "Y_Axis", height: 6, pitch: 3 } : { spine: "Foreign" }) });
assert.equal(missingBase.ok, false); assert.deepEqual(ok(other, { action: "snapshot" }), isolated);
record({ document: other, featureType: type }, missingBase, "an empty Body cannot masquerade as a subtractive base solid");
const saved = ok(document, { action: "save", path: filename }); ok(document, { action: "close" });
const reopened = ok(document, { action: "open", path: filename }); near(feature(reopened).volume, editedExpected);
assert.deepEqual(reopened.objects.map(({ volume, ...value }) => value), saved.objects.map(({ volume, ...value }) => value));
for (const object of reopened.objects) if (object.volume !== undefined)
near(object.volume, saved.objects.find(value => value.name === object.name).volume);
near(feature(await execute(editedArgs)).volume, editedExpected);
const resized = sketch("BaseProfile", helix ? [-6, 0, -6] : [0, 0, 0], helix ? { Width: 14, Height: 12 } : { Width: 12, Height: 10 });
near(feature(resized).volume, editedExpected + baseGrowth);
near(feature(ok(document, { action: "undo" })).volume, editedExpected);
near(feature(ok(document, { action: "redo" })).volume, editedExpected + baseGrowth);
record({ path: filename, args: editedArgs }, compact(reopened), "FCStd restores native subtractive feature identity, dependencies, parameters and continued editing");
result.timing.push({ stage: "shared-wasm-load", seconds: loadSeconds }, { stage: "native-and-worker", seconds: (performance.now() - start) / 1000 });
start = performance.now(); const page = await context.newPage(), errors = []; page.on("pageerror", error => errors.push(String(error)));
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? "http://localhost:5180"}/partdesign.html`, { waitUntil: "domcontentloaded" });
const ready = () => page.waitForSelector('.design-app[data-busy="false"]');
const select = async name => { await page.locator(`[data-design-object="${name}"]`).click(); };
const volume = () => page.locator("[data-design-volume]").innerText().then(Number);
await page.getByLabel("Open FCStd file", { exact: true }).setInputFiles({ name: `${document}.FCStd`, mimeType: "application/octet-stream", buffer: seed });
await page.locator('[data-design-object="Base"]').waitFor(); await ready();
if (!thickness) { await page.locator(`[data-feature-type="${type}"]`).click(); await page.getByLabel("Feature Profile", { exact: true }).selectOption("Profile"); }
if (thickness) { await page.getByLabel("Thickness Base").selectOption("Base"); await page.getByLabel("Thickness Faces").selectOption(["Face6"]); await page.getByLabel("Thickness Size").fill("1"); await page.getByLabel("Thickness Inwards").check(); }
else if (loft) { await page.getByLabel("Loft Sections").selectOption(["Section"]); await page.getByLabel("Loft Ruled").check(); }
else if (helix) { await page.getByLabel("Helix Axis").selectOption("Y_Axis"); await page.getByLabel("Helix Pitch").fill("3"); await page.getByLabel("Helix Height").fill("6"); }
else { await page.getByLabel("Pipe Spine").selectOption("Base"); await page.getByLabel("Pipe Edges").selectOption(["Edge1"]); }
await page.locator(thickness ? "[data-create-thickness]" : "[data-create-feature]").click(); await ready(); await select(`${type}1`); near(await volume(), expected);
await page.getByRole("button", { name: "撤销", exact: true }).click(); await ready();
assert.equal(await page.locator(`[data-design-object="${type}1"]`).count(), 0);
await page.getByRole("button", { name: "重做", exact: true }).click(); await ready(); await select(`${type}1`); near(await volume(), expected);
if (thickness) { await page.getByLabel("Thickness Size").fill("2"); await page.locator("[data-update-thickness]").click(); }
else if (loft) { await page.getByLabel("Loft Sections").selectOption(["Section2"]); await page.locator("[data-update-feature]").click(); }
else if (helix) { await page.getByLabel("Helix Height").fill("9"); await page.getByLabel("Helix Left Handed").check(); await page.locator("[data-update-feature]").click(); }
else { await select("Profile"); await page.getByLabel("Sketch Radius", { exact: true }).fill("2"); await page.locator("[data-update-sketch]").click(); }
await ready(); await select(`${type}1`); near(await volume(), editedExpected);
await page.getByRole("button", { name: "保存 FCStd 到 OPFS", exact: true }).click(); await ready();
const file = await page.getByLabel("OPFS document", { exact: true }).inputValue(); assert.ok(file.endsWith(".FCStd"));
await page.reload({ waitUntil: "domcontentloaded" }); await page.getByLabel("OPFS document", { exact: true }).selectOption(file);
await page.getByRole("button", { name: "打开 OPFS 文档", exact: true }).click();
await page.locator(`[data-design-object="${type}1"]`).waitFor(); await ready(); await select(`${type}1`); near(await volume(), editedExpected);
if (thickness) { assert.equal(await page.getByLabel("Thickness Size").inputValue(), "2"); assert.equal(await page.getByLabel("Thickness Inwards").isChecked(), true); assert.deepEqual(await page.getByLabel("Thickness Faces").evaluate(select => Array.from(select.selectedOptions, option => option.value)), ["Face6"]); }
else if (loft) assert.deepEqual(await page.getByLabel("Loft Sections").evaluate(select => Array.from(select.selectedOptions, option => option.value)), ["Section2"]);
else if (helix) { assert.equal(await page.getByLabel("Helix Height").inputValue(), "9"); assert.equal(await page.getByLabel("Helix Pitch").inputValue(), "3"); assert.equal(await page.getByLabel("Helix Axis").inputValue(), "Y_Axis"); assert.equal(await page.getByLabel("Helix Left Handed").isChecked(), true); }
else { assert.equal(await page.getByLabel("Pipe Spine").inputValue(), "Base"); assert.deepEqual(await page.getByLabel("Pipe Edges").evaluate(select => Array.from(select.selectedOptions, option => option.value)), ["Edge1"]); }
const screenshots = path.join(root, "docs/current/partdesign-subtractive-browser"); fs.mkdirSync(screenshots, { recursive: true });
const viewports = [];
for (const size of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
await page.setViewportSize(size); const canvas = page.locator(".design-viewport canvas"); await canvas.scrollIntoViewIfNeeded();
const before = await canvas.screenshot({ path: path.join(screenshots, `${type}-${size.width}-canvas.png`) });
const pixels = await page.evaluate(async url => {
const image = new Image(); image.src = url; await image.decode();
const canvas = document.createElement("canvas"); canvas.width = canvas.height = 128;
const ctx = canvas.getContext("2d"); ctx.drawImage(image, 0, 0, 128, 128); const rgba = ctx.getImageData(0, 0, 128, 128).data;
let painted = 0; for (let i = 0; i < rgba.length; i += 4) if (Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) - Math.min(rgba[i], rgba[i + 1], rgba[i + 2]) > 25) painted++;
return { painted, overflow: document.documentElement.scrollWidth > innerWidth };
}, `data:image/png;base64,${before.toString("base64")}`);
assert.ok(pixels.painted > 100, JSON.stringify(pixels)); assert.equal(pixels.overflow, false);
const box = await canvas.boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 50, box.y + box.height / 2 + 20, { steps: 5 }); await page.mouse.up();
assert.notDeepEqual(await canvas.screenshot(), before);
await page.locator("[data-create-feature]").scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(screenshots, `${type}-${size.width}-controls.png`) });
viewports.push({ ...size, ...pixels, orbitChanged: true });
}
assert.deepEqual(errors, []);
record({ type, file, route: "/partdesign.html", args, editedArgs }, { expected, editedExpected, viewports, errors },
"actual React controls create and edit the native subtraction; undo/redo, OPFS reload, parameters and interactive Three.js display survive desktop/mobile viewports");
result.timing.push({ stage: "browser-and-opfs", seconds: (performance.now() - start) / 1000 }); result.status = "passed";
} catch (error) { result.error = String(error.stack ?? error); }
finally {
for (const doc of [document, other]) raw(doc, { action: "close" });
try { kernel.FS.unlink(filename); } catch {}
await context.close();
}
}
} catch (error) { for (const result of results) if (result.status !== "passed") result.error = String(error.stack ?? error); }
finally { await browser?.close(); }
return results;
}