Advance M8-M11 parity workflows
This commit is contained in:
164
web/tests/e2e/geometry-node-evaluator-golden.spec.ts
Normal file
164
web/tests/e2e/geometry-node-evaluator-golden.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
interface GoldenMesh {
|
||||
attributes: Record<string, {
|
||||
domain: "POINT";
|
||||
dataType: "FLOAT";
|
||||
values: number[];
|
||||
}>;
|
||||
bounds: { min: number[]; max: number[] };
|
||||
indices: number[];
|
||||
positions: number[];
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
}
|
||||
|
||||
interface GoldenCase {
|
||||
graph: string;
|
||||
mesh: GoldenMesh;
|
||||
name: string;
|
||||
nodeTypes: string[];
|
||||
}
|
||||
|
||||
interface Golden {
|
||||
allowlist: string[];
|
||||
cases: GoldenCase[];
|
||||
fixture: string;
|
||||
fixtureSha256: string;
|
||||
nodeCoverage: Record<string, string[]>;
|
||||
schemaVersion: number;
|
||||
tolerance: {
|
||||
boundsError: number;
|
||||
maxAttributeError: number;
|
||||
maxPositionError: number;
|
||||
rmsPositionError: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EvaluatedMesh {
|
||||
attributes?: GoldenMesh["attributes"];
|
||||
indices: number[];
|
||||
modifiers: Array<{ status: string }>;
|
||||
objectId: string;
|
||||
positions: number[];
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
}
|
||||
|
||||
interface EvaluationResult {
|
||||
graphs: Array<{ name: string; nodes: Array<{ type: string }> }>;
|
||||
report: { engine: string; status: string; meshes: EvaluatedMesh[] };
|
||||
}
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(
|
||||
path.join(root, "tests/golden/M10-03/geometry-node-evaluator.json"),
|
||||
"utf8",
|
||||
)) as Golden;
|
||||
const blendBytes = fs.readFileSync(path.join(root, golden.fixture));
|
||||
|
||||
function errorMetrics(expected: number[], actual: number[]) {
|
||||
expect(actual).toHaveLength(expected.length);
|
||||
const errors = actual.map((value, index) => value - expected[index]);
|
||||
return {
|
||||
maximum: Math.max(0, ...errors.map((value) => Math.abs(value))),
|
||||
rms: errors.length === 0 ? 0 : Math.sqrt(
|
||||
errors.reduce((sum, value) => sum + value * value, 0) / errors.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function bounds(positions: number[]) {
|
||||
if (positions.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
const result = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
result.min[axis] = Math.min(result.min[axis], positions[index + axis]);
|
||||
result.max[axis] = Math.max(result.max[axis], positions[index + axis]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function verifyEvaluation(result: EvaluationResult) {
|
||||
expect(result.report.engine).toBe("BlenderDepsgraph");
|
||||
expect(result.report.status).toBe("EVALUATED");
|
||||
const graphs = new Map(result.graphs.map((graph) => [graph.name, graph]));
|
||||
const meshes = new Map(result.report.meshes.map((mesh) => [mesh.objectId, mesh]));
|
||||
|
||||
for (const expectedCase of golden.cases) {
|
||||
const graph = graphs.get(expectedCase.graph);
|
||||
expect(graph, `${expectedCase.name} graph`).toBeDefined();
|
||||
expect(graph?.nodes.map((node) => node.type).sort(), `${expectedCase.name} node inventory`)
|
||||
.toEqual([...expectedCase.nodeTypes].sort());
|
||||
|
||||
const actual = meshes.get(`object:${expectedCase.name}`);
|
||||
expect(actual, `${expectedCase.name} evaluated mesh`).toBeDefined();
|
||||
if (!actual) continue;
|
||||
expect(actual.vertexCount, `${expectedCase.name} vertex count`).toBe(expectedCase.mesh.vertexCount);
|
||||
expect(actual.triangleCount, `${expectedCase.name} triangle count`).toBe(expectedCase.mesh.triangleCount);
|
||||
expect(actual.indices, `${expectedCase.name} topology`).toEqual(expectedCase.mesh.indices);
|
||||
expect(actual.modifiers).toHaveLength(1);
|
||||
expect(actual.modifiers[0].status, `${expectedCase.name} modifier status`).toBe("EVALUATED");
|
||||
|
||||
const positionError = errorMetrics(expectedCase.mesh.positions, actual.positions);
|
||||
expect(positionError.maximum, `${expectedCase.name} maximum position error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.maxPositionError);
|
||||
expect(positionError.rms, `${expectedCase.name} RMS position error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.rmsPositionError);
|
||||
|
||||
const actualBounds = bounds(actual.positions);
|
||||
expect(errorMetrics(expectedCase.mesh.bounds.min, actualBounds.min).maximum,
|
||||
`${expectedCase.name} minimum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError);
|
||||
expect(errorMetrics(expectedCase.mesh.bounds.max, actualBounds.max).maximum,
|
||||
`${expectedCase.name} maximum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError);
|
||||
|
||||
expect(Object.keys(actual.attributes ?? {}).sort(), `${expectedCase.name} attribute inventory`)
|
||||
.toEqual(Object.keys(expectedCase.mesh.attributes).sort());
|
||||
for (const [name, expectedAttribute] of Object.entries(expectedCase.mesh.attributes)) {
|
||||
const actualAttribute = actual.attributes?.[name];
|
||||
expect(actualAttribute, `${expectedCase.name}/${name}`).toBeDefined();
|
||||
expect(actualAttribute?.domain).toBe(expectedAttribute.domain);
|
||||
expect(actualAttribute?.dataType).toBe(expectedAttribute.dataType);
|
||||
expect(errorMetrics(expectedAttribute.values, actualAttribute?.values ?? []).maximum,
|
||||
`${expectedCase.name}/${name} value error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.maxAttributeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("M10-03 matches every allowlisted Geometry Node against Blender 5.2 desktop goldens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(golden.schemaVersion).toBe(1);
|
||||
expect(crypto.createHash("sha256").update(blendBytes).digest("hex")).toBe(golden.fixtureSha256);
|
||||
expect(Object.keys(golden.nodeCoverage).sort()).toEqual([...golden.allowlist].sort());
|
||||
expect(golden.allowlist).toHaveLength(16);
|
||||
|
||||
await page.goto("/");
|
||||
const evaluations = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const firstReport = await first.evaluateDepsgraph();
|
||||
const saved = await first.saveBlend();
|
||||
const initial = { graphs: opened.snapshot.geometryNodeGraphs ?? [], report: firstReport.depsgraph };
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const secondReport = await second.evaluateDepsgraph();
|
||||
const restored = { graphs: reopened.snapshot.geometryNodeGraphs ?? [], report: secondReport.depsgraph };
|
||||
second.terminate();
|
||||
return [initial, restored];
|
||||
}, { bytes: new Uint8Array(blendBytes) }) as EvaluationResult[];
|
||||
|
||||
expect(evaluations).toHaveLength(2);
|
||||
verifyEvaluation(evaluations[0]);
|
||||
verifyEvaluation(evaluations[1]);
|
||||
});
|
||||
Reference in New Issue
Block a user