Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View 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`);