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