Files
workinf_Blender_Wasm/tools/web/check-authoring-roundtrip.mjs
2026-08-12 04:47:48 -04:00

425 lines
21 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 wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/basic_scene.blend", root));
const modifierFixture = fs.readFileSync(new URL("tests/files/web/rigged_shape_scene.blend", root));
function readOutput(engine, handle, fn, owned = false) {
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];
const bytes = engine.HEAPU8.slice(pointer, pointer + length);
if (owned) engine._web_engine_free_buffer(pointer);
return bytes;
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
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 command(engine, handle, payload) {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0,
`${payload.type}: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
finally {
engine._free(pointer);
}
}
function snapshot(engine, handle) {
return JSON.parse(new TextDecoder().decode(readOutput(engine, handle, engine._web_engine_get_scene_snapshot)));
}
function save(engine, handle) {
return readOutput(engine, handle, engine._web_engine_save_blend, true);
}
function geometryHeader(engine, handle) {
const bytes = readOutput(engine, handle, engine._web_engine_get_scene_geometry);
assert.equal(new TextDecoder().decode(bytes.subarray(0, 4)), "WBG1");
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
assert.ok([1, 2].includes(view.getUint32(4, true)));
assert.ok(view.getUint32(8, true) > 0);
}
function depsgraph(engine, handle) {
return JSON.parse(new TextDecoder().decode(readOutput(engine, handle, engine._web_engine_evaluate_depsgraph)));
}
async function isolatedMeshEdit(operation, selectionMode, elementIndices, extras = {}) {
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const before = snapshot(engine, handle);
const mesh = before.meshes[0];
command(engine, handle, { type: "meshEdit", meshId: mesh.id, operation, selectionMode, elementIndices, ...extras });
const after = snapshot(engine, handle);
assert.ok(after.revision > before.revision);
const saved = save(engine, handle);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
assert.ok(snapshot(engine, reopened).meshes.length > 0);
engine._web_engine_destroy(reopened);
}
for (const [operation, mode, indices, extras] of [
["MERGE", "VERT", [0, 1], {}],
["DISSOLVE", "EDGE", [0], {}],
["EXTRUDE", "FACE", [0], { offset: [0, 0, 0.25] }],
["INSET", "FACE", [0], { amount: 0.1 }],
["BEVEL", "EDGE", [0], { amount: 0.08, segments: 2 }],
["LOOP_CUT", "EDGE", [0], { segments: 1 }],
]) {
await isolatedMeshEdit(operation, mode, indices, extras);
}
{
const sculptEngine = await factory({ wasmBinary: wasmBinary.slice() });
const sculptHandle = sculptEngine._web_engine_create();
open(sculptEngine, sculptHandle, fixture);
const before = snapshot(sculptEngine, sculptHandle);
const sculptMesh = before.meshes[0];
const mask = Array.from(new Float32Array(Array.from({ length: sculptMesh.vertexCount }, (_, index) => index / Math.max(1, sculptMesh.vertexCount - 1))));
const faceSets = Array.from({ length: sculptMesh.faceCount }, (_, index) => index + 1);
command(sculptEngine, sculptHandle, { type: "setSculptMeshAttributes", attributes: { meshId: sculptMesh.id, mask, faceSets } });
let written = snapshot(sculptEngine, sculptHandle).meshes.find((candidate) => candidate.id === sculptMesh.id);
assert.deepEqual(written.sculptMask, mask);
assert.deepEqual(written.faceSets, faceSets);
const sculptObject = before.nodes.find((node) => node.type === "MESH" && node.dataId === sculptMesh.id);
const beforeStroke = depsgraph(sculptEngine, sculptHandle).meshes.find((candidate) => candidate.objectId === sculptObject.id);
assert.ok(beforeStroke);
const firstPosition = beforeStroke.positions.slice(0, 3);
const stroke = (brush, samples) => command(sculptEngine, sculptHandle, {
type: "sculptStroke",
stroke: {
schemaVersion: 1,
meshId: sculptMesh.id,
brush,
samples,
symmetry: [false, false, false],
mirrorObjectSpace: true,
},
});
const sample = (position, strength = 1) => ({
position,
normal: [0, 0, 1],
radius: 0.5,
strength,
pressure: 1,
time: 0,
});
stroke("DRAW", [sample(firstPosition)]);
const afterDraw = depsgraph(sculptEngine, sculptHandle).meshes.find((candidate) => candidate.objectId === sculptObject.id);
assert.ok(afterDraw.positions[2] > firstPosition[2], "Draw must move the unmasked vertex along the supplied normal");
stroke("INFLATE", [sample(firstPosition, 0.25)]);
stroke("GRAB", [sample(firstPosition), sample([firstPosition[0] + 0.05, firstPosition[1], firstPosition[2]])]);
stroke("SMOOTH", [sample(firstPosition, 0.1)]);
geometryHeader(sculptEngine, sculptHandle);
const sculptSaved = save(sculptEngine, sculptHandle);
sculptEngine._web_engine_destroy(sculptHandle);
const sculptReopened = sculptEngine._web_engine_create();
open(sculptEngine, sculptReopened, sculptSaved);
written = snapshot(sculptEngine, sculptReopened).meshes.find((candidate) => candidate.id === sculptMesh.id);
assert.deepEqual(written.sculptMask, mask);
assert.deepEqual(written.faceSets, faceSets);
const reopenedStroke = depsgraph(sculptEngine, sculptReopened).meshes.find((candidate) => candidate.objectId === sculptObject.id);
assert.ok(reopenedStroke.positions[2] > firstPosition[2], "Sculpt vertex motion must survive save and reopen");
geometryHeader(sculptEngine, sculptReopened);
sculptEngine._web_engine_destroy(sculptReopened);
}
{
const linkedEngine = await factory({ wasmBinary: wasmBinary.slice() });
const linkedHandle = linkedEngine._web_engine_create();
open(linkedEngine, linkedHandle, fixture);
const before = snapshot(linkedEngine, linkedHandle);
const source = before.nodes.find((node) => node.type === "MESH");
assert.ok(source?.dataId);
command(linkedEngine, linkedHandle, { type: "duplicateObject", objectId: source.id, offset: [0.5, 0.5, 0], linked: true });
const linked = snapshot(linkedEngine, linkedHandle).nodes.filter((node) => node.dataId === source.dataId);
assert.equal(linked.length, 2);
const linkedSaved = save(linkedEngine, linkedHandle);
linkedEngine._web_engine_destroy(linkedHandle);
const linkedReopened = linkedEngine._web_engine_create();
open(linkedEngine, linkedReopened, linkedSaved);
assert.equal(snapshot(linkedEngine, linkedReopened).nodes.filter((node) => node.dataId === source.dataId).length, 2);
linkedEngine._web_engine_destroy(linkedReopened);
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
geometryHeader(engine, handle);
let scene = snapshot(engine, handle);
const object = scene.nodes.find((node) => node.type === "MESH");
const mesh = scene.meshes.find((candidate) => candidate.id === object.dataId);
assert.ok(object && mesh);
command(engine, handle, { type: "createUVMap", meshId: mesh.id, name: "WebUV" });
command(engine, handle, { type: "unwrapUV", meshId: mesh.id, faceIndices: [0], method: "PLANAR" });
command(engine, handle, { type: "addMaterialSlot", objectId: object.id, name: "WebMaterial" });
scene = snapshot(engine, handle);
const material = scene.materials.find((candidate) => candidate.name.startsWith("WebMaterial"));
assert.ok(material);
command(engine, handle, { type: "setMaterialPrincipled", materialId: material.id, baseColor: [0.2, 0.4, 0.8, 1], roughness: 0.25, metallic: 0.75 });
command(engine, handle, { type: "assignMaterialFaces", meshId: mesh.id, faceIndices: [0], slotIndex: scene.meshes.find((candidate) => candidate.id === mesh.id).materialSlotIds.length - 1 });
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WlS8AAAAASUVORK5CYII=";
command(engine, handle, { type: "importImage", name: "WebPixel", mimeType: "image/png", width: 1, height: 1, base64: png });
scene = snapshot(engine, handle);
const image = scene.images.find((candidate) => candidate.name.startsWith("WebPixel"));
assert.ok(image?.packed);
command(engine, handle, { type: "setMaterialImageNode", materialId: material.id, imageId: image.id, usage: "BASE_COLOR", uvMap: "WebUV" });
command(engine, handle, {
type: "setShaderGraph",
materialId: material.id,
graph: {
schemaVersion: 1,
id: "shader:web-material",
materialId: material.id,
outputNodeId: "output",
nodes: [
{ id: "rgb", type: "RGB", name: "Web RGB", sockets: [
{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.1, 0.3, 0.7, 1] },
] },
{ id: "value", type: "VALUE", name: "Web Value", sockets: [
{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.42 },
] },
{ id: "addend", type: "VALUE", name: "Web Addend", sockets: [
{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.08 },
] },
{ id: "math", type: "MATH", name: "Web Add", properties: { operation: "ADD" }, sockets: [
{ id: "a", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
{ id: "b", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE" },
] },
{ id: "image", type: "IMAGE_TEXTURE", name: "Web Image", imageId: image.id, sockets: [
{ id: "vector", name: "Vector", direction: "INPUT", dataType: "VECTOR", defaultValue: [0, 0, 0] },
{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR" },
] },
{ id: "principled", type: "PRINCIPLED", name: "Web Principled", sockets: [
{ id: "base-color", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.2, 0.4, 0.8, 1] },
{ id: "roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.25 },
{ id: "bsdf", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" },
] },
{ id: "output", type: "MATERIAL_OUTPUT", name: "Web Output", sockets: [
{ id: "surface", name: "Surface", direction: "INPUT", dataType: "SHADER" },
] },
],
links: [
{ fromNodeId: "rgb", fromSocketId: "color", toNodeId: "principled", toSocketId: "base-color" },
{ fromNodeId: "value", fromSocketId: "value", toNodeId: "math", toSocketId: "a" },
{ fromNodeId: "addend", fromSocketId: "value", toNodeId: "math", toSocketId: "b" },
{ fromNodeId: "math", fromSocketId: "value", toNodeId: "principled", toSocketId: "roughness" },
{ fromNodeId: "principled", fromSocketId: "bsdf", toNodeId: "output", toSocketId: "surface" },
],
},
});
scene = snapshot(engine, handle);
let writtenShader = scene.materials.find((candidate) => candidate.id === material.id);
assert.deepEqual(new Set(writtenShader.nodes.map((node) => node.type)), new Set(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "PRINCIPLED", "OUTPUT"]));
assert.equal(writtenShader.links.length, 5);
const writtenRgb = writtenShader.nodes.find((node) => node.type === "RGB");
const writtenValue = writtenShader.nodes.find((node) => node.type === "VALUE");
assert.ok(writtenRgb?.defaultValue && Math.abs(writtenRgb.defaultValue[0] - 0.1) < 1e-6 && Math.abs(writtenRgb.defaultValue[1] - 0.3) < 1e-6);
assert.ok(writtenValue?.defaultValue && Math.abs(writtenValue.defaultValue[0] - 0.42) < 1e-6);
assert.equal(writtenShader.nodes.find((node) => node.type === "MATH")?.properties?.operation, "ADD");
command(engine, handle, { type: "insertObjectKeyframe", objectId: object.id, frame: 1, property: "LOCATION", interpolation: "LINEAR" });
command(engine, handle, { type: "setObjectTransform", objectId: object.id, translation: [1, 2, 3], rotationEuler: [0, 0, 0], scale: [1, 1, 1] });
command(engine, handle, { type: "insertObjectKeyframe", objectId: object.id, frame: 10, property: "LOCATION", interpolation: "LINEAR" });
scene = snapshot(engine, handle);
const animation = scene.animations.find((candidate) => candidate.targetId === object.id);
assert.ok(animation?.channels.some((channel) => channel.interpolation === "LINEAR"));
command(engine, handle, { type: "setNLAStack", objectId: object.id, tracks: [{
schemaVersion: 1,
id: "track:web-action",
ownerId: object.id,
name: "Web Action Track",
muted: false,
solo: false,
selected: true,
strips: [{
id: "strip:web-action",
actionId: animation.id,
frameStart: 1,
frameEnd: 19,
actionFrameStart: 1,
actionFrameEnd: 10,
scale: 1,
repeat: 2,
blendIn: 0,
blendOut: 0,
influence: 1,
blendMode: "REPLACE",
extrapolation: "HOLD",
muted: false,
selected: true,
reverse: true,
stripType: "CLIP",
}],
}] });
command(engine, handle, { type: "setActiveAction", objectId: object.id, actionId: null });
command(engine, handle, { type: "setFrame", frame: 5 });
scene = snapshot(engine, handle);
assert.equal(scene.nlaTracks.length, 1);
assert.equal(scene.nlaTracks[0].strips[0].actionId, animation.id);
assert.equal(scene.nlaTracks[0].strips[0].reverse, true);
const nlaEvaluation = JSON.parse(new TextDecoder().decode(readOutput(engine, handle, engine._web_engine_evaluate_depsgraph)));
const evaluatedNlaMesh = nlaEvaluation.meshes.find((candidate) => candidate.objectId === object.id);
assert.ok(evaluatedNlaMesh);
assert.ok(evaluatedNlaMesh.worldMatrix[3] > 0.5 && evaluatedNlaMesh.worldMatrix[3] < 0.7,
`unexpected NLA frame-5 matrix: ${JSON.stringify(evaluatedNlaMesh.worldMatrix)}`);
assert.ok(evaluatedNlaMesh.worldMatrix[7] > 1.0 && evaluatedNlaMesh.worldMatrix[7] < 1.3,
`unexpected NLA frame-5 matrix: ${JSON.stringify(evaluatedNlaMesh.worldMatrix)}`);
const firstRepeatMatrix = evaluatedNlaMesh.worldMatrix;
command(engine, handle, { type: "setFrame", frame: 14 });
const repeatedNlaEvaluation = JSON.parse(new TextDecoder().decode(readOutput(engine, handle, engine._web_engine_evaluate_depsgraph)));
const repeatedNlaMesh = repeatedNlaEvaluation.meshes.find((candidate) => candidate.objectId === object.id);
assert.ok(repeatedNlaMesh);
assert.ok(Math.abs(repeatedNlaMesh.worldMatrix[3] - firstRepeatMatrix[3]) < 1e-5,
`unexpected repeated NLA X matrix: ${JSON.stringify(repeatedNlaMesh.worldMatrix)}`);
assert.ok(Math.abs(repeatedNlaMesh.worldMatrix[7] - firstRepeatMatrix[7]) < 1e-5,
`unexpected repeated NLA Y matrix: ${JSON.stringify(repeatedNlaMesh.worldMatrix)}`);
command(engine, handle, { type: "createPrimitive", primitive: "CUBE", name: "HierarchyChild", location: [2, 0, 0] });
scene = snapshot(engine, handle);
const child = scene.nodes.find((node) => node.name.startsWith("HierarchyChild"));
assert.ok(child);
command(engine, handle, { type: "setParent", objectId: child.id, parentId: object.id, keepTransform: true });
command(engine, handle, { type: "createCollection", name: "WebCollection" });
scene = snapshot(engine, handle);
const collection = scene.collections.find((candidate) => candidate.name.startsWith("WebCollection"));
assert.ok(collection);
command(engine, handle, { type: "moveObjectToCollection", objectId: child.id, collectionId: collection.id });
command(engine, handle, { type: "setParent", objectId: child.id, parentId: null, keepTransform: true });
command(engine, handle, { type: "joinObjects", activeObjectId: object.id, objectIds: [object.id, child.id] });
scene = snapshot(engine, handle);
const joinedMesh = scene.meshes.find((candidate) => candidate.id === scene.nodes.find((node) => node.id === object.id)?.dataId);
assert.ok(joinedMesh?.faceCount > mesh.faceCount);
command(engine, handle, { type: "separateMeshFaces", objectId: object.id, faceIndices: [0], name: "WebSeparated" });
scene = snapshot(engine, handle);
assert.ok(scene.nodes.some((node) => node.name.startsWith("WebSeparated")));
command(engine, handle, { type: "applyObjectTransform", objectId: object.id });
command(engine, handle, { type: "setObjectOrigin", objectId: object.id, mode: "GEOMETRY" });
command(engine, handle, {
type: "setMaterialPrincipled",
materialId: material.id,
baseColor: [0.2, 0.4, 0.8, 0.9],
roughness: 0.21,
metallic: 0.62,
emissionColor: [0.05, 0.1, 0.2, 1],
alpha: 0.9,
ior: 1.52,
specularIORLevel: 0.35,
transmissionWeight: 0.27,
coatWeight: 0.64,
coatRoughness: 0.12,
emissionStrength: 3.5,
});
scene = snapshot(engine, handle);
const writtenPbr = scene.materials.find((candidate) => candidate.id === material.id);
assert.ok(writtenPbr);
for (const [field, expected] of Object.entries({
roughness: 0.21,
metallic: 0.62,
alpha: 0.9,
ior: 1.52,
specularIORLevel: 0.35,
transmissionWeight: 0.27,
coatWeight: 0.64,
coatRoughness: 0.12,
emissionStrength: 3.5,
})) {
assert.ok(Math.abs(writtenPbr[field] - expected) < 1e-5, `${field} did not round-trip through Main`);
}
const saved = save(engine, handle);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
scene = snapshot(engine, reopened);
assert.ok(scene.materials.some((candidate) => candidate.name.startsWith("WebMaterial")));
writtenShader = scene.materials.find((candidate) => candidate.name.startsWith("WebMaterial"));
assert.deepEqual(new Set(writtenShader.nodes.map((node) => node.type)), new Set(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "PRINCIPLED", "OUTPUT"]));
assert.equal(writtenShader.links.length, 5);
assert.ok(writtenShader.nodes.find((node) => node.type === "RGB")?.defaultValue?.[2] !== undefined);
assert.ok(writtenShader.nodes.find((node) => node.type === "VALUE")?.defaultValue?.[0] !== undefined);
assert.equal(writtenShader.nodes.find((node) => node.type === "MATH")?.properties?.operation, "ADD");
for (const [field, expected] of Object.entries({
roughness: 0.21,
metallic: 0.62,
alpha: 0.9,
ior: 1.52,
specularIORLevel: 0.35,
transmissionWeight: 0.27,
coatWeight: 0.64,
coatRoughness: 0.12,
emissionStrength: 3.5,
})) {
assert.ok(Math.abs(writtenShader[field] - expected) < 1e-5, `${field} did not survive save/reopen`);
}
assert.ok(scene.images.some((candidate) => candidate.name.startsWith("WebPixel") && candidate.packed));
assert.ok(scene.animations.some((candidate) => candidate.targetId === object.id));
assert.ok(scene.nlaTracks.some((track) => track.ownerId === object.id && track.name === "Web Action Track"));
assert.equal(scene.nlaTracks.find((track) => track.ownerId === object.id)?.strips[0].reverse, true);
assert.ok(scene.collections.some((candidate) => candidate.name.startsWith("WebCollection")));
geometryHeader(engine, reopened);
engine._web_engine_destroy(reopened);
const modifierHandle = engine._web_engine_create();
open(engine, modifierHandle, modifierFixture);
let modifierScene = snapshot(engine, modifierHandle);
const modifierMesh = modifierScene.meshes.find((candidate) => candidate.modifierStack?.length);
const modifier = modifierMesh?.modifierStack?.[0];
assert.ok(modifierMesh && modifier);
command(engine, modifierHandle, {
type: "setModifierVisibility",
meshId: modifierMesh.id,
modifierUuid: modifier.uuid,
showViewport: false,
showRender: false,
showEditMode: true,
showOnCage: false,
});
modifierScene = snapshot(engine, modifierHandle);
let updatedModifier = modifierScene.meshes.find((candidate) => candidate.id === modifierMesh.id)?.modifierStack?.[0];
assert.equal(updatedModifier?.showViewport, false);
assert.equal(updatedModifier?.showRender, false);
assert.equal(updatedModifier?.showEditMode, true);
const modifierSaved = save(engine, modifierHandle);
engine._web_engine_destroy(modifierHandle);
const modifierReopened = engine._web_engine_create();
open(engine, modifierReopened, modifierSaved);
modifierScene = snapshot(engine, modifierReopened);
updatedModifier = modifierScene.meshes.find((candidate) => candidate.id === modifierMesh.id)?.modifierStack?.[0];
assert.equal(updatedModifier?.showViewport, false);
assert.equal(updatedModifier?.showRender, false);
assert.equal(updatedModifier?.showEditMode, true);
engine._web_engine_destroy(modifierReopened);
process.stdout.write("authoring-roundtrip-ok mesh=6 linked-instances=ok sculpt-attributes=ok material-uv=ok pbr-physical-main=ok shader-main=ok animation=ok nla-main-evaluation=ok hierarchy=ok modifier-switches=ok binary-sceneir=ok\n");