165 lines
6.6 KiB
TypeScript
165 lines
6.6 KiB
TypeScript
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]);
|
|
});
|