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; 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; 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]); });