Add Chromium-only Blender WebEngine parity work
This commit is contained in:
265
tools/web/check-modifier-goldens.mjs
Normal file
265
tools/web/check-modifier-goldens.mjs
Normal file
@@ -0,0 +1,265 @@
|
||||
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`);
|
||||
Reference in New Issue
Block a user