266 lines
13 KiB
JavaScript
266 lines
13 KiB
JavaScript
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`);
|