import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import factory from "../../web/app/src/vendor/blender/web_engine.js"; const root = new URL("../../", import.meta.url); const golden = JSON.parse(fs.readFileSync(new URL("tests/golden/M10-11/nla-evaluation.json", root), "utf8")); const fixture = fs.readFileSync(new URL(golden.fixture, root)); const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root)); assert.equal(crypto.createHash("sha256").update(fixture).digest("hex"), golden.fixtureSha256); assert.equal(golden.schemaVersion, 1); function open(engine, handle, bytes) { const pointer = engine._malloc(bytes.byteLength); try { engine.HEAPU8.set(bytes, pointer); assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); } finally { engine._free(pointer); } } function output(engine, handle, fn) { const dataOut = engine._malloc(4); const lengthOut = engine._malloc(4); try { assert.equal(fn(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); const pointer = engine.HEAPU32[dataOut >>> 2]; const length = engine.HEAPU32[lengthOut >>> 2]; assert.ok(pointer > 0 && length > 0); return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length))); } finally { engine._free(dataOut); engine._free(lengthOut); } } function setFrame(engine, handle, frame) { const command = new TextEncoder().encode(JSON.stringify({ type: "setFrame", frame })); const pointer = engine._malloc(command.byteLength); try { engine.HEAPU8.set(command, pointer); assert.equal(engine._web_engine_apply_command(handle, pointer, command.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); } finally { engine._free(pointer); } } function assertClose(expected, actual, label) { assert.equal(actual.length, expected.length, `${label} length`); const maximum = Math.max(0, ...expected.map((value, index) => Math.abs(actual[index] - value))); assert.ok(maximum <= golden.tolerance.maxMatrixError, `${label} max error ${maximum}`); } function assertNlaSnapshot(snapshot) { assert.equal(snapshot.nlaTracks?.length, golden.tracks.length, "NLA track count"); const track = snapshot.nlaTracks[0]; const expectedTrack = golden.tracks[0]; assert.equal(track.schemaVersion, 1); assert.equal(track.ownerId, `object:${golden.object}`); assert.equal(track.name, expectedTrack.name); assert.equal(track.muted, expectedTrack.muted); assert.equal(track.solo, expectedTrack.solo); assert.equal(track.strips.length, expectedTrack.strips.length); expectedTrack.strips.forEach((expected, index) => { const actual = track.strips[index]; assert.equal(actual.id, expected.id); assert.equal(actual.actionId, `action:${expected.action}:object:${golden.object}`); for (const field of ["frameStart", "frameEnd", "actionFrameStart", "actionFrameEnd", "scale", "repeat", "blendIn", "blendOut", "influence"]) { assert.equal(actual[field], expected[field], `${expected.id}.${field}`); } for (const field of ["blendMode", "extrapolation", "muted", "reverse"]) { assert.equal(actual[field], expected[field], `${expected.id}.${field}`); } assert.equal(actual.stripType, "CLIP"); assert.equal(actual.useTimeWarp, false); }); for (const expected of expectedTrack.strips) { const action = snapshot.animations.find((candidate) => candidate.id === `action:${expected.action}:object:${golden.object}`); assert.ok(action, `${expected.action} animation`); assert.equal(action.targetId, `object:${golden.object}`); assert.equal(action.frameStart, 1); assert.equal(action.frameEnd, 11); assert.ok(action.channels.some((channel) => channel.path.startsWith("location[")), `${expected.action} location channel`); } } const engine = await factory({ wasmBinary: wasmBinary.slice() }); const handle = engine._web_engine_create(); assert.ok(handle > 0); try { open(engine, handle, fixture); const initial = output(engine, handle, engine._web_engine_get_scene_snapshot); assertNlaSnapshot(initial); const initialNla = JSON.stringify({ nlaTracks: initial.nlaTracks, animations: initial.animations }); let maximumMatrixError = 0; for (const expected of golden.frames) { setFrame(engine, handle, expected.frame); const report = output(engine, handle, engine._web_engine_evaluate_depsgraph); assert.equal(report.engine, "BlenderDepsgraph"); assert.equal(report.status, "EVALUATED"); assert.equal(report.frame, expected.frame); const mesh = report.meshes.find((candidate) => candidate.objectId === `object:${golden.object}`); assert.ok(mesh, `evaluated ${golden.object} at frame ${expected.frame}`); const errors = mesh.worldMatrix.map((value, index) => Math.abs(value - expected.worldMatrix[index])); maximumMatrixError = Math.max(maximumMatrixError, ...errors); assertClose(expected.worldMatrix, mesh.worldMatrix, `frame ${expected.frame}`); const after = output(engine, handle, engine._web_engine_get_scene_snapshot); assert.equal(JSON.stringify({ nlaTracks: after.nlaTracks, animations: after.animations }), initialNla, `NLA read-only identity changed at frame ${expected.frame}`); } process.stdout.write(`nla-evaluation-ok tracks=${golden.tracks.length} strips=${golden.tracks[0].strips.length} frames=${golden.frames.length} max-matrix-error=${maximumMatrixError} read-only=passed\n`); } finally { engine._web_engine_destroy(handle); }