181 lines
16 KiB
JavaScript
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;
|
|
}
|