Files
FreeCADWasmDev/tools/partdesign-finishing-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

181 lines
16 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 finishingCases = Object.fromEntries(["Revolution", "Groove", "Fillet", "Chamfer"].map(type => [`PartDesign:desktop:PartDesign_${type}`, type]));
const near = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-5, `${actual} != ${expected}`);
const compact = result => ({ ...result, objects: result.objects?.map(({ mesh, ...object }) => ({ ...object, ...(mesh ? { mesh: { triangleCount: mesh.triangleCount, bounds: mesh.bounds } } : {}) })) });
const volume = (type, edited = false, sourceEdited = false) => {
if (type === "Revolution") return Math.PI * 16 * (sourceEdited ? 9 : 8) * (edited ? 0.5 : 1);
if (type === "Groove") return Math.PI * 16 * (sourceEdited ? 9 : 8) - Math.PI * 14 * (edited ? 0.25 : 0.5);
const length = sourceEdited ? 8 : 6, size = edited ? 0.8 : 0.5;
return 4 * 8 * length - (type === "Fillet" ? 1 - Math.PI / 4 : 0.5) * size ** 2 * length;
};
async function canvasCheck(page, type) {
const artifacts = path.join(root, "docs/current/partdesign-finishing-browser"); fs.mkdirSync(artifacts, { recursive: true });
const results = [];
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport); const canvas = page.locator(".design-viewport canvas"); await canvas.scrollIntoViewIfNeeded();
const before = await canvas.screenshot();
const pixels = await page.evaluate(async url => {
const image = new Image(); image.src = url; await image.decode(); const c = document.createElement("canvas"); c.width = c.height = 128;
const ctx = c.getContext("2d"); ctx.drawImage(image, 0, 0, 128, 128); const data = ctx.getImageData(0, 0, 128, 128).data;
let painted = 0; for (let i = 0; i < data.length; i += 4) if (Math.max(data[i], data[i + 1], data[i + 2]) - Math.min(data[i], data[i + 1], data[i + 2]) > 25) painted++;
return { painted, overflow: document.documentElement.scrollWidth > innerWidth };
}, `data:image/png;base64,${before.toString("base64")}`);
assert.ok(pixels.painted > 100); assert.equal(pixels.overflow, false);
await page.screenshot({ path: path.join(artifacts, `${type}-${viewport.width}.png`) });
const b = await canvas.boundingBox(); await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); await page.mouse.down();
await page.mouse.move(b.x + b.width / 2 + 50, b.y + b.height / 2 + 20, { steps: 6 }); await page.mouse.up();
assert.notDeepEqual(await canvas.screenshot(), before); results.push({ ...viewport, ...pixels, orbitChanged: true });
}
return results;
}
export async function runFinishingCases(ids, loadBusinessWorker) {
if (!ids.length) return [];
const results = ids.map(capabilityId => ({ capabilityId, status: "failed", cases: [], timing: [], nativeTiming: [] }));
let kernel, browser;
try {
const start = performance.now();
({ kernel } = await loadWasmProbe(process.env.FREECAD_PARTDESIGN_PROFILE_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebPartDesignRuntimeProbe"));
const loadSeconds = (performance.now() - start) / 1000;
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 = finishingCases[result.capabilityId], document = `Finishing${type}`, filename = `/tmp/${document}.FCStd`;
const rotary = type === "Revolution" || type === "Groove";
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); context.setDefaultTimeout(12000);
const call = args => {
const start = performance.now();
if (process.env.FREECAD_FINISHING_TRACE === "1") fs.writeSync(2, `${document} ${JSON.stringify(args)}\n`);
const value = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: "partdesign.document", args: { document, body: "Body", ...args } })]));
result.nativeTiming.push({ action: args.action, object: args.object, ok: value.ok, seconds: (performance.now() - start) / 1000 });
return value;
};
const ok = args => {
const reply = call(args);
if (!reply.ok) result.cases.push({ input: { document, ...args }, actual: reply, assertions: [{ name: "native operation succeeds with valid current-document input", passed: false }] });
assert.equal(reply.ok, true, JSON.stringify(reply)); return reply.result;
};
const record = (input, actual, name) => result.cases.push({ input: { document, ...input }, actual: compact(actual), assertions: [{ name, passed: true }] });
try {
let start = performance.now();
ok({ action: "create" }); ok({ action: "createBody", object: "Body" });
const sketch = { action: "setSketch", object: "Sketch", profileType: "Rectangle", parameters: { Width: 4, Height: 8 }, position: [0, 0, 0] };
ok(sketch);
if (type !== "Revolution") ok({ action: "setFeature", object: "Base", profile: "Sketch", featureType: rotary ? "Revolution" : "Pad", ...(rotary ? { angle: 360, axis: "Y_Axis" } : { length: 6 }) });
const originalEdges = !rotary ? ok({ action: "snapshot", trace: true }).objects.find(value => value.name === "Base").mappedEdges : null;
if (type === "Groove") ok({ ...sketch, object: "Tool", parameters: { Width: 1, Height: 2 }, position: [3, 2, 0] });
const input = rotary ? { action: "setFeature", object: "Result", featureType: type, profile: type === "Groove" ? "Tool" : "Sketch", angle: type === "Groove" ? 180 : 360, axis: "Y_Axis", reversed: false, includeMesh: true }
: { action: "setDressUp", object: "Result", dressUpType: type, base: "Base", size: 0.5, edges: ["Edge1"], includeMesh: true };
let state;
if (!rotary && loadBusinessWorker) {
const worker = loadBusinessWorker(kernel);
let requestNumber = 0, appliedSelection;
const router = { request: (_module, command, payload, documentId) => worker.send(worker.request(`desktop-${++requestNumber}`, command, payload, documentId)) };
const { base, edges, action, ...parameters } = input;
const selectedInput = { ...parameters, body: "Body", selection: [{ documentId: document, object: base, subelements: edges }] };
const execute = args => worker.desktop(router, `PartDesign_${type}`, args, document,
(documentId, selection) => { appliedSelection = { documentId, selection }; });
for (const change of [
{ selection: [{ documentId: "OtherDocument", object: base, subelements: edges }] },
{ base: "OtherBase" }, { edges: ["Edge3"] },
{ selection: [{ documentId: document, object: base, subelements: ["Face1"] }] },
]) {
const before = ok({ action: "snapshot" });
const rejected = await execute({ ...selectedInput, ...change });
assert.equal(rejected.kind, "error", JSON.stringify(rejected));
assert.deepEqual(ok({ action: "snapshot" }), before);
assert.equal(appliedSelection, undefined);
record({ ...selectedInput, ...change }, rejected, "desktop rejects conflicting or foreign edge selection without changing the native document");
}
const reply = await execute(selectedInput);
assert.equal(reply.kind, "result", JSON.stringify(reply));
state = reply.result;
assert.deepEqual(JSON.parse(JSON.stringify(appliedSelection)), { documentId: document, selection: [{ documentId: document, object: "Result" }] });
record(selectedInput, state, "desktop selected edges reach the real document Worker and create the native finishing feature");
} else state = ok(input);
const object = state.objects.find(value => value.name === "Result");
near(object.volume, volume(type)); assert.equal(object.type, `PartDesign::${type}`); assert.ok(object.mesh.triangleCount > 0);
assert.equal(state.objects.find(value => value.name === "Body").tip, "Result");
const edited = { ...input, ...(rotary ? { angle: type === "Groove" ? 90 : 180 } : { size: 0.8 }) };
state = ok(edited); near(state.objects.find(value => value.name === "Result").volume, volume(type, true));
near(ok({ action: "undo" }).objects.find(value => value.name === "Result").volume, volume(type));
near(ok({ action: "redo" }).objects.find(value => value.name === "Result").volume, volume(type, true));
record({ input, edited, actions: ["undo", "redo"] }, state, "native feature type, parameters, analytic volume and Body Tip survive parameter edits and transactions");
const invalid = rotary ? [{ angle: 361 }, { axis: "Z_Axis" }, { axis: "Missing" }, { profile: "Missing" }]
: [{ size: 0 }, { size: 100 }, { edges: [] }, { edges: ["Edge999"] }, { edges: ["Edge1", "Edge1"] }, { base: "Result" }];
for (const change of invalid) {
const before = ok({ action: "snapshot" }); const rejected = call({ ...edited, ...change }); assert.equal(rejected.ok, false, JSON.stringify(rejected));
assert.deepEqual(ok({ action: "snapshot" }), before);
record({ ...edited, ...change }, rejected, "invalid parameters or native operation failure leaves the document, source objects and transaction history unchanged");
}
if (!rotary) {
const moreEdges = ok({ ...edited, edges: ["Edge1", "Edge3"] });
assert.ok(moreEdges.objects.find(value => value.name === "Result").volume < volume(type, true));
assert.deepEqual(moreEdges.objects.find(value => value.name === "Result").edges, ["Edge1", "Edge3"]);
record({ ...edited, edges: ["Edge1", "Edge3"] }, moreEdges, "multiple selected native edges produce a different computed shape"); ok(edited);
}
const sourceEdit = rotary ? { ...sketch, parameters: { Width: 4, Height: 9 } } : { action: "setFeature", object: "Base", featureType: "Pad", profile: "Sketch", length: 8 };
state = ok({ ...sourceEdit, trace: true }); near(state.objects.find(value => value.name === "Result").volume, volume(type, true, true));
if (!rotary) {
const currentEdges = state.objects.find(value => value.name === "Base").mappedEdges;
assert.equal(originalEdges.length, 12);
assert.deepEqual(currentEdges, originalEdges);
record(sourceEdit, { originalEdges, currentEdges }, "first and repeated native topology encoding preserve every base edge identity without index rebinding");
}
record(sourceEdit, state, "editing the original sketch or base Pad recomputes the dependent finishing feature");
const saved = ok({ action: "save", path: filename }); assert.ok(saved.size > 0); ok({ action: "close" });
const restored = ok({ action: "open", path: filename });
assert.deepEqual(restored.objects.map(({ volume, ...object }) => object), saved.objects.map(({ volume, ...object }) => object));
for (const object of restored.objects) if (object.volume !== undefined)
near(object.volume, saved.objects.find(value => value.name === object.name).volume);
const modifiedAfterOpen = ok(input); near(modifiedAfterOpen.objects.find(value => value.name === "Result").volume, volume(type, false, true));
record({ path: filename, input, actions: ["save", "close", "open", "edit"] }, modifiedAfterOpen, "FCStd preserves the native feature, links and editable parameters after reopening");
result.timing.push({ stage: "shared-wasm-load", seconds: loadSeconds }, { stage: "native-workflow", 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.waitForFunction(() => document.querySelector(".design-app")?.getAttribute("data-busy") === "false");
const click = async locator => { await locator.click(); await ready(); assert.equal(await page.locator("[role=status]").getAttribute("data-error"), "false", await page.locator("[role=status]").innerText()); };
const select = name => page.locator(`[data-design-object="${name}"]`).click();
const fill = (name, value) => page.getByRole("spinbutton", { name, exact: true }).fill(String(value));
const actualVolume = () => page.locator("[data-design-volume]").innerText().then(Number);
await click(page.getByRole("button", { name: "新建 Body", exact: true })); await fill("Sketch Width", 4); await click(page.locator("[data-create-sketch]"));
if (rotary) await page.locator("[data-feature-type=Revolution]").click();
if (type !== "Revolution") await click(page.locator("[data-create-feature]"));
if (type === "Groove") {
await fill("Sketch Width", 1); await fill("Sketch Height", 2); await fill("Sketch X", 3); await fill("Sketch Y", 2);
await click(page.locator("[data-create-sketch]")); await page.locator("[data-feature-type=Groove]").click(); await fill("Feature Angle", 180);
}
if (rotary) await click(page.locator("[data-create-feature]"));
else { await page.locator(`[data-dressup-type=${type}]`).click(); await page.getByRole("checkbox", { name: "Edge1", exact: true }).check(); await click(page.locator("[data-create-dressup]")); }
near(await actualVolume(), volume(type));
await fill(rotary ? "Feature Angle" : "Dress-up Size", rotary ? (type === "Groove" ? 90 : 180) : 0.8);
await click(page.locator(rotary ? "[data-update-feature]" : "[data-update-dressup]")); near(await actualVolume(), volume(type, true));
const name = `${type}1`; await click(page.getByRole("button", { name: "撤销", exact: true })); await select(name); near(await actualVolume(), volume(type));
await click(page.getByRole("button", { name: "重做", exact: true })); await select(name); near(await actualVolume(), volume(type, true));
const viewports = await canvasCheck(page, type);
await click(page.getByRole("button", { name: "保存 FCStd 到 OPFS", exact: true })); const opfsFile = await page.getByRole("combobox", { name: "OPFS document", exact: true }).inputValue();
await page.reload({ waitUntil: "domcontentloaded" }); await page.getByRole("combobox", { name: "OPFS document", exact: true }).selectOption(opfsFile);
await click(page.getByRole("button", { name: "打开 OPFS 文档", exact: true })); await select(name); near(await actualVolume(), volume(type, true));
if (rotary) { await select("Sketch1"); await fill("Sketch Height", 9); await click(page.locator("[data-update-sketch]")); }
else { await select("Pad1"); await fill("Feature Length", 8); await click(page.locator("[data-update-feature]")); }
await select(name); const restoredVolume = await actualVolume(); near(restoredVolume, volume(type, true, true));
await click(page.getByRole("button", { name: "移除选中对象", exact: true })); assert.equal(await page.locator(`[data-design-object="${name}"]`).count(), 0);
assert.deepEqual(errors, []);
record({ route: "/partdesign.html", type, opfsFile }, { viewports, restoredVolume, errors }, "React edits current native features, supports undo/redo and OPFS reload, displays nonblank interactive geometry and removes the feature");
result.timing.push({ stage: "browser-workflow", seconds: (performance.now() - start) / 1000 }); result.status = "passed";
} catch (error) { result.error = String(error.stack ?? error); }
finally { call({ 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;
}