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");