import assert from "node:assert/strict"; import fs from "node:fs"; import factory from "../../web/app/src/vendor/blender/web_engine.js"; const root = new URL("../../", import.meta.url); const cases = [ { fixture: "modifier_generate_scene.blend", outcome: "REPORT", compareMeshes: [ "GenerateArray", "GenerateBevel", "GenerateBoolean", "GenerateBooleanTarget", "GenerateMirror", "GenerateSolidify", "GenerateSubsurf", "GenerateTriangulate", "GenerateWeld", "GenerateWireframe", ], }, { fixture: "modifier_deform_scene.blend", outcome: "REPORT", compareMeshes: [ "DeformCast", "DeformHook", "DeformLattice", "DeformMeshDeform", "DeformShrinkwrap", "DeformSimple", "DeformSmooth", "DeformSurface", ], }, { fixture: "modifier_deform_curve_scene.blend", outcome: "REPORT", compareMeshes: ["DeformCurve"], }, { fixture: "modifier_physics_scene.blend", outcome: "REPORT", compareMeshes: ["PhysicsBuild", "PhysicsWave"], }, { fixture: "modifier_geometry_nodes_scene.blend", outcome: "REPORT", compareMeshes: ["GeometryNodesObject", "GeometryNodesSetPosition"], blockedModifiers: [{ name: "Geometry Nodes Simulation", errorCode: "GEOMETRY_NODES_SIMULATION_UNAVAILABLE" }], preservedMeshes: [["GeometryNodesSimulation", "GeometryNodesSimulationBaseline"]], }, { fixture: "modifier_grease_pencil_scene.blend", outcome: "REPORT" }, { fixture: "modifier_extended_native.blend", goldenTask: "N-002", outcome: "REPORT", compareMeshes: ["ExtendedEdgeSplit", "ExtendedScrew", "ExtendedDisplace"], blockedModifiers: [{ name: "Blocked RGB Displace", errorCode: "MODIFIER_CONFIGURATION_UNSUPPORTED" }], preservedMeshes: [["ExtendedDisplaceBlocked", "ExtendedDisplaceBlockedBaseline"]], }, ]; const evaluatedTypeCodes = new Set([1, 2, 3, 4, 5, 7, 9, 11, 12, 13, 14, 16, 17, 18, 24, 25, 28, 33, 34, 44, 48, 53, 55, 57]); const nativeBlockedTypeCodes = new Set(); const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root)); const subdivisionGolden = JSON.parse(fs.readFileSync(new URL("tests/golden/W-075/modifier_generate_subdivision_opensubdiv.json", root), "utf8")); const repeat = Math.max(1, Number.parseInt(process.env.MODIFIER_GOLDEN_REPEAT ?? "1", 10) || 1); function evaluateFixture(engine, bytes) { const handle = engine._web_engine_create(); assert.ok(handle > 0, "WebEngine handle creation failed"); try { const input = engine._malloc(bytes.byteLength); try { engine.HEAPU8.set(bytes, input); assert.equal(engine._web_engine_open_blend(handle, input, bytes.byteLength), 0, "blend open failed"); } finally { engine._free(input); } const snapshotDataOut = engine._malloc(4); const snapshotLengthOut = engine._malloc(4); let snapshot; try { assert.equal(engine._web_engine_get_scene_snapshot(handle, snapshotDataOut, snapshotLengthOut), 0); const pointer = engine.HEAPU32[snapshotDataOut >>> 2]; const length = engine.HEAPU32[snapshotLengthOut >>> 2]; snapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length))); } finally { engine._free(snapshotDataOut); engine._free(snapshotLengthOut); } const dataOut = engine._malloc(4); const lengthOut = engine._malloc(4); try { const result = engine._web_engine_evaluate_depsgraph(handle, dataOut, lengthOut); if (result !== 0) { return { result, message: engine.UTF8ToString(engine._web_engine_last_error_message()) }; } const pointer = engine.HEAPU32[dataOut >>> 2]; const length = engine.HEAPU32[lengthOut >>> 2]; return { result, snapshot, report: JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length))) }; } finally { engine._free(dataOut); engine._free(lengthOut); } } finally { engine._web_engine_destroy(handle); } } function compareModifiers(golden, report, blockedModifiers = []) { const reportedObjects = new Map(report.objects.map((object) => [object.objectId, object])); for (const expectedObject of golden.objects) { const expectedModifiers = expectedObject.modifiers ?? []; const actualObject = reportedObjects.get(`object:${expectedObject.name}`); assert.ok(actualObject, `missing native object ${expectedObject.name}`); assert.equal(actualObject.modifierCount, expectedModifiers.length, `${expectedObject.name} modifier count`); for (let index = 0; index < expectedModifiers.length; index++) { const expected = expectedModifiers[index]; const actual = actualObject.modifiers[index]; assert.equal(actual.name, expected.name, `${expectedObject.name} modifier name`); assert.equal(actual.typeCode, expected.typeCode, `${expectedObject.name}/${expected.name} type code`); assert.equal(actual.showViewport, expected.showViewport, `${expectedObject.name}/${expected.name} viewport mode`); assert.equal(actual.showRender, expected.showRender, `${expectedObject.name}/${expected.name} render mode`); assert.equal(actual.showEditmode, expected.showEditmode, `${expectedObject.name}/${expected.name} edit mode`); assert.equal(actual.showOnCage, expected.showOnCage, `${expectedObject.name}/${expected.name} cage mode`); if (!expected.showViewport) { assert.equal(actual.status, "DISABLED", `${expectedObject.name}/${expected.name} must stay disabled`); } else if (blockedModifiers.some((blocked) => blocked.name === expected.name)) { const blocked = blockedModifiers.find((item) => item.name === expected.name); assert.equal(actual.status, "BLOCKED", `${expectedObject.name}/${expected.name} must block`); assert.equal(actual.errorCode, blocked.errorCode); assert.ok(actual.error?.length > 0, `${expectedObject.name}/${expected.name} error missing`); assert.ok(actual.suggestion?.length > 0, `${expectedObject.name}/${expected.name} suggestion missing`); } else if (evaluatedTypeCodes.has(expected.typeCode)) { assert.equal(actual.status, "EVALUATED", `${expectedObject.name}/${expected.name} must evaluate`); } else if (nativeBlockedTypeCodes.has(expected.typeCode)) { assert.equal(actual.status, "BLOCKED", `${expectedObject.name}/${expected.name} must block`); assert.equal(actual.errorCode, "BLENDER_MODIFIER_ERROR"); assert.ok(actual.error?.length > 0, `${expectedObject.name}/${expected.name} error missing`); assert.ok(actual.suggestion?.length > 0, `${expectedObject.name}/${expected.name} suggestion missing`); } else { assert.equal(actual.status, "BLOCKED", `${expectedObject.name}/${expected.name} must block`); assert.equal(actual.errorCode, "UNSUPPORTED_MODIFIER_TYPE"); assert.ok(actual.suggestion?.length > 0, `${expectedObject.name}/${expected.name} suggestion missing`); } } } } function comparePreservedMeshes(report, objectName, baselineName) { const mesh = report.meshes.find((item) => item.objectId === `object:${objectName}`); const baseline = report.meshes.find((item) => item.objectId === `object:${baselineName}`); assert.ok(mesh && baseline, `preserved mesh comparison missing for ${objectName}`); assert.equal(mesh.vertexCount, baseline.vertexCount, `${objectName} preserved vertex count`); assert.equal(mesh.triangleCount, baseline.triangleCount, `${objectName} preserved triangle count`); assert.deepEqual(mesh.positions, baseline.positions, `${objectName} must preserve input positions`); assert.deepEqual(mesh.indices, baseline.indices, `${objectName} must preserve input topology`); } function compareMesh(golden, report, objectName) { const expected = objectName === subdivisionGolden.object ? subdivisionGolden : golden.meshes.find((mesh) => mesh.object === objectName); const actual = report.meshes.find((mesh) => mesh.objectId === `object:${objectName}`); assert.ok(expected && actual, `evaluated mesh missing for ${objectName}`); assert.equal(actual.vertexCount, expected.vertexCount, `${objectName} vertex count`); assert.equal(actual.triangleCount, expected.triangleCount, `${objectName} triangle count`); if (objectName === "GenerateBoolean") { compareCoplanarSurface(expected, actual, objectName); } else { assert.deepEqual(actual.indices, expected.indices, `${objectName} triangle topology`); } const errors = actual.positions.map((value, index) => value - expected.positions[index]); const maxError = Math.max(0, ...errors.map((value) => Math.abs(value))); const rmsError = errors.length === 0 ? 0 : Math.sqrt(errors.reduce((sum, value) => sum + value * value, 0) / errors.length); assert.ok(maxError <= golden.tolerance.maxPositionError, `${objectName} max error ${maxError}`); assert.ok(rmsError <= golden.tolerance.rmsPositionError, `${objectName} RMS error ${rmsError}`); } function coplanarAreaGroups(mesh) { const groups = new Map(); const point = (index) => mesh.positions.slice(index * 3, index * 3 + 3); for (let offset = 0; offset < mesh.indices.length; offset += 3) { const a = point(mesh.indices[offset]); const b = point(mesh.indices[offset + 1]); const c = point(mesh.indices[offset + 2]); const ab = b.map((value, axis) => value - a[axis]); const ac = c.map((value, axis) => value - a[axis]); const cross = [ ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0], ]; const length = Math.hypot(...cross); assert.ok(length > 1e-12, "Boolean golden contains a degenerate triangle"); const normal = cross.map((value) => value / length); const distance = normal.reduce((sum, value, axis) => sum + value * a[axis], 0); const key = [...normal, distance].map((value) => value.toFixed(5)).join(","); groups.set(key, (groups.get(key) ?? 0) + length * 0.5); } return groups; } function compareCoplanarSurface(expected, actual, objectName) { const expectedGroups = coplanarAreaGroups(expected); const actualGroups = coplanarAreaGroups(actual); assert.deepEqual([...actualGroups.keys()].sort(), [...expectedGroups.keys()].sort(), `${objectName} boundary planes`); for (const [plane, expectedArea] of expectedGroups) { const error = Math.abs(actualGroups.get(plane) - expectedArea); assert.ok(error <= 1e-5, `${objectName} coplanar area error ${error} on ${plane}`); } } for (let iteration = 0; iteration < repeat; iteration++) { for (const testCase of cases) { const bytes = fs.readFileSync(new URL(`tests/files/web/${testCase.fixture}`, root)); const goldenTask = testCase.goldenTask ?? "W-075"; const golden = JSON.parse(fs.readFileSync(new URL(`tests/golden/${goldenTask}/${testCase.fixture.replace(".blend", ".json")}`, root), "utf8")); assert.equal(golden.blenderVersion, "5.2.0 LTS"); const engine = await factory({ wasmBinary: wasmBinary.slice() }); const evaluated = evaluateFixture(engine, bytes); if (testCase.outcome === "BLOCKED") { assert.equal(evaluated.result, -3, `${testCase.fixture} must be capability-blocked`); assert.match(evaluated.message, new RegExp(testCase.message)); continue; } assert.equal(evaluated.result, 0, `${testCase.fixture} evaluation failed: ${evaluated.message ?? ""}`); assert.equal(evaluated.report.engine, "BlenderDepsgraph"); assert.equal(evaluated.report.status, "EVALUATED"); assert.equal(evaluated.report.frame, golden.frame); compareModifiers(golden, evaluated.report, testCase.blockedModifiers); for (const objectName of testCase.compareMeshes ?? []) compareMesh(golden, evaluated.report, objectName); for (const [objectName, baselineName] of testCase.preservedMeshes ?? []) { comparePreservedMeshes(evaluated.report, objectName, baselineName); } if (testCase.fixture === "modifier_grease_pencil_scene.blend") { const greasePencil = evaluated.report.objects.find((object) => object.objectId === "object:GreasePencilObject"); assert.equal(golden.greasePencils[0].layers[0].frames[0].pointCount, 4); assert.equal(greasePencil.type, "GREASE_PENCIL"); assert.equal(greasePencil.modifierCount, 26); } if (testCase.fixture === "modifier_extended_native.blend") { const displace = evaluated.snapshot.meshes .flatMap((mesh) => mesh.modifierStack ?? []) .find((modifier) => modifier.name === "Native Constant Displace"); assert.equal(displace?.parameters?.direction, 2); assert.equal(displace?.parameters?.space, 0); assert.ok(Math.abs(displace?.parameters?.strength - 0.75) <= 1e-6); assert.ok(Math.abs(displace?.parameters?.midLevel - 0.25) <= 1e-6); } } } process.stdout.write(`modifier-goldens-ok repeat=${repeat} fixtures=${cases.map((item) => item.fixture).join(",")}\n`);