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,144 @@
import json
import math
import os
import sys
import bpy
def rounded(values):
return [round(float(value), 6) for value in values]
def socket_value(node, name, fallback=None):
if node is None or name not in node.inputs:
return fallback
value = node.inputs[name].default_value
return round(float(value), 6) if not hasattr(value, "__len__") else rounded(value)
def main(glb_path, output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
result = bpy.ops.import_scene.gltf(filepath=os.path.abspath(glb_path))
if "FINISHED" not in result:
raise RuntimeError(f"Blender glTF importer failed: {result}")
scene = bpy.context.scene
scene.render.fps = 24
mesh_object = bpy.data.objects.get("RoundTripMesh")
if mesh_object is None or mesh_object.type != "MESH":
raise RuntimeError("RoundTripMesh was not imported")
mesh = mesh_object.data
mesh.calc_loop_triangles()
shape_keys = {}
if mesh.shape_keys is not None:
basis = mesh.shape_keys.key_blocks[0]
for key in mesh.shape_keys.key_blocks[1:]:
shape_keys[key.name] = [
round((key.data[index].co - basis.data[index].co).length, 6)
for index in range(len(key.data))
]
material = mesh.materials[0] if mesh.materials else None
principled = None
texture = None
if material and material.node_tree:
principled = next((node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED"), None)
texture = next(
(node for candidate in bpy.data.materials if candidate.node_tree
for node in candidate.node_tree.nodes if node.type == "TEX_IMAGE"),
None,
)
def animated_location(frame):
scene.frame_set(frame)
return rounded(mesh_object.matrix_world.translation)
armature = next((obj for obj in bpy.data.objects if obj.type == "ARMATURE"), None)
skinned = bpy.data.objects.get("SkinnedMesh")
weight_sums = []
if skinned is not None and skinned.type == "MESH":
for vertex in skinned.data.vertices:
weight_sums.append(round(sum(group.weight for group in vertex.groups), 6))
image = texture.image if texture is not None else None
report = {
"mesh": {
"vertexCount": len(mesh.vertices),
"triangleCount": len(mesh.loop_triangles),
"positions": [rounded(vertex.co) for vertex in mesh.vertices],
"frame1WorldTranslation": animated_location(1),
"frame25WorldTranslation": animated_location(25),
"shapeKeys": shape_keys,
},
"material": {
"name": material.name if material else None,
"baseColor": rounded(material.diffuse_color) if material else [],
"metallic": round(float(material.metallic), 6) if material else None,
"roughness": round(float(material.roughness), 6) if material else None,
"blendMethod": material.surface_render_method if material else None,
"texture": {
"present": texture is not None,
"interpolation": texture.interpolation if texture else None,
"extension": texture.extension if texture else None,
"imageWidth": image.size[0] if image else 0,
"imageHeight": image.size[1] if image else 0,
"packed": bool(image and image.packed_file),
"colorSpace": image.colorspace_settings.name if image else None,
},
"principled": {
"baseColor": rounded(principled.inputs["Base Color"].default_value) if principled else [],
"alpha": round(float(principled.inputs["Alpha"].default_value), 6) if principled else None,
"metallic": round(float(principled.inputs["Metallic"].default_value), 6) if principled else None,
"roughness": round(float(principled.inputs["Roughness"].default_value), 6) if principled else None,
"ior": socket_value(principled, "IOR"),
"specularIORLevel": socket_value(principled, "Specular IOR Level"),
"transmissionWeight": socket_value(principled, "Transmission Weight"),
"coatWeight": socket_value(principled, "Coat Weight"),
"coatRoughness": socket_value(principled, "Coat Roughness"),
"emissionStrength": socket_value(principled, "Emission Strength"),
},
"nodes": [
{
"type": node.type,
"name": node.name,
"inputs": {
socket.name: rounded(socket.default_value) if hasattr(socket.default_value, "__len__") else round(float(socket.default_value), 6)
for socket in node.inputs
if hasattr(socket, "default_value")
},
}
for node in material.node_tree.nodes
] if material and material.node_tree else [],
},
"animation": {
"actionCount": len(bpy.data.actions),
"frameRange": rounded([scene.frame_start, scene.frame_end]),
},
"skin": {
"armatureCount": sum(1 for obj in bpy.data.objects if obj.type == "ARMATURE"),
"bones": sorted(bone.name for bone in armature.data.bones) if armature else [],
"boneParents": {
bone.name: bone.parent.name if bone.parent else None
for bone in armature.data.bones
} if armature else {},
"modifierCount": len(skinned.modifiers) if skinned else 0,
"vertexGroupCount": len(skinned.vertex_groups) if skinned else 0,
"weightSums": weight_sums,
},
}
with open(output_path, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2, sort_keys=True)
handle.write("\n")
print(
"blender-glb-import-ok "
f"vertices={report['mesh']['vertexCount']} bones={len(report['skin']['bones'])} "
f"actions={report['animation']['actionCount']}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python blender-check-glb-roundtrip.py -- input.glb output.json")
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,62 @@
import json
import os
import sys
import bpy
EXPECTED_OBJECTS = {
"WebCurveObject",
"WebSurfaceObject",
"WebFontObject",
"WebMetaballObject",
"WebPointCloudObject",
"WebCurvesObject",
"WebHairObject",
}
def rounded(values):
return [round(float(value), 7) for value in values]
def main(glb_path, output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
result = bpy.ops.import_scene.gltf(filepath=os.path.abspath(glb_path))
if "FINISHED" not in result:
raise RuntimeError(f"Blender glTF importer failed: {result}")
report = {}
for name in sorted(EXPECTED_OBJECTS):
obj = bpy.data.objects.get(name)
if obj is None:
raise RuntimeError(f"{name} was not imported")
if obj.type != "MESH":
raise RuntimeError(f"{name} was not imported as a mesh")
mesh = obj.data
mesh.calc_loop_triangles()
report[name] = {
"vertexCount": len(mesh.vertices),
"edgeCount": len(mesh.edges),
"triangleCount": len(mesh.loop_triangles),
"boundsMin": rounded([
min((vertex.co[axis] for vertex in mesh.vertices), default=0.0)
for axis in range(3)
]),
"boundsMax": rounded([
max((vertex.co[axis] for vertex in mesh.vertices), default=0.0)
for axis in range(3)
]),
}
with open(output_path, "w", encoding="ascii") as output:
json.dump(report, output, indent=2, sort_keys=True)
output.write("\n")
print(f"blender-nonmesh-glb-import-ok objects={len(report)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python blender-check-nonmesh-glb-roundtrip.py -- input.glb output.json")
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,81 @@
import json
import os
import sys
import bpy
EXPECTED_OBJECTS = {
"WebCurveObject",
"WebSurfaceObject",
"WebFontObject",
"WebMetaballObject",
"WebPointCloudObject",
"WebCurvesObject",
"WebHairObject",
}
def rounded(values):
return [round(float(value), 7) for value in values]
def main(usd_path, output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
result = bpy.ops.wm.usd_import(filepath=os.path.abspath(usd_path))
if "FINISHED" not in result:
raise RuntimeError(f"Blender USD importer failed: {result}")
report = {}
for name in sorted(EXPECTED_OBJECTS):
obj = bpy.data.objects.get(name)
if obj is None:
raise RuntimeError(f"{name} was not imported")
if obj.type == "MESH":
mesh = obj.data
mesh.calc_loop_triangles()
positions = [vertex.co[:] for vertex in mesh.vertices]
report[name] = {
"type": obj.type,
"vertexCount": len(mesh.vertices),
"edgeCount": len(mesh.edges),
"triangleCount": len(mesh.loop_triangles),
"boundsMin": rounded([min((point[axis] for point in positions), default=0.0) for axis in range(3)]),
"boundsMax": rounded([max((point[axis] for point in positions), default=0.0) for axis in range(3)]),
}
elif obj.type == "CURVES":
curves = obj.data
positions = [point.position[:] for point in curves.points]
report[name] = {
"type": obj.type,
"vertexCount": len(curves.points),
"edgeCount": sum(max(0, len(curve.points) - 1) for curve in curves.curves),
"triangleCount": 0,
"boundsMin": rounded([min((point[axis] for point in positions), default=0.0) for axis in range(3)]),
"boundsMax": rounded([max((point[axis] for point in positions), default=0.0) for axis in range(3)]),
}
elif obj.type == "POINTCLOUD":
points = obj.data
positions = [point.co[:] for point in points.points]
report[name] = {
"type": obj.type,
"vertexCount": len(points.points),
"edgeCount": 0,
"triangleCount": 0,
"boundsMin": rounded([min((point[axis] for point in positions), default=0.0) for axis in range(3)]),
"boundsMax": rounded([max((point[axis] for point in positions), default=0.0) for axis in range(3)]),
}
else:
raise RuntimeError(f"{name} has unsupported imported type {obj.type}")
with open(output_path, "w", encoding="ascii") as output:
json.dump(report, output, indent=2, sort_keys=True)
output.write("\n")
print(f"blender-nonmesh-usd-import-ok objects={len(report)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python blender-check-nonmesh-usd-roundtrip.py -- input.usda output.json")
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${repo_root}/tools/web/emscripten-env.sh"
configure_variant() {
local name="$1"
local threads="$2"
local build_dir="${repo_root}/build_web-${name}"
emcmake cmake -S "${repo_root}/web/engine" -B "${build_dir}" -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DWEB_ENGINE_THREADS="${threads}"
cmake --build "${build_dir}" --target web_engine
test -s "${build_dir}/web_engine.js"
test -s "${build_dir}/web_engine.wasm"
printf '%s variant ok: %s\n' "${name}" "${build_dir}"
}
configure_variant single OFF
configure_variant pthread ON

View File

@@ -0,0 +1,424 @@
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");

26
tools/web/check-baseline.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${workspace_root}"
required_paths=(
"blender-5.2.0/CMakeLists.txt"
"build_blender_5.2.0/bin/blender"
"docs/decisions/000-baseline.md"
"tests/files/web/empty.blend"
"tests/files/web/basic_scene.blend"
"tests/files/web/manifest.json"
)
for path in "${required_paths[@]}"; do
test -e "${path}" || { printf 'missing: %s\n' "${path}" >&2; exit 1; }
done
grep -q 'Blender 5.2.0' <(build_blender_5.2.0/bin/blender --version)
command -v cmake >/dev/null
command -v ninja >/dev/null
command -v emcc >/dev/null
command -v node >/dev/null
printf 'baseline-ok\n'

View File

@@ -0,0 +1,19 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const probe = path.join(root, "tools/web/probe-collapse-ratio.mjs");
const ratios = [1, 0.9, 0.8, 0.75, 0.7, 0.65, 0.5, 0.25];
const results = [];
for (const ratio of ratios) {
const output = execFileSync(process.execPath, [probe, String(ratio)], { cwd: root, encoding: "utf8" }).trim();
const result = JSON.parse(output.split("\n").at(-1) ?? "{}");
if (result.result !== 0 || !result.output || result.output.triangleCount <= 0) {
throw new Error(`Collapse ratio ${ratio} failed: ${output}`);
}
results.push({ ratio, triangles: result.output.triangleCount, vertices: result.output.vertexCount });
}
console.log(`collapse-ratios-ok ${JSON.stringify(results)}`);

View File

@@ -0,0 +1,81 @@
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 repeat = Math.max(1, Number.parseInt(process.env.DEPSGRAPH_REPEAT ?? "100", 10) || 100);
const fixtures = ["empty.blend", "basic_scene.blend", "rigged_shape_scene.blend"];
const deformationGolden = JSON.parse(fs.readFileSync(new URL("tests/golden/W-079/blender-deformation.json", root), "utf8"));
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 dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_evaluate_depsgraph(handle, dataOut, lengthOut), 0, "depsgraph evaluation failed");
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
assert.ok(pointer && length, "depsgraph returned an empty report");
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
finally {
engine._web_engine_destroy(handle);
}
}
for (let iteration = 0; iteration < repeat; iteration++) {
for (const fixture of fixtures) {
const bytes = fs.readFileSync(new URL(`tests/files/web/${fixture}`, root));
const engine = await factory({
wasmBinary: fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root)),
});
const report = evaluateFixture(engine, bytes);
assert.equal(report.engine, "BlenderDepsgraph");
assert.equal(report.status, "EVALUATED");
assert.ok(report.objectCount >= report.meshObjectCount);
for (const mesh of report.meshes) {
assert.equal(mesh.positions.length, mesh.vertexCount * 3);
assert.equal(mesh.indices.length, mesh.triangleCount * 3);
assert.ok(mesh.indices.every((index) => index >= 0 && index < mesh.vertexCount));
assert.equal(mesh.modifiers.length, mesh.modifierCount);
assert.deepEqual(mesh.modifiers.map((modifier) => modifier.index), mesh.modifiers.map((_, index) => index));
assert.ok(mesh.modifiers.every((modifier) => modifier.uuid.startsWith(`modifier:${mesh.objectId}:`)));
}
if (fixture === "rigged_shape_scene.blend") {
const mesh = report.meshes.find((candidate) => candidate.sourceMeshId === `mesh:${deformationGolden.mesh}`);
assert.ok(mesh, "rigged mesh missing from depsgraph report");
assert.deepEqual(mesh.modifiers.map((modifier) => modifier.type), ["Armature", "Decimate"]);
assert.deepEqual(mesh.modifiers.map((modifier) => modifier.status), ["EVALUATED", "BLOCKED"]);
assert.equal(mesh.modifiers[1].showViewport, true);
assert.match(mesh.modifiers[1].error, /more than 3 input faces/);
assert.equal(mesh.modifiers[1].errorCode, "BLENDER_MODIFIER_ERROR");
assert.match(mesh.modifiers[1].suggestion, /disable/);
assert.deepEqual(mesh.modifiers[0].targetObjectIds, ["object:RiggedArmatureObject"]);
assert.ok(mesh.modifiers[0].dependsOn.includes("object:RiggedArmatureObject"));
assert.deepEqual(mesh.modifiers[1].dependsOn, [mesh.modifiers[0].uuid]);
const errors = mesh.positions.map((value, index) => value - deformationGolden.positions[index]);
const maxError = Math.max(...errors.map((value) => Math.abs(value)));
const rmsError = Math.sqrt(errors.reduce((sum, value) => sum + value * value, 0) / errors.length);
assert.ok(maxError <= deformationGolden.tolerance.maxPositionError, `max error ${maxError}`);
assert.ok(rmsError <= deformationGolden.tolerance.rmsPositionError, `RMS error ${rmsError}`);
}
}
}
process.stdout.write(`depsgraph-ok repeat=${repeat} fixtures=${fixtures.join(",")}\n`);

18
tools/web/check-dual-engine.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
native_dir="${repo_root}/build_web/native"
mkdir -p "${native_dir}"
c++ -std=c++17 -O2 \
-I"${repo_root}/blender-5.2.0/source/blender/web_engine" \
-I"${repo_root}/blender-5.2.0/extern/json/include" \
"${repo_root}/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp" \
"${repo_root}/web/engine/web_engine_native_reader_stub.cpp" \
"${repo_root}/web/engine/web_engine_native_smoke.cpp" \
-o "${native_dir}/web_engine_native_smoke"
"${native_dir}/web_engine_native_smoke"
node "${repo_root}/tools/web/run-web-engine-smoke.mjs"
printf 'dual-engine-ok\n'

26
tools/web/check-emscripten.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${repo_root}/tools/web/emscripten-env.sh"
build_dir="${repo_root}/build_web/toolchain-smoke"
mkdir -p "${build_dir}"
emcc "${repo_root}/web/engine/emscripten_smoke.c" \
-O2 \
${WEB_EMSCRIPTEN_CFLAGS} \
${WEB_EMSCRIPTEN_LDFLAGS} \
-o "${build_dir}/smoke.js"
emcc "${repo_root}/web/engine/emscripten_smoke.c" \
-O2 -pthread \
${WEB_EMSCRIPTEN_CFLAGS} \
${WEB_EMSCRIPTEN_LDFLAGS} \
-sPTHREAD_POOL_SIZE=1 \
-o "${build_dir}/smoke-pthread.js"
test -s "${build_dir}/smoke.wasm"
test -s "${build_dir}/smoke-pthread.wasm"
printf 'emscripten-toolchain-ok version=%s\n' "${WEB_EMSCRIPTEN_VERSION}"
sha256sum "${build_dir}/smoke.wasm" "${build_dir}/smoke-pthread.wasm"

View File

@@ -0,0 +1,80 @@
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 golden = JSON.parse(fs.readFileSync(new URL("tests/golden/W-080/animation-depsgraph.json", root), "utf8"));
const blend = fs.readFileSync(new URL(`tests/files/web/${golden.fixture}`, root));
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const tolerance = golden.tolerance;
function readJson(engine, dataOut, lengthOut) {
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
assert.ok(pointer && length, "native response is empty");
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
function sendCommand(engine, handle, command) {
const encoded = new TextEncoder().encode(JSON.stringify(command));
const pointer = engine._malloc(encoded.byteLength);
try {
engine.HEAPU8.set(encoded, pointer);
assert.equal(engine._web_engine_apply_command(handle, pointer, encoded.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(pointer);
}
}
function evaluate(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_evaluate_depsgraph(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
return readJson(engine, dataOut, lengthOut);
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
assert.ok(handle > 0, "WebEngine handle creation failed");
try {
const input = engine._malloc(blend.byteLength);
try {
engine.HEAPU8.set(blend, input);
assert.equal(engine._web_engine_open_blend(handle, input, blend.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(input);
}
for (const expected of golden.frames) {
sendCommand(engine, handle, { type: "setFrame", frame: expected.frame });
const report = evaluate(engine, handle);
assert.equal(report.status, "EVALUATED");
assert.equal(report.frame, expected.frame);
const mesh = report.meshes.find((candidate) => candidate.sourceMeshId === `mesh:${golden.mesh}`);
assert.ok(mesh, `mesh ${golden.mesh} is missing at frame ${expected.frame}`);
assert.equal(mesh.vertexCount, expected.positions.length / 3);
assert.equal(mesh.positions.length, expected.positions.length);
const positionErrors = mesh.positions.map((value, index) => value - expected.positions[index]);
const maxPositionError = Math.max(0, ...positionErrors.map((value) => Math.abs(value)));
const rmsPositionError = Math.sqrt(positionErrors.reduce((sum, value) => sum + value * value, 0) / positionErrors.length);
assert.ok(maxPositionError <= tolerance.maxPositionError, `frame ${expected.frame} max position error ${maxPositionError}`);
assert.ok(rmsPositionError <= tolerance.rmsPositionError, `frame ${expected.frame} RMS position error ${rmsPositionError}`);
const matrixErrors = mesh.worldMatrix.map((value, index) => value - expected.worldMatrix[index]);
const maxMatrixError = Math.max(0, ...matrixErrors.map((value) => Math.abs(value)));
if (maxMatrixError > tolerance.maxMatrixError) console.error("debug-world", expected.frame, mesh.worldMatrix);
assert.ok(maxMatrixError <= tolerance.maxMatrixError, `frame ${expected.frame} world matrix error ${maxMatrixError}`);
}
}
finally {
engine._web_engine_destroy(handle);
}
process.stdout.write(`frame-evaluation-ok fixture=${golden.fixture} frames=${golden.frames.map((frame) => frame.frame).join(",")}\n`);

View File

@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(new URL("../../", import.meta.url).pathname);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "web-glb-blender-"));
function identity(translation = [0, 0, 0]) {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, translation[0], translation[1], translation[2], 1];
}
function geometry(meshId, positions) {
const positionBuffer = Float32Array.from(positions).buffer;
const indexBuffer = Uint32Array.from([0, 1, 2]).buffer;
return {
schemaVersion: 1,
meshId,
byteLength: positionBuffer.byteLength + indexBuffer.byteLength,
positions: positionBuffer,
indices: indexBuffer,
};
}
try {
const exporterSource = fs.readFileSync(path.join(root, "web/protocol/glb-export.ts"), "utf8");
const transpiled = ts.transpileModule(exporterSource, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
fileName: "glb-export.ts",
});
const exporterPath = path.join(temporary, "glb-export.cjs");
fs.writeFileSync(exporterPath, transpiled.outputText);
const { exportGLB } = await import(pathToFileURL(exporterPath).href);
const roundTripPositions = [0, 0, 0, 1, 0, 0, 0, 1, 0];
const skinPositions = [0, 0, 0, 0.5, 0, 0, 0, 0, 1];
const snapshot = {
schemaVersion: 1,
revision: 7,
sceneId: "scene:BlenderRoundTrip",
source: { kind: "mock" },
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
nodes: [
{ id: "object:Armature", name: "Armature", type: "ARMATURE", parentId: null, dataId: "armature:Rig", visible: true, selectable: true, localMatrix: identity(), worldMatrix: identity(), transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } },
{ id: "object:RoundTripMesh", name: "RoundTripMesh", type: "MESH", parentId: null, dataId: "mesh:RoundTripMesh", visible: true, selectable: true, localMatrix: identity([2, -3, 4]), worldMatrix: identity([2, -3, 4]), transform: { translation: [2, -3, 4], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } },
{ id: "object:SkinnedMesh", name: "SkinnedMesh", type: "MESH", parentId: null, dataId: "mesh:SkinnedMesh", visible: true, selectable: true, localMatrix: identity(), worldMatrix: identity(), transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } },
],
meshes: [
{ id: "mesh:RoundTripMesh", name: "RoundTripMesh", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:RoundTripMesh", topology: "triangles", materialSlotIds: ["material:RoundTrip"], shapeKeys: [{ name: "Lift", positions: [0, 0, 0, 1, 0, 0, 0, 1, 1] }] },
{ id: "mesh:SkinnedMesh", name: "SkinnedMesh", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:SkinnedMesh", topology: "triangles", materialSlotIds: ["material:Texture"], skinWeights: { boneNames: ["Root", "Tip"], indices: [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0], weights: [0.75, 0.25, 0, 0, 0.5, 0.5, 0, 0, 0.25, 0.75, 0, 0], bindMatrix: identity(), armatureId: "armature:Rig", jointIds: ["bone:Root", "bone:Tip"] } },
],
armatures: [{ id: "armature:Rig", name: "Rig", objectId: "object:Armature", bones: [
{ id: "bone:Root", name: "Root", parentId: null, head: [0, 0, 0], tail: [0, 0, 1], restMatrix: identity(), poseMatrix: identity() },
{ id: "bone:Tip", name: "Tip", parentId: "bone:Root", head: [0, 0, 1], tail: [0, 0, 2], restMatrix: identity([0, 0, 1]), poseMatrix: identity([0, 0, 1]) },
] }],
materials: [
{ id: "material:RoundTrip", name: "RoundTripMaterial", baseColor: [0.25, 0.5, 0.75, 0.8], roughness: 0.6, metallic: 0.35, emissionColor: [0.05, 0.1, 0.2, 1], alpha: 0.8, ior: 1.6, specularIORLevel: 0.35, transmissionWeight: 0.27, coatWeight: 0.64, coatRoughness: 0.12, emissionStrength: 3.5 },
{ id: "material:Texture", name: "TextureMaterial", baseColor: [1, 1, 1, 1], roughness: 0.4, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, imageIds: ["image:RoundTripTexture"] },
],
images: [{ id: "image:RoundTripTexture", name: "RoundTripTexture", assetId: "asset:RoundTripTexture", mimeType: "image/png", packed: true }],
animations: [{ id: "action:Move", name: "Move", targetId: "object:RoundTripMesh", frameStart: 1, frameEnd: 25, channels: [
{ path: "location[0]", keyframes: [{ frame: 1, value: [2] }, { frame: 25, value: [4] }] },
{ path: "location[1]", keyframes: [{ frame: 1, value: [-3] }, { frame: 25, value: [-3] }] },
{ path: "location[2]", keyframes: [{ frame: 1, value: [4] }, { frame: 25, value: [4] }] },
] }],
cameras: [], lights: [], worlds: [], collections: [], scenes: [], activeObjectId: "object:RoundTripMesh", frame: { current: 1, start: 1, end: 25 },
};
const texture = fs.readFileSync(path.join(root, "tests/files/web/resources/udim_1001.png"));
const exported = exportGLB(
snapshot,
[geometry("mesh:RoundTripMesh", roundTripPositions), geometry("mesh:SkinnedMesh", skinPositions)],
[{ assetId: "asset:RoundTripTexture", mimeType: "image/png", data: texture.buffer.slice(texture.byteOffset, texture.byteOffset + texture.byteLength) }],
);
assert.equal(exported.report.canExport, true, JSON.stringify(exported.report.warnings));
assert.ok(exported.glb?.byteLength > 1000, "GLB output is unexpectedly small");
const glbPath = path.join(temporary, "roundtrip.glb");
const reportPath = path.join(temporary, "blender-report.json");
fs.writeFileSync(glbPath, new Uint8Array(exported.glb));
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const imported = spawnSync(blender, ["-b", "--python", path.join(root, "tools/web/blender-check-glb-roundtrip.py"), "--", glbPath, reportPath], { cwd: root, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
assert.equal(imported.status, 0, `${imported.stdout}\n${imported.stderr}`);
assert.ok(fs.existsSync(reportPath), `Blender produced no round-trip report\n${imported.stdout}\n${imported.stderr}`);
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
if (process.env.GLB_ROUNDTRIP_DEBUG === "1") process.stderr.write(`${JSON.stringify(report.material, null, 2)}\n`);
assert.equal(report.mesh.vertexCount, 3);
assert.equal(report.mesh.triangleCount, 1);
assert.deepEqual(report.mesh.positions, [[0, 0, 0], [1, 0, 0], [0, 1, 0]]);
assert.deepEqual(report.mesh.frame1WorldTranslation, [2, -3, 4]);
assert.deepEqual(report.mesh.frame25WorldTranslation, [4, -3, 4]);
assert.deepEqual(report.mesh.shapeKeys.Lift, [0, 0, 1]);
assert.deepEqual(report.material.principled.baseColor, [0.25, 0.5, 0.75, 1]);
assert.equal(report.material.principled.alpha, 0.8);
assert.equal(report.material.principled.metallic, 0.35);
assert.equal(report.material.principled.roughness, 0.6);
assert.equal(report.material.principled.ior, 1.6);
assert.equal(report.material.principled.specularIORLevel, 0.35);
assert.equal(report.material.principled.transmissionWeight, 0.27);
assert.equal(report.material.principled.coatWeight, 0.64);
assert.equal(report.material.principled.coatRoughness, 0.12);
assert.equal(report.material.principled.emissionStrength, 3.5);
assert.equal(report.material.texture.present, true);
assert.equal(report.material.texture.interpolation, "Linear");
assert.equal(report.material.texture.extension, "REPEAT");
assert.equal(report.material.texture.imageWidth, 8);
assert.equal(report.material.texture.imageHeight, 8);
assert.equal(report.material.texture.colorSpace, "sRGB");
assert.ok(report.animation.actionCount >= 1);
assert.equal(report.skin.armatureCount, 1);
assert.deepEqual(report.skin.bones, ["Root", "Tip"]);
assert.deepEqual(report.skin.boneParents, { Root: null, Tip: "Root" });
assert.ok(report.skin.modifierCount >= 1);
assert.equal(report.skin.vertexGroupCount, 2);
assert.deepEqual(report.skin.weightSums, [1, 1, 1]);
process.stdout.write(`glb-blender-roundtrip-ok bytes=${exported.glb.byteLength} vertices=3 morphs=1 bones=2 actions=${report.animation.actionCount} texture=8x8\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,137 @@
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/modifier_grease_pencil_scene.blend", root));
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, 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 snapshot(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
}
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 close(actual, expected, label) {
assert.ok(Math.abs(actual - expected) <= 1e-6, `${label}: ${actual} != ${expected}`);
}
function assertFixture(scene) {
const node = scene.nodes.find((candidate) => candidate.name === "GreasePencilObject");
const data = scene.greasePencils?.find((candidate) => candidate.name === "GreasePencilData");
assert.equal(node?.type, "GREASE_PENCIL");
assert.equal(node?.dataId, data?.id);
assert.equal(data?.geometryStatus, "available");
assert.equal(data?.layerCount, 1);
assert.equal(data?.frameCount, 1);
assert.equal(data?.strokeCount, 1);
assert.equal(data?.pointCount, 4);
const layer = data.layers[0];
const stroke = layer.frames[0].drawing.strokes[0];
assert.equal(layer.name, "Lines");
assert.equal(layer.visible, true);
assert.equal(layer.locked, false);
assert.equal(stroke.cyclic, false);
assert.equal(stroke.materialIndex, 0);
assert.deepEqual(stroke.points[0].position, [-1.5, 0, 0]);
close(stroke.points[0].radius, 0.05, "radius");
close(stroke.points[0].opacity, 0.9, "opacity");
assert.equal(data.attributes.length, 6);
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
assertFixture(snapshot(engine, handle));
const original = snapshot(engine, handle).greasePencils[0];
const dataId = original.id;
command(engine, handle, { type: "createGreasePencilLayer", dataId, name: "Web Drafts" });
let edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layerCount, 2);
const layerId = edited.layers.find((layer) => layer.name === "Web Drafts").id;
command(engine, handle, { type: "moveGreasePencilLayer", dataId, layerId, direction: "BOTTOM" });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layers[0].id, layerId);
command(engine, handle, { type: "insertGreasePencilFrame", dataId, layerId, frame: 10, duration: 4 });
const webStrokes = [{
cyclic: true,
materialIndex: 0,
points: [
{ position: [0, 0, 0], radius: 0.1, opacity: 0.7, vertexColor: [1, 0, 0, 1] },
{ position: [1, 1, 0], radius: 0.2, opacity: 0.8, vertexColor: [0, 1, 0, 1] },
{ position: [2, 0, 0], radius: 0.3, opacity: 0.9, vertexColor: [0, 0, 1, 1] },
],
}];
command(engine, handle, { type: "setGreasePencilStrokes", dataId, layerId, frame: 10, strokes: webStrokes });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.frameCount, 2);
assert.equal(edited.strokeCount, 2);
assert.equal(edited.pointCount, 7);
assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].drawing.strokes[0].cyclic, true);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].strokeCount, 1);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].pointCount, 7);
command(engine, handle, { type: "removeGreasePencilFrame", dataId, layerId, frame: 10 });
assert.equal(snapshot(engine, handle).greasePencils[0].frameCount, 1);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].frameCount, 2);
command(engine, handle, { type: "removeGreasePencilLayer", dataId, layerId });
assert.equal(snapshot(engine, handle).greasePencils[0].layerCount, 1);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layerCount, 2);
assert.equal(edited.pointCount, 7);
const saved = output(engine, handle, engine._web_engine_save_blend, true);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
const reopenedScene = snapshot(engine, reopened);
const reopenedData = reopenedScene.greasePencils[0];
assert.equal(reopenedData.layerCount, 2);
assert.equal(reopenedData.frameCount, 2);
assert.equal(reopenedData.strokeCount, 2);
assert.equal(reopenedData.pointCount, 7);
const reopenedStroke = reopenedData.layers.find((layer) => layer.name === "Web Drafts").frames[0].drawing.strokes[0];
assert.equal(reopenedStroke.cyclic, true);
assert.deepEqual(reopenedStroke.points[2].position, [2, 0, 0]);
close(reopenedStroke.points[2].radius, 0.3, "reopened radius");
engine._web_engine_destroy(reopened);
process.stdout.write("grease-pencil-roundtrip-ok layer-frame-stroke=passed undo-redo=passed save-reopen=passed\n");

View File

@@ -0,0 +1,91 @@
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));
async function openFixture(name) {
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
const blend = fs.readFileSync(new URL(`tests/files/web/${name}`, root));
const input = engine._malloc(blend.byteLength);
engine.HEAPU8.set(blend, input);
assert.equal(engine._web_engine_open_blend(handle, input, blend.byteLength), 0, `${name} open`);
engine._free(input);
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0);
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const snapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
engine._free(dataOut);
engine._free(lengthOut);
return { engine, handle, snapshot };
}
function packedAsset(engine, handle, assetId) {
const encoded = new TextEncoder().encode(assetId);
const id = engine._malloc(encoded.byteLength);
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
engine.HEAPU8.set(encoded, id);
try {
assert.equal(engine._web_engine_get_packed_asset(handle, id, encoded.byteLength, dataOut, lengthOut), 0, assetId);
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return engine.HEAPU8.slice(pointer, pointer + length);
}
finally {
engine._free(id);
engine._free(dataOut);
engine._free(lengthOut);
}
}
const matrix = await openFixture("image_resource_matrix.blend");
try {
const byName = new Map(matrix.snapshot.images.map((image) => [image.name, image]));
const generated = byName.get("GeneratedTexture");
assert.equal(generated?.sourceKind, "GENERATED");
assert.equal(generated?.assetStatus, "GENERATED");
assert.equal(generated?.width, 12);
assert.equal(generated?.height, 6);
const udim = byName.get("UDIMTexture");
assert.equal(udim?.sourceKind, "TILED");
assert.equal(udim?.assetStatus, "PACKED");
assert.deepEqual(udim?.tiles.map((tile) => tile.number), [1001, 1002]);
for (const tile of udim.tiles) {
assert.equal(tile.packed, true);
assert.equal(tile.validSignature, true);
assert.deepEqual([...packedAsset(matrix.engine, matrix.handle, tile.assetId).slice(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
}
const linked = matrix.snapshot.libraries.find((library) => /image_resource_library\.blend$/.test(library.sourcePath));
assert.ok(linked, "linked image library resource is missing");
assert.equal(linked.packed, false);
assert.equal(linked.status, "EXTERNAL_REQUIRED");
assert.equal(linked.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED");
const missing = byName.get("MissingExternalTexture");
assert.equal(missing?.sourceKind, "FILE");
assert.equal(missing?.assetStatus, "EXTERNAL");
assert.match(missing?.sourcePath, /missing\/not-present\.png$/);
}
finally {
matrix.engine._web_engine_destroy(matrix.handle);
}
const corrupt = await openFixture("corrupt_packed_image.blend");
try {
const image = corrupt.snapshot.images.find((candidate) => candidate.name === "CorruptPackedTexture");
assert.equal(image?.packed, true);
assert.equal(image?.assetStatus, "CORRUPT");
assert.equal(image?.errorCode, "PACKED_IMAGE_SIGNATURE_INVALID");
}
finally {
corrupt.engine._web_engine_destroy(corrupt.handle);
}
process.stdout.write("image-resources-ok udimTiles=2 generated=1 linked=1 corrupt=1 external=1\n");

View File

@@ -0,0 +1,146 @@
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));
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, 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 snapshot(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
}
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 reject(engine, handle, payload, code) {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.notEqual(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), new RegExp(code));
}
finally { engine._free(pointer); }
}
function close(actual, expected, label) {
assert.ok(Math.abs(actual - expected) <= 1e-5, `${label}: ${actual} != ${expected}`);
}
function assertLighting(scene) {
const camera = scene.cameras.find((item) => item.id === "camera:Camera.001");
const light = scene.lights.find((item) => item.id === "light:Area");
const world = scene.worlds.find((item) => item.id === "world:World");
const definition = scene.scenes.find((item) => item.id === "scene:Scene");
assert.equal(camera.projection, "PERSPECTIVE");
assert.equal(camera.depthOfField.enabled, false);
assert.equal(camera.depthOfField.focusDistance, 10);
assert.equal(light.energy, 800);
assert.equal(light.exposure, 0);
assert.equal(light.castsShadow, true);
assert.equal(light.temperature, 6500);
assert.equal(world.mist.enabled, false);
assert.equal(world.mist.type, "QUADRATIC");
assert.equal(definition.renderEngine, "BLENDER_EEVEE");
assert.equal(definition.colorManagement.displayDevice, "sRGB");
assert.equal(definition.colorManagement.viewTransform, "AgX");
assert.equal(definition.colorManagement.look, "None");
assert.equal(definition.colorManagement.exposure, 0);
assert.equal(definition.colorManagement.whiteBalanceStatus, "AVAILABLE");
close(definition.colorManagement.temperature, 6500, "scene temperature");
close(definition.colorManagement.tint, 10, "scene tint");
return { camera, light, world, definition };
}
function assertEdited(scene) {
const camera = scene.cameras.find((item) => item.id === "camera:Camera.001");
const light = scene.lights.find((item) => item.id === "light:Area");
const world = scene.worlds.find((item) => item.id === "world:World");
const definition = scene.scenes.find((item) => item.id === "scene:Scene");
assert.equal(camera.projection, "PERSPECTIVE");
assert.equal(camera.depthOfField.enabled, false);
close(light.energy, 400, "light energy");
close(light.exposure, 1, "light exposure");
assert.equal(light.castsShadow, false);
assert.equal(light.useTemperature, true);
close(light.temperature, 5000, "light temperature");
assert.deepEqual(light.color, [0.25, 0.5, 0.75]);
assert.equal(world.mist.enabled, true);
assert.equal(world.mist.type, "LINEAR");
close(world.mist.start, 2, "mist start");
close(world.mist.depth, 50, "mist depth");
close(world.mist.intensity, 0.2, "mist intensity");
close(definition.colorManagement.exposure, 0, "preserved scene exposure");
close(definition.colorManagement.gamma, 1, "preserved scene gamma");
assert.equal(definition.colorManagement.whiteBalanceStatus, "BLOCKED");
assert.equal(definition.colorManagement.temperature, undefined);
assert.equal(definition.colorManagement.tint, undefined);
return { camera, light, world, definition };
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const before = assertLighting(snapshot(engine, handle));
command(engine, handle, { type: "setObjectVisibility", objectId: "object:BasicCube", visible: true });
assert.equal(snapshot(engine, handle).scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "AVAILABLE");
command(engine, handle, { type: "setLightProperties", dataId: before.light.id, properties: {
color: [0.25, 0.5, 0.75], energy: 400, exposure: 1, castsShadow: false,
temperature: 5000, useTemperature: true,
} });
assert.equal(snapshot(engine, handle).scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "BLOCKED");
command(engine, handle, { type: "setWorldProperties", dataId: before.world.id, properties: {
color: [0.1, 0.2, 0.3], exposure: 0.5,
mist: { enabled: true, type: "LINEAR", start: 2, depth: 50, intensity: 0.2, height: 3 },
} });
assert.equal(snapshot(engine, handle).scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "BLOCKED");
assertEdited(snapshot(engine, handle));
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).worlds.find((item) => item.id === before.world.id).mist.enabled, false);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assertEdited(snapshot(engine, handle));
const saved = output(engine, handle, engine._web_engine_save_blend, true);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
assertEdited(snapshot(engine, reopened));
engine._web_engine_destroy(reopened);
process.stdout.write("lighting-roundtrip-ok camera-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed\n");

59
tools/web/check-local-deps.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
web_dir="${repo_root}/web"
check_sha256() {
local expected="$1"
local path="$2"
local actual
actual="$(sha256sum "${path}" | cut -d ' ' -f 1)"
if [[ "${actual}" != "${expected}" ]]; then
echo "local dependency checksum mismatch: ${path}" >&2
exit 1
fi
}
node "${web_dir}/-check-runtime-deps.mjs"
node "${repo_root}/tools/web/check-manifest-assets.mjs" \
"${web_dir}/app/public/engine-manifest.json" "${web_dir}/app/public"
node "${repo_root}/tools/web/check-three-vendor.mjs"
if rg -n --glob '!vendor/**' --glob '!*.map' 'https?://(unpkg|cdn\.jsdelivr|esm\.sh|skypack|jsdelivr)' \
"${web_dir}/app/src" "${web_dir}/app/index.html" "${web_dir}/app/vite.config.ts"; then
echo "remote runtime module reference found" >&2
exit 1
fi
test -s "${web_dir}/app/src/vendor/three/three.core.js"
test -s "${web_dir}/app/src/vendor/three/three.module.js"
test -s "${web_dir}/app/src/vendor/three/addons/controls/OrbitControls.js"
test -s "${web_dir}/app/src/vendor/blender/web_engine.js"
test -s "${web_dir}/app/src/vendor/blender/web_engine.wasm"
test -s "${web_dir}/app/public/vendor/blender/web_engine.js"
test -s "${web_dir}/app/public/vendor/blender/web_engine.wasm"
cmp "${web_dir}/app/src/vendor/blender/web_engine.js" "${web_dir}/app/public/vendor/blender/web_engine.js"
cmp "${web_dir}/app/src/vendor/blender/web_engine.wasm" "${web_dir}/app/public/vendor/blender/web_engine.wasm"
test -s "${repo_root}/blender-5.2.0/extern/zlib/CMakeLists.txt"
test -s "${repo_root}/blender-5.2.0/extern/zstd/build/cmake/CMakeLists.txt"
test -s "${repo_root}/blender-5.2.0/extern/opensubdiv-source/CMakeLists.txt"
test -s "${repo_root}/blender-5.2.0/extern/gmp-wasm/include/gmp.h"
test -s "${repo_root}/blender-5.2.0/extern/gmp-wasm/include/gmpxx.h"
test -s "${repo_root}/blender-5.2.0/extern/gmp-wasm/lib/libgmp.a"
test -s "${repo_root}/blender-5.2.0/extern/gmp-wasm/lib/libgmpxx.a"
check_sha256 \
"f843eb49daf20264007d807cbc64516a1fed9cdb1149aaf84ff47691d97491f9" \
"${repo_root}/blender-5.2.0/extern/opensubdiv-source/opensubdiv-v3_7_0.tar.gz"
check_sha256 \
"bd2966e6d277f79328e894a5a9f3ba3fbf2ed2be81def5f48623e30c23fb1572" \
"${repo_root}/blender-5.2.0/extern/gmp-source/gmp_6.3.0+dfsg.orig.tar.xz"
test -s "${web_dir}/dist/index.html"
test -n "$(find "${web_dir}/dist" -maxdepth 2 -type f -name 'index-*.js' -print -quit)"
if rg -n 'https?://(unpkg|cdn\.jsdelivr|esm\.sh|skypack|jsdelivr)' "${web_dir}/dist"; then
echo "remote runtime URL found in dist" >&2
exit 1
fi
echo "local-dependencies-ok"

View File

@@ -0,0 +1,134 @@
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 cases = [
{
golden: JSON.parse(fs.readFileSync(new URL("tests/golden/W-080/animation-depsgraph.json", root), "utf8")),
samplesKey: "frames",
},
{
golden: JSON.parse(fs.readFileSync(new URL("tests/golden/W-080/pose-constraint-depsgraph.json", root), "utf8")),
samplesKey: "samples",
},
];
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,
engine.UTF8ToString(engine._web_engine_last_error_message()),
);
}
finally {
engine._free(pointer);
}
}
function readOutput(engine, handle, fn, owned) {
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 && length, "native response is empty");
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 evaluate(engine, handle) {
const bytes = readOutput(engine, handle, engine._web_engine_evaluate_depsgraph, false);
return JSON.parse(new TextDecoder().decode(bytes));
}
function save(engine, handle) {
return readOutput(engine, handle, engine._web_engine_save_blend, true);
}
function maxError(actual, expected) {
assert.equal(actual.length, expected.length);
return Math.max(0, ...actual.map((value, index) => Math.abs(value - expected[index])));
}
for (const testCase of cases) {
const { golden } = testCase;
const source = fs.readFileSync(new URL(`tests/files/web/${golden.fixture}`, root));
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const sourceHandle = engine._web_engine_create();
open(engine, sourceHandle, source);
command(engine, sourceHandle, { type: "setFrame", frame: 5 });
const firstGeneration = save(engine, sourceHandle);
engine._web_engine_destroy(sourceHandle);
const reopenedHandle = engine._web_engine_create();
open(engine, reopenedHandle, firstGeneration);
const samples = new Map(golden[testCase.samplesKey].map((sample) => [sample.frame, sample]));
const frames = testCase.samplesKey === "frames" ? golden.frames.map((sample) => sample.frame) : golden.frames;
for (const frame of frames) {
command(engine, reopenedHandle, { type: "setFrame", frame });
const report = evaluate(engine, reopenedHandle);
const expected = samples.get(frame);
assert.ok(expected, `missing golden sample at frame ${frame}`);
const mesh = report.meshes.find((candidate) => candidate.sourceMeshId === `mesh:${golden.mesh}`);
assert.ok(mesh, `round-trip mesh missing at frame ${frame}`);
assert.ok(
maxError(mesh.positions, expected.positions) <= golden.tolerance.maxPositionError,
`${golden.fixture} frame ${frame} position parity failed`,
);
if (expected.worldMatrix) {
assert.ok(
maxError(mesh.worldMatrix, expected.worldMatrix) <= golden.tolerance.maxMatrixError,
`${golden.fixture} frame ${frame} matrix parity failed`,
);
}
if (expected.bones) {
const armature = report.armatures.find((candidate) => candidate.id === `armature:${golden.armature}`);
assert.ok(armature, `round-trip armature missing at frame ${frame}`);
for (const [boneName, poseMatrix] of Object.entries(expected.bones)) {
const bone = armature.bones.find((candidate) => candidate.name === boneName);
assert.ok(bone, `${boneName} missing after round-trip at frame ${frame}`);
assert.ok(
maxError(bone.poseMatrix, poseMatrix) <= golden.tolerance.maxMatrixError,
`${boneName} frame ${frame} pose parity failed`,
);
}
}
}
const secondGeneration = save(engine, reopenedHandle);
engine._web_engine_destroy(reopenedHandle);
const secondHandle = engine._web_engine_create();
open(engine, secondHandle, secondGeneration);
engine._web_engine_destroy(secondHandle);
assert.ok(firstGeneration.byteLength > 0 && secondGeneration.byteLength > 0);
}
process.stdout.write("main-roundtrip-ok fixtures=animation_scene.blend,pose_constraint_scene.blend frames=1,5,10 generations=2\n");

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
const valid = fs.readFileSync(new URL("../../tests/files/web/basic_scene.blend", import.meta.url));
const encoder = new TextEncoder();
const cases = [
{ name: "empty", bytes: new Uint8Array(0) },
{ name: "random", bytes: Uint8Array.from({ length: 4096 }, (_, index) => (index * 73 + 19) & 0xff) },
{ name: "truncated-header", bytes: valid.subarray(0, 11) },
{ name: "truncated-dna", bytes: valid.subarray(0, Math.min(8192, valid.length - 1)) },
{ name: "forged-block-size", bytes: (() => { const data = new Uint8Array(64); data.set(encoder.encode("BLENDER-v520")); data.set(encoder.encode("TEST"), 12); data.set([0xff, 0xff, 0xff, 0x7f], 16); return data; })() },
];
for (const fixture of cases) {
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
assert.ok(handle > 0);
try {
let pointer = 0;
if (fixture.bytes.byteLength > 0) {
pointer = engine._malloc(fixture.bytes.byteLength);
engine.HEAPU8.set(fixture.bytes, pointer);
}
const result = engine._web_engine_open_blend(handle, pointer, fixture.bytes.byteLength);
if (pointer) engine._free(pointer);
assert.notEqual(result, 0, `${fixture.name} malicious blend was accepted`);
const message = engine.UTF8ToString(engine._web_engine_last_error_message());
assert.ok(message.length > 0 && message.length < 1024, `${fixture.name} did not return a bounded structured error`);
}
finally {
engine._web_engine_destroy(handle);
}
}
process.stdout.write(`malicious-blends-ok rejected=${cases.length}\n`);

View File

@@ -0,0 +1,17 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const [manifestPath, publicRoot] = process.argv.slice(2);
if (!manifestPath || !publicRoot) throw new Error("usage: check-manifest-assets.mjs MANIFEST PUBLIC_ROOT");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const resource of manifest.wasm ?? []) {
const relativePath = resource.url.replace(/^\/+/, "");
const filePath = path.join(publicRoot, relativePath);
const actual = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (actual !== String(resource.sha256).toLowerCase()) {
throw new Error(`manifest hash mismatch: ${resource.id} expected=${resource.sha256} actual=${actual}`);
}
}
console.log(`manifest-assets-ok resources=${(manifest.wasm ?? []).length}`);

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const reader = fs.readFileSync(new URL("../../blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp", import.meta.url), "utf8");
const registry = fs.readFileSync(new URL("../../blender-5.2.0/source/blender/modifiers/intern/MOD_util.cc", import.meta.url), "utf8");
const table = reader.match(/static const char \*const names\[\] = \{([\s\S]*?)\};\s*static_assert\(std::size\(names\) == NUM_MODIFIER_TYPES/);
assert.ok(table, "modifier identity table is missing its NUM_MODIFIER_TYPES guard");
const names = [...table[1].matchAll(/"([A-Z0-9_]+)"/g)].map((match) => match[1]);
assert.equal(names.length, 87, "Blender 5.2 modifier identity table must cover codes 0..86");
assert.equal(new Set(names).size, 87, "modifier identity names must be unique");
const webRegistry = registry.match(/#ifdef WITH_WEB([\s\S]*?)#else/);
assert.ok(webRegistry, "WITH_WEB modifier registry is missing");
const enabledNames = new Set([...webRegistry[1].matchAll(/INIT_TYPE\(([A-Za-z0-9_]+)\)/g)].map((match) => match[1].toUpperCase()));
const aliases = new Map([["NONE", "NONE"], ["SUBSURF", "SUBSURF"], ["SHAPE_KEY", "SHAPEKEY"], ["EDGE_SPLIT", "EDGESPLIT"], ["MESH_DEFORM", "MESHDEFORM"], ["SIMPLE_DEFORM", "SIMPLEDEFORM"], ["SURFACE_DEFORM", "SURFACEDEFORM"]]);
const matrix = names.map((name, typeCode) => {
const registryName = aliases.get(name) ?? name.replaceAll("_", "");
return { typeCode, name, evaluation: enabledNames.has(registryName) ? "NATIVE_WHITELIST" : "STRUCTURED_BLOCK" };
});
assert.ok(matrix.every((entry, index) => entry.typeCode === index));
assert.ok(matrix.filter((entry) => entry.evaluation === "NATIVE_WHITELIST").length >= 27);
assert.ok(matrix.filter((entry) => entry.evaluation === "STRUCTURED_BLOCK").length > 0);
assert.match(registry, /types\[type\] = &modifierType_None/, "non-whitelisted types must retain identity through the disabled sentinel");
assert.match(reader, /"UNKNOWN_" \+ std::to_string\(type\)/, "out-of-range types must remain explicit");
process.stdout.write(`modifier-capability-matrix-ok types=${matrix.length} native=${matrix.filter((entry) => entry.evaluation === "NATIVE_WHITELIST").length} blocked=${matrix.filter((entry) => entry.evaluation === "STRUCTURED_BLOCK").length}\n`);

View File

@@ -0,0 +1,53 @@
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 golden = JSON.parse(fs.readFileSync(new URL("tests/golden/N-002/modifier_dependency_cycle.json", root), "utf8"));
const blend = fs.readFileSync(new URL(`tests/files/web/${golden.fixture}`, root));
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
const input = engine._malloc(blend.byteLength);
engine.HEAPU8.set(blend, input);
assert.equal(engine._web_engine_open_blend(handle, input, blend.byteLength), 0);
engine._free(input);
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
assert.equal(engine._web_engine_evaluate_depsgraph(handle, dataOut, lengthOut), 0);
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const report = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
const byObject = new Map(report.objects.map((object) => [object.objectId, object]));
for (const name of golden.cycleObjects) {
const object = byObject.get(`object:${name}`);
assert.ok(object, `${name} report is missing`);
assert.equal(object.modifiers.length, 1);
assert.equal(object.modifiers[0].status, "BLOCKED");
assert.equal(object.modifiers[0].errorCode, "MODIFIER_DEPENDENCY_CYCLE");
assert.equal(object.modifiers[0].targetObjectIds.length, 1);
}
const missing = byObject.get(`object:${golden.missingTarget.object}`)?.modifiers.find(
(modifier) => modifier.name === golden.missingTarget.modifier,
);
assert.equal(missing?.status, "BLOCKED");
assert.equal(missing?.errorCode, "MODIFIER_TARGET_MISSING");
const ordered = byObject.get(`object:${golden.orderedStack.object}`)?.modifiers;
assert.deepEqual(ordered?.map((modifier) => modifier.name), golden.orderedStack.modifiers);
assert.deepEqual(ordered?.[1].dependsOn, [ordered[0].uuid]);
assert.equal(ordered?.[0].showViewport, true);
assert.equal(ordered?.[0].showRender, true);
assert.equal(ordered?.[0].showEditmode, true);
assert.equal(ordered?.[0].showOnCage, true);
assert.equal(ordered?.[1].status, "DISABLED");
assert.equal(ordered?.[1].showRender, true);
engine._free(dataOut);
engine._free(lengthOut);
engine._web_engine_destroy(handle);
process.stdout.write("modifier-cycle-ok cycles=2 missingTargets=1 orderedStack=2\n");

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

View File

@@ -0,0 +1,101 @@
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 golden = JSON.parse(fs.readFileSync(new URL("tests/golden/N-015/nonmesh-desktop-geometry.json", root), "utf8"));
const fixture = fs.readFileSync(new URL(`tests/files/web/${golden.fixture}`, root));
function readOutput(engine, handle, callback) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(callback(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function close(actual, expected, tolerance, label) {
assert.ok(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, got ${actual}`);
}
function metrics(geometry) {
const positions = geometry.positions ?? [];
const edgeIndices = geometry.edgeVertexIndices ?? [];
const indices = geometry.indices ?? [];
const vertexCount = positions.length / 3;
const boundsMin = [0, 1, 2].map((axis) => vertexCount ? Math.min(...Array.from({ length: vertexCount }, (_, index) => positions[index * 3 + axis])) : 0);
const boundsMax = [0, 1, 2].map((axis) => vertexCount ? Math.max(...Array.from({ length: vertexCount }, (_, index) => positions[index * 3 + axis])) : 0);
const centroid = [0, 1, 2].map((axis) => vertexCount ? Array.from({ length: vertexCount }, (_, index) => positions[index * 3 + axis]).reduce((sum, value) => sum + value, 0) / vertexCount : 0);
let surfaceArea = 0;
for (let triangle = 0; triangle < indices.length; triangle += 3) {
const a = indices[triangle] * 3;
const b = indices[triangle + 1] * 3;
const c = indices[triangle + 2] * 3;
const ab = [positions[b] - positions[a], positions[b + 1] - positions[a + 1], positions[b + 2] - positions[a + 2]];
const ac = [positions[c] - positions[a], positions[c + 1] - positions[a + 1], positions[c + 2] - positions[a + 2]];
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]];
surfaceArea += Math.hypot(...cross) * 0.5;
}
return {
vertexCount,
edgeCount: edgeIndices.length / 2,
triangleCount: indices.length / 3,
boundsMin,
boundsMax,
centroid,
surfaceArea,
positionMoment: positions.reduce((sum, value, index) => sum + (index + 1) * value, 0),
edgeIndexMoment: edgeIndices.reduce((sum, value, index) => sum + (index + 1) * (value + 1), 0),
indexMoment: indices.reduce((sum, value, index) => sum + (index + 1) * (value + 1), 0),
};
}
assert.equal(golden.schemaVersion, 1);
assert.equal(golden.blenderVersion, "5.2.0 LTS");
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
try {
const input = engine._malloc(fixture.byteLength);
engine.HEAPU8.set(fixture, input);
try {
assert.equal(engine._web_engine_open_blend(handle, input, fixture.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(input);
}
const evaluation = readOutput(engine, handle, engine._web_engine_evaluate_depsgraph);
const byObject = new Map(evaluation.nonMeshGeometries.map((geometry) => [geometry.objectId.replace(/^object:/, ""), geometry]));
for (const expected of golden.geometries) {
const geometry = byObject.get(expected.object);
assert.ok(geometry, `desktop golden object ${expected.object} is missing from the Web evaluation`);
assert.equal(geometry.status, "EVALUATED");
assert.equal(geometry.sourceType, expected.sourceType);
const actual = metrics(geometry);
assert.equal(actual.vertexCount, expected.vertexCount, `${expected.object} vertex count`);
assert.equal(actual.edgeCount, expected.edgeCount, `${expected.object} edge count`);
assert.equal(actual.triangleCount, expected.triangleCount, `${expected.object} triangle count`);
assert.equal(actual.edgeIndexMoment, expected.edgeIndexMoment, `${expected.object} edge index moment`);
assert.equal(actual.indexMoment, expected.indexMoment, `${expected.object} index moment`);
for (let axis = 0; axis < 3; axis++) {
close(actual.boundsMin[axis], expected.boundsMin[axis], golden.tolerance.coordinate, `${expected.object} boundsMin[${axis}]`);
close(actual.boundsMax[axis], expected.boundsMax[axis], golden.tolerance.coordinate, `${expected.object} boundsMax[${axis}]`);
close(actual.centroid[axis], expected.centroid[axis], golden.tolerance.coordinate, `${expected.object} centroid[${axis}]`);
}
close(actual.surfaceArea, expected.surfaceArea, golden.tolerance.surfaceArea, `${expected.object} surface area`);
close(actual.positionMoment, expected.positionMoment, golden.tolerance.positionMoment, `${expected.object} position moment`);
}
assert.equal(byObject.size, golden.geometries.length, "unexpected evaluated non-mesh object count");
}
finally {
engine._web_engine_destroy(handle);
}
console.log(`nonmesh-desktop-golden-ok fixture=${golden.fixture} geometries=${golden.geometries.length}`);

View File

@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(new URL("../../", import.meta.url).pathname);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "web-nonmesh-glb-"));
const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm"));
const fixture = fs.readFileSync(path.join(root, "tests/files/web/nonmesh_scene.blend"));
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/N-015/nonmesh-desktop-geometry.json"), "utf8"));
const expected = Object.fromEntries(golden.geometries.map((geometry) => [geometry.object, geometry]));
const binaryTypes = new Set(["POINT_CLOUD", "CURVES", "HAIR"]);
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];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function close(actual, expectedValue, label) {
assert.ok(Math.abs(actual - expectedValue) <= 1e-5, `${label}: expected ${expectedValue}, got ${actual}`);
}
function transpile(sourceName, outputName) {
const source = fs.readFileSync(path.join(root, `web/protocol/${sourceName}`), "utf8");
const result = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
fileName: sourceName,
});
fs.writeFileSync(path.join(temporary, outputName), result.outputText);
}
try {
transpile("nonmesh-binary.ts", "nonmesh-binary.js");
transpile("nonmesh-export.ts", "nonmesh-export.js");
transpile("glb-export.ts", "glb-export.cjs");
const { exportGLB } = await import(pathToFileURL(path.join(temporary, "glb-export.cjs")).href);
const { chunkNonMeshGeometry } = await import(pathToFileURL(path.join(temporary, "nonmesh-binary.js")).href);
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
try {
const input = engine._malloc(fixture.byteLength);
engine.HEAPU8.set(fixture, input);
assert.equal(engine._web_engine_open_blend(handle, input, fixture.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
engine._free(input);
const sourceSnapshot = output(engine, handle, engine._web_engine_get_scene_snapshot);
const depsgraph = output(engine, handle, engine._web_engine_evaluate_depsgraph);
const sourceData = new Map((sourceSnapshot.nonMeshData ?? []).map((data) => [data.id, data]));
const binaryData = (sourceSnapshot.nonMeshData ?? []).filter((data) => binaryTypes.has(data.type));
const binaryIds = new Set(binaryData.map((data) => data.id));
const binaryObjectIds = new Set(sourceSnapshot.nodes.filter((node) => binaryIds.has(node.dataId)).map((node) => node.id));
const binaryChunks = [];
const binaryMetadata = [];
for (const data of binaryData) {
assert.ok(data.controlPoints?.length === data.pointCount * 3, `${data.name} source points missing`);
binaryChunks.push(...await chunkNonMeshGeometry({
dataId: data.id,
positions: Float32Array.from(data.controlPoints),
radii: data.radii ? Float32Array.from(data.radii) : undefined,
curveOffsets: data.splineOffsets ? Uint32Array.from(data.splineOffsets) : undefined,
attributes: (data.attributeValues ?? []).map((attribute) => ({ ...attribute,
values: attribute.dataType === "INT" ? Int32Array.from(attribute.values) : attribute.dataType === "BOOL" || attribute.dataType === "BYTE_COLOR" ? Uint8Array.from(attribute.values) : Float32Array.from(attribute.values) })),
}));
const { controlPoints, radii, splineOffsets, attributeValues, ...metadata } = data;
binaryMetadata.push({ ...metadata, geometryStatus: "binary", geometryBufferId: data.id });
}
const selected = new Set(["CURVE", "SURFACE", "FONT", "METABALL"]);
const evaluated = (depsgraph.nonMeshGeometries ?? []).filter((geometry) => geometry.status === "EVALUATED" && selected.has(geometry.sourceType));
assert.equal(evaluated.length, 4, "all four evaluated non-mesh objects are required");
const evaluatedByObject = new Map(evaluated.map((geometry) => [geometry.objectId, geometry]));
const geometryBuffers = evaluated.map((geometry) => {
const positions = Float32Array.from(geometry.positions ?? []).buffer;
const indices = Uint32Array.from(geometry.indices ?? []).buffer;
const edgeVertexIndices = Uint32Array.from(geometry.edgeVertexIndices ?? []).buffer;
return {
schemaVersion: 1,
meshId: geometry.meshId,
byteLength: positions.byteLength + indices.byteLength + edgeVertexIndices.byteLength,
positions,
indices,
...(edgeVertexIndices.byteLength > 0 ? { edgeVertexIndices } : {}),
};
});
const meshes = evaluated.map((geometry) => {
const source = sourceData.get(geometry.sourceDataId);
const lineTopology = geometry.triangleCount === 0 && geometry.edgeCount > 0;
return {
id: geometry.meshId,
name: source?.name ?? geometry.objectId,
vertexCount: geometry.vertexCount,
edgeCount: geometry.edgeCount,
faceCount: geometry.triangleCount,
cornerCount: geometry.triangleCount * 3,
triangleCount: geometry.triangleCount,
geometryStatus: "binary",
geometryBufferId: geometry.meshId,
topology: lineTopology ? "lines" : "triangles",
...(lineTopology ? { edgeVertexIndices: geometry.edgeVertexIndices } : {}),
};
});
const nonMeshData = (sourceSnapshot.nonMeshData ?? []).filter((data) => selected.has(data.type)).map((data) => {
const geometry = evaluated.find((candidate) => candidate.sourceDataId === data.id);
assert.ok(geometry, `${data.name} evaluation missing`);
return { ...data, evaluatedGeometry: [{ objectId: geometry.objectId, meshId: geometry.meshId, vertexCount: geometry.vertexCount, edgeCount: geometry.edgeCount, triangleCount: geometry.triangleCount, status: "EVALUATED" }] };
});
const snapshot = {
...sourceSnapshot,
nodes: sourceSnapshot.nodes.filter((node) => evaluatedByObject.has(node.id)).map((node) => ({ ...node, dataId: evaluatedByObject.get(node.id).meshId })),
meshes,
nonMeshData,
materials: [],
images: [],
animations: [],
armatures: [],
};
const triangulated = evaluated.filter((geometry) => geometry.triangleCount > 0 || geometry.edgeCount > 0);
const triangulatedMeshIds = new Set(triangulated.map((geometry) => geometry.meshId));
const triangulatedObjectIds = new Set(triangulated.map((geometry) => geometry.objectId));
const triangulatedDataIds = new Set(triangulated.map((geometry) => geometry.sourceDataId));
const exportSnapshot = {
...snapshot,
nodes: [...snapshot.nodes.filter((node) => triangulatedObjectIds.has(node.id)), ...sourceSnapshot.nodes.filter((node) => binaryObjectIds.has(node.id))],
meshes: snapshot.meshes.filter((mesh) => triangulatedMeshIds.has(mesh.id)),
nonMeshData: [...snapshot.nonMeshData.filter((data) => triangulatedDataIds.has(data.id)), ...binaryMetadata],
};
const exportBuffers = geometryBuffers.filter((geometry) => triangulatedMeshIds.has(geometry.meshId));
const exported = exportGLB(exportSnapshot, exportBuffers, [], binaryChunks);
assert.equal(exported.report.canExport, true, JSON.stringify(exported.report.warnings));
assert.ok(exported.glb && exported.glb.byteLength > 1000, "non-mesh GLB is unexpectedly small");
const glbView = new DataView(exported.glb);
const jsonLength = glbView.getUint32(12, true);
const gltf = JSON.parse(new TextDecoder().decode(new Uint8Array(exported.glb, 20, jsonLength)).trim());
const primitiveModes = gltf.meshes.flatMap((mesh) => mesh.primitives.map((primitive) => primitive.mode ?? 4)).sort();
const expectedModes = [...Object.values(expected).map((geometry) => geometry.triangleCount > 0 ? 4 : 1), 0, 1, 1].sort();
assert.deepEqual(primitiveModes, expectedModes, "GLB primitive modes must match evaluated edge/triangle/point topology");
assert.equal(exported.report.warnings.filter((warning) => warning.code === "NON_MESH_ATTRIBUTE_LOSS").length, 3);
const glbPath = path.join(temporary, "nonmesh.glb");
const reportPath = path.join(temporary, "blender-report.json");
fs.writeFileSync(glbPath, new Uint8Array(exported.glb));
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const imported = spawnSync(blender, ["-b", "--python", path.join(root, "tools/web/blender-check-nonmesh-glb-roundtrip.py"), "--", glbPath, reportPath], { cwd: root, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
assert.equal(imported.status, 0, `${imported.stdout}\n${imported.stderr}`);
assert.ok(fs.existsSync(reportPath), `Blender checker produced no report:\n${imported.stdout}\n${imported.stderr}`);
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
assert.deepEqual(Object.keys(report).sort(), [...Object.keys(expected), "WebPointCloudObject", "WebCurvesObject", "WebHairObject"].sort());
for (const [name, record] of Object.entries(expected)) {
const actual = report[name];
assert.equal(actual.vertexCount, record.vertexCount, `${name} vertex count`);
if (record.triangleCount === 0) assert.equal(actual.edgeCount, record.edgeCount, `${name} edge count`);
else assert.equal(actual.triangleCount, record.triangleCount, `${name} triangle count`);
for (let axis = 0; axis < 3; axis++) {
close(actual.boundsMin[axis], record.boundsMin[axis], `${name} boundsMin[${axis}]`);
close(actual.boundsMax[axis], record.boundsMax[axis], `${name} boundsMax[${axis}]`);
}
}
for (const data of binaryData) {
const name = sourceSnapshot.nodes.find((node) => node.dataId === data.id)?.name;
const actual = report[name];
assert.equal(actual.vertexCount, data.pointCount, `${name} vertex count`);
const expectedEdges = data.splineOffsets ? data.pointCount - (data.splineOffsets.length - 1) : 0;
assert.equal(actual.edgeCount, expectedEdges, `${name} edge count`);
}
console.log(`nonmesh-glb-blender-roundtrip-ok bytes=${exported.glb.byteLength} objects=7 points=1 lines=${expectedModes.filter((mode) => mode === 1).length} triangles=${expectedModes.filter((mode) => mode === 4).length}`);
}
finally {
engine._web_engine_destroy(handle);
}
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,301 @@
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/nonmesh_scene.blend", root));
function output(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(output(engine, handle, engine._web_engine_get_scene_snapshot)));
}
function save(engine, handle) {
return output(engine, handle, engine._web_engine_save_blend, true);
}
function depsgraph(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_evaluate_depsgraph)));
}
function close(actual, expected, label) {
assert.ok(Math.abs(actual - expected) < 1e-5, `${label}: ${actual} != ${expected}`);
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const before = snapshot(engine, handle);
const curve = before.nonMeshData.find((data) => data.type === "CURVE");
const surface = before.nonMeshData.find((data) => data.type === "SURFACE");
const font = before.nonMeshData.find((data) => data.type === "FONT");
const metaball = before.nonMeshData.find((data) => data.type === "METABALL");
assert.ok(curve?.controlPoints?.length && curve.splineOffsets?.length);
assert.ok(surface?.controlPoints?.length && surface.splineOffsets?.length);
assert.deepEqual(surface.splineDimensions, [{ u: 4, v: 4, orderU: 4, orderV: 4 }]);
assert.equal(surface.pointWeights?.length, 16);
assert.ok(font?.text && metaball?.elements?.length);
assert.equal(before.vfonts?.length, 2);
assert.ok(before.vfonts.every((resource) => resource.packed && resource.id.startsWith("vfont:")));
assert.ok(font.fontLinks);
assert.deepEqual(curve.splineTypes, ["POLY", "BEZIER"]);
assert.equal(curve.cyclicU?.length, curve.splineCount);
assert.equal(curve.handleTypes?.length, 6);
assert.equal(curve.handlePoints?.length, 18);
assert.deepEqual(curve.handlePointIndices, [4, 5, 6]);
command(engine, handle, { type: "renameId", id: curve.id, name: "WebCurveRenamed" });
const renamedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.type === "CURVE" && data.name === "WebCurveRenamed");
assert.equal(renamedCurve?.id, "curve:WebCurveRenamed");
const curveId = renamedCurve.id;
command(engine, handle, { type: "createCurve", curveType: "CURVE", name: "CreatedWebCurve", controlPoints: [-1, 0, 0, 0, 1, 0, 1, 0, 0], cyclic: true, resolution: 8 });
const createdCurve = snapshot(engine, handle).nonMeshData.find((data) => data.name === "CreatedWebCurve");
assert.equal(createdCurve?.type, "CURVE");
assert.equal(createdCurve?.controlPoints?.length, 9);
assert.deepEqual(createdCurve?.cyclicU, [true]);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["NURBS"], cyclicU: [true], cyclicV: [false] });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id)?.splineTypes, ["NURBS"]);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["POLY"], cyclicU: [true], cyclicV: [false] });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id)?.splineTypes, ["POLY"]);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["BEZIER"], cyclicU: [true], cyclicV: [false] });
const bezierCreatedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id);
assert.deepEqual(bezierCreatedCurve?.splineTypes, ["BEZIER"]);
assert.equal(bezierCreatedCurve?.handleTypes?.length, 6);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["NURBS"], cyclicU: [true], cyclicV: [false] });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id)?.splineTypes, ["NURBS"]);
const threeSplineTypes = ["POLY", "BEZIER", "NURBS"];
const threeSplineOffsets = [0, 3, 6, 10];
const threeSplinePoints = [
-2, 0, 0, -1.5, 0.5, 0, -1, 0, 0,
-0.5, 0, 0, 0, 0.75, 0, 0.5, 0, 0,
1, 0, 0, 1.5, 0.5, 0, 2, 0.5, 0, 2.5, 0, 0,
];
const threeSplineWeights = Array(10).fill(1);
threeSplineWeights[8] = 0.8;
const threeSplineHandles = [
-0.75, 0, 0, -0.25, 0, 0,
-0.25, 0.75, 0, 0.25, 0.75, 0,
0.25, 0, 0, 0.75, 0, 0,
];
const threeSplineCommand = {
type: "setCurveSplines", dataId: createdCurve.id, splineTypes: threeSplineTypes,
splineOffsets: threeSplineOffsets, ordersU: [0, 0, 4], controlPoints: threeSplinePoints,
pointWeights: threeSplineWeights, cyclicU: [false, true, false],
handleTypes: [0, 0, 1, 1, 2, 2], handlePoints: threeSplineHandles,
};
command(engine, handle, threeSplineCommand);
let transactedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id);
assert.deepEqual(transactedCurve.splineTypes, threeSplineTypes);
assert.deepEqual(transactedCurve.splineOffsets, threeSplineOffsets);
assert.deepEqual(transactedCurve.cyclicU, [false, true, false]);
assert.equal(transactedCurve.pointCount, 10);
assert.equal(transactedCurve.handlePoints.length, 18);
close(transactedCurve.pointWeights[8], 0.8, "multi-spline NURBS weight");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, ["NURBS"]);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, threeSplineTypes);
const invalidSplineCommand = new TextEncoder().encode(JSON.stringify({ ...threeSplineCommand, splineOffsets: [1, 4, 7, 11] }));
const invalidSplinePointer = engine._malloc(invalidSplineCommand.byteLength);
try {
engine.HEAPU8.set(invalidSplineCommand, invalidSplinePointer);
assert.notEqual(engine._web_engine_apply_command(handle, invalidSplinePointer, invalidSplineCommand.byteLength), 0);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), /NON_MESH_PROPERTY_INVALID/);
}
finally { engine._free(invalidSplinePointer); }
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineOffsets, threeSplineOffsets);
const twoSplineHandlePoints = threeSplineHandles.slice();
twoSplineHandlePoints[1] -= 0.2;
command(engine, handle, {
type: "setCurveSplines", dataId: createdCurve.id, splineTypes: ["BEZIER", "NURBS"],
splineOffsets: [0, 3, 7], ordersU: [0, 4], controlPoints: threeSplinePoints.slice(9),
pointWeights: threeSplineWeights.slice(3), cyclicU: [false, true],
handleTypes: [0, 0, 0, 0, 0, 0], handlePoints: twoSplineHandlePoints,
});
transactedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id);
assert.deepEqual(transactedCurve.splineTypes, ["BEZIER", "NURBS"]);
assert.deepEqual(transactedCurve.splineOffsets, [0, 3, 7]);
assert.deepEqual(transactedCurve.cyclicU, [false, true]);
close(transactedCurve.handlePoints[1], twoSplineHandlePoints[1], "bulk handle transaction");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, threeSplineTypes);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, ["BEZIER", "NURBS"]);
const invalidSurfaceCommand = new TextEncoder().encode(JSON.stringify({ type: "setCurveTopology", dataId: surface.id, splineTypes: ["POLY"], cyclicU: [false], cyclicV: [false] }));
const invalidSurfacePointer = engine._malloc(invalidSurfaceCommand.byteLength);
try {
engine.HEAPU8.set(invalidSurfaceCommand, invalidSurfacePointer);
assert.notEqual(engine._web_engine_apply_command(handle, invalidSurfacePointer, invalidSurfaceCommand.byteLength), 0);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), /NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED/);
}
finally { engine._free(invalidSurfacePointer); }
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id)?.splineTypes, ["NURBS"]);
const curvePoints = curve.controlPoints.slice();
curvePoints[0] += 0.25;
command(engine, handle, { type: "setCurveControlPoints", dataId: curveId, controlPoints: curvePoints, splineOffsets: curve.splineOffsets, resolution: (curve.resolution ?? 12) + 1 });
const surfaceDimensions = [{ u: 5, v: 4, orderU: 4, orderV: 4 }];
const surfacePoints = [];
const surfaceWeights = [];
for (let v = 0; v < 4; v++) for (let u = 0; u < 5; u++) {
surfacePoints.push(-1 + u * 0.5, v / 3, u === 2 && (v === 1 || v === 2) ? 0.45 : 0);
surfaceWeights.push(u === 2 ? 0.85 : 1);
}
command(engine, handle, { type: "setSurfaceTopology", dataId: surface.id, splineDimensions: surfaceDimensions, controlPoints: surfacePoints, pointWeights: surfaceWeights, cyclicU: [false], cyclicV: [false] });
let changedSurface = snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id);
assert.deepEqual(changedSurface.splineDimensions, surfaceDimensions);
assert.equal(changedSurface.pointCount, 20);
close(changedSurface.pointWeights[2], 0.85, "surface rational weight");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id).splineDimensions, surface.splineDimensions);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
changedSurface = snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id);
assert.deepEqual(changedSurface.splineDimensions, surfaceDimensions);
const curveCyclic = curve.cyclicU.map((value, index) => index === 0 ? !value : value);
const curveHandlePoints = curve.handlePoints.slice();
curveHandlePoints[0] += 0.25;
curveHandlePoints[15] -= 0.2;
command(engine, handle, { type: "setCurveTopology", dataId: curveId, splineTypes: curve.splineTypes, cyclicU: curveCyclic, cyclicV: curve.cyclicV, handleTypes: curve.handleTypes, handlePoints: curveHandlePoints });
const preciseHandlePosition = curveHandlePoints.slice(12, 15);
preciseHandlePosition[2] += 0.125;
command(engine, handle, { type: "setCurveHandle", dataId: curveId, pointIndex: curve.handlePointIndices[2], side: "LEFT", position: preciseHandlePosition });
close(snapshot(engine, handle).nonMeshData.find((data) => data.id === curveId).handlePoints[14], preciseHandlePosition[2], "precise handle command");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
close(snapshot(engine, handle).nonMeshData.find((data) => data.id === curveId).handlePoints[14], curveHandlePoints[14], "precise handle undo");
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
close(snapshot(engine, handle).nonMeshData.find((data) => data.id === curveId).handlePoints[14], preciseHandlePosition[2], "precise handle redo");
curveHandlePoints.splice(12, 3, ...preciseHandlePosition);
const metaballElements = metaball.elements.map((element, index) => ({ ...element, radius: index === 0 ? element.radius + 0.1 : element.radius }));
command(engine, handle, { type: "setMetaballElements", dataId: metaball.id, elements: metaballElements });
command(engine, handle, { type: "setFontBody", dataId: font.id, body: "N-015 roundtrip" });
command(engine, handle, { type: "setFontProperties", dataId: font.id, properties: { alignment: "RIGHT", alignY: "CENTER", extrude: 0.08, bevelDepth: 0.015, bevelResolution: 3, offset: 0.01, spacing: 1.2, lineDistance: 1.4, wordSpace: 1.1, shear: 0.2, fontSize: 1.25, offsetX: 0.15, offsetY: -0.1 } });
const fontLinks = { regular: font.fontLinks.bold, bold: font.fontLinks.regular, italic: font.fontLinks.boldItalic, boldItalic: font.fontLinks.italic };
command(engine, handle, { type: "setFontLinks", dataId: font.id, links: fontLinks });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id).fontLinks, font.fontLinks);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
const fontCharacters = Array.from("N-015 roundtrip", (_, index) => ({ kern: index === 1 ? 0.25 : 0, materialIndex: index % 2, styleFlags: index === 0 ? 0x3 : index === 2 ? 0x14 : 0 }));
const fontTextBoxes = [
{ x: 0, y: 0, width: 3.5, height: 1.25 },
{ x: 4, y: -0.5, width: 2.25, height: 1.5 },
];
command(engine, handle, { type: "setFontAdvanced", dataId: font.id, characters: fontCharacters, textBoxes: fontTextBoxes, activeTextBox: 1 });
const changed = snapshot(engine, handle);
close(changed.nonMeshData.find((data) => data.id === curveId).controlPoints[0], curvePoints[0], "curve point");
assert.deepEqual(changed.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions);
close(changed.nonMeshData.find((data) => data.id === surface.id).controlPoints[8], surfacePoints[8], "surface topology point");
close(changed.nonMeshData.find((data) => data.id === metaball.id).elements[0].radius, metaballElements[0].radius, "metaball radius");
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).text, "N-015 roundtrip");
assert.deepEqual(changed.nonMeshData.find((data) => data.id === curveId).cyclicU, curveCyclic);
close(changed.nonMeshData.find((data) => data.id === curveId).handlePoints[0], curveHandlePoints[0], "curve left handle");
close(changed.nonMeshData.find((data) => data.id === curveId).handlePoints[15], curveHandlePoints[15], "curve right handle");
assert.equal(changed.nonMeshData.find((data) => data.id === curveId).handleTypes[4], 0);
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.alignment, "RIGHT");
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.bevelResolution, 3);
assert.deepEqual(changed.nonMeshData.find((data) => data.id === font.id).fontCharacters, fontCharacters);
assert.deepEqual(changed.nonMeshData.find((data) => data.id === font.id).fontTextBoxes, fontTextBoxes);
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).activeFontTextBox, 1);
assert.deepEqual(changed.nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
close(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.spacing, 1.2, "font spacing");
close(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.fontSize, 1.25, "font size");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const undoneProperties = snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id);
assert.equal(undoneProperties.text, "N-015 roundtrip");
assert.equal(undoneProperties.fontProperties.alignment, "RIGHT");
assert.notDeepEqual(undoneProperties.fontTextBoxes, fontTextBoxes);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const redoneProperties = snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id);
assert.equal(redoneProperties.text, "N-015 roundtrip");
assert.equal(redoneProperties.fontProperties.alignment, "RIGHT");
assert.deepEqual(redoneProperties.fontCharacters, fontCharacters);
assert.deepEqual(redoneProperties.fontTextBoxes, fontTextBoxes);
const evaluated = depsgraph(engine, handle);
const evaluatedTypes = new Set((evaluated.nonMeshGeometries ?? []).filter((geometry) => geometry.status === "EVALUATED").map((geometry) => geometry.sourceType));
for (const type of ["CURVE", "SURFACE", "FONT", "METABALL"]) assert.ok(evaluatedTypes.has(type), `missing evaluated ${type}`);
const evaluatedSurface = evaluated.nonMeshGeometries.find((geometry) => geometry.sourceDataId === surface.id && geometry.status === "EVALUATED");
assert.ok(evaluatedSurface?.vertexCount > 256 && evaluatedSurface?.triangleCount > 450, "5x4 Surface topology did not change evaluated geometry");
for (const geometry of evaluated.nonMeshGeometries ?? []) if (geometry.status === "EVALUATED") {
assert.ok(geometry.vertexCount > 0, `${geometry.sourceType} has no evaluated vertices`);
assert.equal(geometry.positions.length, geometry.vertexCount * 3);
assert.equal(geometry.indices.length, geometry.triangleCount * 3);
}
const saved = save(engine, handle);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
const roundtripped = snapshot(engine, reopened);
close(roundtripped.nonMeshData.find((data) => data.id === curveId).controlPoints[0], curvePoints[0], "reopened curve point");
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions);
assert.equal(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointCount, 20);
close(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointWeights[2], 0.85, "reopened surface weight");
close(roundtripped.nonMeshData.find((data) => data.id === metaball.id).elements[0].radius, metaballElements[0].radius, "reopened metaball radius");
assert.equal(roundtripped.nonMeshData.find((data) => data.id === font.id).text, "N-015 roundtrip");
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === curveId).cyclicU, curveCyclic);
close(roundtripped.nonMeshData.find((data) => data.id === curveId).handlePoints[0], curveHandlePoints[0], "reopened curve left handle");
close(roundtripped.nonMeshData.find((data) => data.id === curveId).handlePoints[15], curveHandlePoints[15], "reopened curve right handle");
assert.equal(roundtripped.nonMeshData.find((data) => data.id === curveId).handleTypes[4], 0);
assert.equal(roundtripped.nonMeshData.find((data) => data.id === font.id).fontProperties.alignment, "RIGHT");
close(roundtripped.nonMeshData.find((data) => data.id === font.id).fontProperties.spacing, 1.2, "reopened font spacing");
close(roundtripped.nonMeshData.find((data) => data.id === font.id).fontProperties.offsetY, -0.1, "reopened font offset y");
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === font.id).fontCharacters, fontCharacters);
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === font.id).fontTextBoxes, fontTextBoxes);
assert.equal(roundtripped.nonMeshData.find((data) => data.id === font.id).activeFontTextBox, 1);
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
assert.equal(roundtripped.nonMeshData.find((data) => data.name === "CreatedWebCurve")?.controlPoints?.length, 21);
const createdAfterReopen = roundtripped.nonMeshData.find((data) => data.name === "CreatedWebCurve");
assert.deepEqual(createdAfterReopen.splineTypes, ["BEZIER", "NURBS"]);
assert.deepEqual(createdAfterReopen.splineOffsets, [0, 3, 7]);
assert.deepEqual(createdAfterReopen.cyclicU, [false, true]);
close(createdAfterReopen.handlePoints[1], twoSplineHandlePoints[1], "reopened bulk handle transaction");
command(engine, reopened, { type: "deleteNonMeshData", dataId: createdAfterReopen.id });
assert.equal(snapshot(engine, reopened).nonMeshData.some((data) => data.id === createdAfterReopen.id), false);
engine._web_engine_destroy(reopened);
console.log("nonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links=passed undo-redo=passed save-reopen=passed");

View File

@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(new URL("../../", import.meta.url).pathname);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "web-nonmesh-usd-"));
const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm"));
const fixture = fs.readFileSync(path.join(root, "tests/files/web/nonmesh_scene.blend"));
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/N-015/nonmesh-desktop-geometry.json"), "utf8"));
const expected = Object.fromEntries(golden.geometries.map((geometry) => [geometry.object, geometry]));
const binaryTypes = new Set(["POINT_CLOUD", "CURVES", "HAIR"]);
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];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function transpile(sourceName, outputName) {
const source = fs.readFileSync(path.join(root, `web/protocol/${sourceName}`), "utf8");
const result = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
fileName: sourceName,
});
fs.writeFileSync(path.join(temporary, outputName), result.outputText);
}
function close(actual, expectedValue, label) {
assert.ok(Math.abs(actual - expectedValue) <= 1e-5, `${label}: expected ${expectedValue}, got ${actual}`);
}
try {
transpile("nonmesh-binary.ts", "nonmesh-binary.js");
transpile("nonmesh-export.ts", "nonmesh-export.js");
transpile("usd-export.ts", "usd-export.cjs");
const { exportUSD } = await import(pathToFileURL(path.join(temporary, "usd-export.cjs")).href);
const { chunkNonMeshGeometry } = await import(pathToFileURL(path.join(temporary, "nonmesh-binary.js")).href);
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
try {
const input = engine._malloc(fixture.byteLength);
engine.HEAPU8.set(fixture, input);
try {
assert.equal(engine._web_engine_open_blend(handle, input, fixture.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(input);
}
const sourceSnapshot = output(engine, handle, engine._web_engine_get_scene_snapshot);
const depsgraph = output(engine, handle, engine._web_engine_evaluate_depsgraph);
const selected = new Set(["CURVE", "SURFACE", "FONT", "METABALL", ...binaryTypes]);
const selectedData = (sourceSnapshot.nonMeshData ?? []).filter((data) => selected.has(data.type));
const selectedDataIds = new Set(selectedData.map((data) => data.id));
const selectedGeometry = (depsgraph.nonMeshGeometries ?? []).filter((geometry) => selectedDataIds.has(geometry.sourceDataId) && !binaryTypes.has(geometry.sourceType));
const binaryChunks = [];
const exportData = [];
for (const data of selectedData) {
if (!binaryTypes.has(data.type)) {
exportData.push(data);
continue;
}
assert.ok(data.controlPoints?.length === data.pointCount * 3, `${data.name} source points missing`);
binaryChunks.push(...await chunkNonMeshGeometry({
dataId: data.id,
positions: Float32Array.from(data.controlPoints),
radii: data.radii ? Float32Array.from(data.radii) : undefined,
curveOffsets: data.splineOffsets ? Uint32Array.from(data.splineOffsets) : undefined,
attributes: (data.attributeValues ?? []).map((attribute) => ({ ...attribute,
values: attribute.dataType === "INT" ? Int32Array.from(attribute.values) : attribute.dataType === "BOOL" || attribute.dataType === "BYTE_COLOR" ? Uint8Array.from(attribute.values) : Float32Array.from(attribute.values) })),
}));
const { controlPoints, radii, splineOffsets, attributeValues, ...metadata } = data;
exportData.push({ ...metadata, geometryStatus: "binary", geometryBufferId: data.id });
}
const selectedObjectIds = new Set(sourceSnapshot.nodes.filter((node) => selectedDataIds.has(node.dataId)).map((node) => node.id));
assert.equal(selectedData.length, 7);
assert.equal(selectedGeometry.length, 4);
const snapshot = {
...sourceSnapshot,
nodes: sourceSnapshot.nodes.filter((node) => selectedObjectIds.has(node.id)),
meshes: [],
nonMeshData: exportData,
materials: [],
images: [],
animations: [],
armatures: [],
};
const selectedDepsgraph = { ...depsgraph, nonMeshGeometries: selectedGeometry };
const exported = exportUSD(snapshot, [], selectedDepsgraph, binaryChunks);
assert.equal(exported.report.canExport, true, JSON.stringify(exported.report));
assert.ok(exported.usda && exported.usda.byteLength > 1000, "non-mesh USDA is unexpectedly small");
const source = new TextDecoder().decode(exported.usda);
const legacyCurves = Object.values(expected).filter((geometry) => geometry.triangleCount === 0).length;
const expectedCurves = legacyCurves + 2;
const expectedMeshes = Object.keys(expected).length - legacyCurves;
const expectedPoints = 1;
assert.equal((source.match(/def BasisCurves /g) ?? []).length, expectedCurves);
assert.equal((source.match(/def Mesh /g) ?? []).length, expectedMeshes);
assert.equal((source.match(/def Points /g) ?? []).length, expectedPoints);
for (const attribute of ["web_weight", "web_color", "web_density"]) assert.match(source, new RegExp(`primvars:${attribute}`));
assert.match(source, /float\[\] widths =/);
const blocked = exportUSD({ ...snapshot, nonMeshData: [] }, [], undefined);
assert.equal(blocked.report.canExport, false);
assert.equal(blocked.usda, undefined);
assert.deepEqual(blocked.report.errors?.map((error) => error.code), ["USD_EMPTY_SCENE"]);
const usdPath = path.join(temporary, "nonmesh.usda");
const reportPath = path.join(temporary, "blender-report.json");
fs.writeFileSync(usdPath, exported.usda);
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const probe = spawnSync(blender, ["-b", "--factory-startup", "--python-expr", "import bpy; print('USD_RUNTIME=' + ('READY' if bpy.app.build_options.usd else 'MISSING'))"], { cwd: root, encoding: "utf8", maxBuffer: 1024 * 1024 });
assert.equal(probe.status, 0, `${probe.stdout}\n${probe.stderr}`);
if (!probe.stdout.includes("USD_RUNTIME=READY")) {
console.log(`nonmesh-usd-serialization-ok bytes=${exported.usda.byteLength} objects=7 points=${expectedPoints} basisCurves=${expectedCurves} meshes=${expectedMeshes} desktop=BLOCKED code=USD_RUNTIME_MISSING`);
if (process.env.USD_DESKTOP_REQUIRED !== "0") process.exitCode = 2;
}
else {
const imported = spawnSync(blender, ["-b", "--python", path.join(root, "tools/web/blender-check-nonmesh-usd-roundtrip.py"), "--", usdPath, reportPath], { cwd: root, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
assert.equal(imported.status, 0, `${imported.stdout}\n${imported.stderr}`);
assert.ok(fs.existsSync(reportPath), `Blender USD checker produced no report:\n${imported.stdout}\n${imported.stderr}`);
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
const expectedNames = [...Object.keys(expected), "WebPointCloudObject", "WebCurvesObject", "WebHairObject"].sort();
assert.deepEqual(Object.keys(report).sort(), expectedNames);
for (const [name, record] of Object.entries(expected)) {
const actual = report[name];
assert.equal(actual.vertexCount, record.vertexCount, `${name} vertex count`);
if (record.triangleCount === 0) assert.equal(actual.edgeCount, record.edgeCount, `${name} edge count`);
else assert.equal(actual.triangleCount, record.triangleCount, `${name} triangle count`);
for (let axis = 0; axis < 3; axis++) {
close(actual.boundsMin[axis], record.boundsMin[axis], `${name} boundsMin[${axis}]`);
close(actual.boundsMax[axis], record.boundsMax[axis], `${name} boundsMax[${axis}]`);
}
}
for (const data of selectedData.filter((item) => binaryTypes.has(item.type))) {
const name = sourceSnapshot.nodes.find((node) => node.dataId === data.id)?.name;
const actual = report[name];
assert.equal(actual.vertexCount, data.pointCount, `${name} vertex count`);
const expectedEdges = data.splineOffsets ? data.pointCount - (data.splineOffsets.length - 1) : 0;
assert.equal(actual.edgeCount, expectedEdges, `${name} edge count`);
}
console.log(`nonmesh-usd-blender-roundtrip-ok bytes=${exported.usda.byteLength} objects=7 points=${expectedPoints} basisCurves=${expectedCurves} meshes=${expectedMeshes}`);
}
}
finally {
engine._web_engine_destroy(handle);
}
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import { execFileSync } from "node:child_process";
const creator = new URL("./create-offline-release.mjs", import.meta.url);
const archive = new URL("../../release/blender-web-offline.tar.gz", import.meta.url);
const sourceArchive = new URL("../../release/blender-web-corresponding-source.tar.gz", import.meta.url);
const digest = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
execFileSync(process.execPath, [creator.pathname], { stdio: "inherit" });
const first = [digest(archive), digest(sourceArchive)];
execFileSync(process.execPath, [creator.pathname], { stdio: "inherit" });
const second = [digest(archive), digest(sourceArchive)];
assert.deepEqual(second, first, "offline binary/source archives are not reproducible");
process.stdout.write(`offline-reproducibility-ok binary=${second[0]} source=${second[1]}\n`);

View File

@@ -0,0 +1,166 @@
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 colorFixture = fs.readFileSync(new URL("tests/files/web/attribute_scene.blend", root));
const weightFixture = fs.readFileSync(new URL("tests/files/web/rigged_shape_scene.blend", root));
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, 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 snapshot(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
}
function apply(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 reject(engine, handle, payload, code) {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.notEqual(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0,
`${payload.type} unexpectedly succeeded`);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), new RegExp(code));
}
finally { engine._free(pointer); }
}
function close(actual, expected, label) {
assert.ok(Math.abs(actual - expected) <= 1e-6, `${label}: ${actual} != ${expected}`);
}
function assertUniformColor(mesh, expected) {
assert.equal(mesh.colors.length, mesh.cornerCount * 4);
for (let offset = 0; offset < mesh.colors.length; offset += 4) {
for (let channel = 0; channel < 4; channel++) {
close(mesh.colors[offset + channel], expected[channel], `color[${offset / 4}][${channel}]`);
}
}
}
function vertexWeight(mesh, vertex, groupName) {
const group = mesh.skinWeights?.boneNames.indexOf(groupName) ?? -1;
if (group < 0) return 0;
for (let slot = 0; slot < 4; slot++) {
const offset = vertex * 4 + slot;
if (mesh.skinWeights.indices[offset] === group) return mesh.skinWeights.weights[offset];
}
return 0;
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const colorHandle = engine._web_engine_create();
open(engine, colorHandle, colorFixture);
const originalColorMesh = snapshot(engine, colorHandle).meshes.find((mesh) => mesh.id === "mesh:AttributeMesh");
assert.equal(originalColorMesh.cornerCount, 7);
const paintColor = [0.125, 0.375, 0.625, 0.875];
apply(engine, colorHandle, {
type: "setVertexColors",
meshId: originalColorMesh.id,
attributeName: "WebPaintColor",
domain: "CORNER",
indices: Array.from({ length: originalColorMesh.cornerCount }, (_, index) => index),
colors: Array.from({ length: originalColorMesh.cornerCount }, () => paintColor).flat(),
});
assertUniformColor(snapshot(engine, colorHandle).meshes.find((mesh) => mesh.id === originalColorMesh.id), paintColor);
assert.equal(engine._web_engine_undo(colorHandle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.notDeepEqual(snapshot(engine, colorHandle).meshes.find((mesh) => mesh.id === originalColorMesh.id).colors,
Array.from({ length: originalColorMesh.cornerCount }, () => paintColor).flat());
assert.equal(engine._web_engine_redo(colorHandle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assertUniformColor(snapshot(engine, colorHandle).meshes.find((mesh) => mesh.id === originalColorMesh.id), paintColor);
const savedColors = output(engine, colorHandle, engine._web_engine_save_blend, true);
engine._web_engine_destroy(colorHandle);
const reopenedColors = engine._web_engine_create();
open(engine, reopenedColors, savedColors);
assertUniformColor(snapshot(engine, reopenedColors).meshes.find((mesh) => mesh.id === originalColorMesh.id), paintColor);
engine._web_engine_destroy(reopenedColors);
const weightHandle = engine._web_engine_create();
open(engine, weightHandle, weightFixture);
const meshId = "mesh:RiggedShapeMesh";
const objectId = "object:RiggedShapeObject";
apply(engine, weightHandle, {
type: "setVertexWeights",
objectId,
vertexGroup: "WebPaintGroup",
indices: [0, 1],
values: [0.75, 0.25],
normalize: false,
});
let weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId);
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "vertex 0 paint weight");
close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.25, "vertex 1 paint weight");
apply(engine, weightHandle, {
type: "setVertexWeights",
objectId,
vertexGroup: "WebPaintGroup",
indices: [2],
values: [0.5],
normalize: true,
});
weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId);
const normalizedSum = weightedMesh.skinWeights.weights.slice(8, 12).reduce((sum, weight) => sum + weight, 0);
close(normalizedSum, 1, "normalized vertex 2 weight sum");
assert.equal(engine._web_engine_undo(weightHandle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
close(vertexWeight(snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId), 2, "WebPaintGroup"), 0,
"undone vertex 2 paint weight");
assert.equal(engine._web_engine_redo(weightHandle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
close(vertexWeight(snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId), 2, "WebPaintGroup"), 0.5 / 1.75,
"redone normalized vertex 2 paint weight");
reject(engine, weightHandle, {
type: "setVertexWeights",
objectId,
vertexGroup: "WebPaintGroup",
indices: [3],
values: [0.5],
mirror: true,
}, "CAPABILITY_MISSING");
const savedWeights = output(engine, weightHandle, engine._web_engine_save_blend, true);
engine._web_engine_destroy(weightHandle);
const reopenedWeights = engine._web_engine_create();
open(engine, reopenedWeights, savedWeights);
weightedMesh = snapshot(engine, reopenedWeights).meshes.find((mesh) => mesh.id === meshId);
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "reopened vertex 0 paint weight");
close(vertexWeight(weightedMesh, 2, "WebPaintGroup"), 0.5 / 1.75, "reopened normalized vertex 2 paint weight");
engine._web_engine_destroy(reopenedWeights);
process.stdout.write("paint-roundtrip-ok vertex-color=passed vertex-weight-normalize=passed mirror-gate=passed undo-redo=passed save-reopen=passed\n");

View File

@@ -0,0 +1,113 @@
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 golden = JSON.parse(fs.readFileSync(new URL("tests/golden/W-080/pose-constraint-depsgraph.json", root), "utf8"));
const blend = fs.readFileSync(new URL(`tests/files/web/${golden.fixture}`, root));
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const tolerance = golden.tolerance;
function readJson(engine, dataOut, lengthOut) {
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
assert.ok(pointer && length, "native response is empty");
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
function command(engine, handle, payload) {
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const pointer = engine._malloc(encoded.byteLength);
try {
engine.HEAPU8.set(encoded, pointer);
assert.equal(engine._web_engine_apply_command(handle, pointer, encoded.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(pointer);
}
}
function evaluate(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_evaluate_depsgraph(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
return readJson(engine, dataOut, lengthOut);
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
assert.ok(handle > 0, "WebEngine handle creation failed");
try {
const input = engine._malloc(blend.byteLength);
try {
engine.HEAPU8.set(blend, input);
assert.equal(engine._web_engine_open_blend(handle, input, blend.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(input);
}
const expectedByFrame = new Map(golden.samples.map((sample) => [sample.frame, sample]));
const snapshotDataOut = engine._malloc(4);
const snapshotLengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, snapshotDataOut, snapshotLengthOut), 0);
const snapshot = readJson(engine, snapshotDataOut, snapshotLengthOut);
const mesh = snapshot.meshes.find((candidate) => candidate.id === `mesh:${golden.mesh}`);
assert.ok(mesh?.skinWeights, "pose fixture skin weights are missing");
assert.equal(mesh.skinWeights.boneNames.length, 4, "pose fixture must cover four bones");
const bindErrors = mesh.skinWeights.bindMatrix.map((value, index) => value - golden.meshBindMatrix[index]);
assert.ok(Math.max(...bindErrors.map(Math.abs)) <= tolerance.maxMatrixError, "non-unit bind matrix mismatch");
assert.ok(bindErrors.some((value, index) => Math.abs(golden.meshBindMatrix[index] - (index % 5 === 0 ? 1 : 0)) > 1e-3), "fixture bind matrix must be non-unit");
}
finally {
engine._free(snapshotDataOut);
engine._free(snapshotLengthOut);
}
for (const frame of golden.frames) {
command(engine, handle, { type: "setFrame", frame });
const report = evaluate(engine, handle);
assert.equal(report.status, "EVALUATED");
assert.equal(report.frame, frame);
const expected = expectedByFrame.get(frame);
assert.ok(expected, `missing desktop sample for frame ${frame}`);
const armature = report.armatures?.find((candidate) => candidate.id === `armature:${golden.armature}`);
assert.ok(armature, `armature report missing at frame ${frame}`);
assert.equal(armature.objectId, `object:${golden.armatureObject}`);
for (const [boneName, expectedMatrix] of Object.entries(expected.bones)) {
const bone = armature.bones.find((candidate) => candidate.name === boneName);
assert.ok(bone, `${boneName} missing at frame ${frame}`);
const matrixErrors = bone.poseMatrix.map((value, index) => value - expectedMatrix[index]);
const maxMatrixError = Math.max(0, ...matrixErrors.map((value) => Math.abs(value)));
assert.ok(maxMatrixError <= tolerance.maxMatrixError, `${boneName} frame ${frame} matrix error ${maxMatrixError}`);
const metadata = golden.constraintMetadata[boneName] ?? [];
assert.equal(bone.constraints.length, metadata.length, `${boneName} constraint count at frame ${frame}`);
for (const [index, expectedConstraint] of metadata.entries()) {
const actual = bone.constraints[index];
assert.equal(actual.name, expectedConstraint.name);
assert.equal(actual.typeCode, expectedConstraint.typeCode);
assert.ok(Math.abs(actual.influence - expectedConstraint.influence) <= tolerance.maxMatrixError);
if (expectedConstraint.targetObject) assert.equal(actual.targetObjectId, `object:${expectedConstraint.targetObject}`);
if (expectedConstraint.poleTargetObject) assert.equal(actual.poleTargetObjectId, `object:${expectedConstraint.poleTargetObject}`);
}
}
const mesh = report.meshes.find((candidate) => candidate.sourceMeshId === `mesh:${golden.mesh}`);
assert.ok(mesh, `mesh report missing at frame ${frame}`);
const positionErrors = mesh.positions.map((value, index) => value - expected.positions[index]);
const maxPositionError = Math.max(0, ...positionErrors.map((value) => Math.abs(value)));
const rmsPositionError = Math.sqrt(positionErrors.reduce((sum, value) => sum + value * value, 0) / positionErrors.length);
assert.ok(maxPositionError <= tolerance.maxPositionError, `frame ${frame} max position error ${maxPositionError}`);
assert.ok(rmsPositionError <= tolerance.rmsPositionError, `frame ${frame} RMS position error ${rmsPositionError}`);
}
}
finally {
engine._web_engine_destroy(handle);
}
process.stdout.write(`pose-constraint-goldens-ok fixture=${golden.fixture} frames=${golden.frames.join(",")}\n`);

View File

@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const dist = path.join(root, "web/dist");
const notices = JSON.parse(fs.readFileSync(path.join(root, "docs/web/third-party-notices.json"), "utf8"));
const packageLock = JSON.parse(fs.readFileSync(path.join(root, "web/package-lock.json"), "utf8"));
for (const dependency of ["react", "react-dom", "three", "vite", "typescript", "@playwright/test"]) {
const normalize = (value) => value.toLowerCase().replace("@playwright/test", "playwright").replace(/\.js$/, "").replace(/[^a-z0-9]/g, "");
const covered = notices.packages.some((entry) => normalize(entry.name) === normalize(dependency));
assert.ok(covered, `third-party notices do not cover ${dependency}`);
}
assert.ok(packageLock.lockfileVersion >= 3, "npm lockfile must use an integrity-bearing format");
assert.ok(fs.existsSync(path.join(root, "blender-5.2.0/COPYING")), "Blender GPL text is missing");
assert.ok(fs.existsSync(path.join(root, "web/app/src/vendor/three/LICENSE")), "Three.js license is missing");
assert.ok(fs.existsSync(path.join(dist, "index.html")), "offline dist is missing; run npm build first");
const files = [];
function walk(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) walk(absolute);
else files.push(absolute);
}
}
walk(dist);
assert.ok(files.some((file) => file.endsWith("web_engine.wasm")), "offline package omits WebEngine WASM");
for (const file of files.filter((candidate) => /\.(html|js|css|json)$/.test(candidate))) {
const text = fs.readFileSync(file, "utf8");
assert.doesNotMatch(text, /(?:src|href|from)\s*[=:]\s*["']https?:\/\//i, `remote runtime dependency in ${path.relative(dist, file)}`);
}
const manifest = files.map((file) => ({
path: path.relative(dist, file).replaceAll(path.sep, "/"),
bytes: fs.statSync(file).size,
sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
})).sort((left, right) => left.path.localeCompare(right.path));
assert.ok(manifest.every((entry) => entry.bytes > 0 && /^[a-f0-9]{64}$/.test(entry.sha256)));
process.stdout.write(`release-package-ok files=${manifest.length} bytes=${manifest.reduce((sum, entry) => sum + entry.bytes, 0)}\n`);

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { performance } from "node:perf_hooks";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
const limits = {
100000: Number(process.env.WEB_PERF_100K_MS ?? 120_000),
1000000: Number(process.env.WEB_PERF_1M_MS ?? 180_000),
heapBytes: Number(process.env.WEB_PERF_MAX_HEAP_BYTES ?? 1_610_612_736),
};
function grid(targetTriangles) {
const columns = Math.ceil(Math.sqrt(targetTriangles / 2));
const rows = Math.ceil(targetTriangles / (columns * 2));
const positions = new Float32Array((columns + 1) * (rows + 1) * 3);
for (let y = 0; y <= rows; y++) {
for (let x = 0; x <= columns; x++) {
const offset = (y * (columns + 1) + x) * 3;
positions[offset] = x / columns;
positions[offset + 1] = y / rows;
positions[offset + 2] = Math.sin(x * 0.03) * Math.cos(y * 0.03) * 0.01;
}
}
const triangleCount = Math.min(targetTriangles, columns * rows * 2);
const indices = new Uint32Array(triangleCount * 3);
for (let triangle = 0; triangle < triangleCount; triangle++) {
const cell = Math.floor(triangle / 2);
const x = cell % columns;
const y = Math.floor(cell / columns);
const a = y * (columns + 1) + x;
const offset = triangle * 3;
if (triangle % 2 === 0) indices.set([a, a + 1, a + columns + 2], offset);
else indices.set([a, a + columns + 2, a + columns + 1], offset);
}
return { positions, indices, triangleCount };
}
function alloc(engine, typed) {
const pointer = engine._malloc(typed.byteLength);
assert.ok(pointer > 0, `WASM allocation failed for ${typed.byteLength} bytes`);
engine.HEAPU8.set(new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength), pointer);
return pointer;
}
const results = [];
for (const target of [100_000, 1_000_000]) {
const engine = await factory({ wasmBinary });
const source = grid(target);
const pointers = [];
try {
const positions = alloc(engine, source.positions); pointers.push(positions);
const indices = alloc(engine, source.indices); pointers.push(indices);
const positionsOut = engine._malloc(source.positions.byteLength); pointers.push(positionsOut);
const indicesOut = engine._malloc(source.indices.byteLength); pointers.push(indicesOut);
const counts = Array.from({ length: 6 }, () => engine._malloc(4)); pointers.push(...counts);
const started = performance.now();
const ratio = target === 100_000 ? 0.9 : 1;
const result = engine._web_engine_decimate_apply(
positions, source.positions.length / 3, indices, source.triangleCount,
0, ratio, 1, 0, 0, 0, 0, -1, 1e-4,
0, 1, 0, 0, 0, 0,
positionsOut, source.positions.length, counts[0],
indicesOut, source.indices.length, counts[1],
0, 0, counts[2], 0, 0, counts[3], 0, 0, counts[4], counts[5],
);
const elapsedMs = performance.now() - started;
assert.equal(result, 0, `native decimate failed for ${target} triangles with code ${result}`);
const outputTriangles = engine.HEAPU32[counts[1] >>> 2] / 3;
assert.ok(outputTriangles > 0 && outputTriangles <= source.triangleCount, "invalid performance-gate output topology");
const heapBytes = engine.HEAPU8.byteLength;
assert.ok(elapsedMs <= limits[target], `${target} triangle gate exceeded ${limits[target]}ms: ${elapsedMs.toFixed(1)}ms`);
assert.ok(heapBytes <= limits.heapBytes, `${target} triangle gate exceeded WASM heap limit: ${heapBytes}`);
results.push({ target, ratio, outputTriangles, elapsedMs: Math.round(elapsedMs), heapBytes });
}
finally {
for (const pointer of pointers.reverse()) if (pointer) engine._free(pointer);
}
}
process.stdout.write(`release-performance-ok ${JSON.stringify(results)}\n`);

View File

@@ -0,0 +1,17 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const root = path.resolve(new URL("../..", import.meta.url).pathname);
const assets = {
"web/app/src/vendor/three/three.module.js": "bbf5ed13fe4373f5bd38b14ea8e62e9f157327da5638edc6d3863e08b167c9c7",
"web/app/src/vendor/three/three.core.js": "3718df126d69c125362a03340913204470d8c50238605150e57f808840fb7759",
"web/app/src/vendor/three/addons/controls/OrbitControls.js": "1e08bf297c9062aa5055a9d649d8a8458c3871aa31c7d600f535a06f567e7cad",
};
for (const [relativePath, expected] of Object.entries(assets)) {
const filePath = path.join(root, relativePath);
const actual = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (actual !== expected) throw new Error(`Three.js vendor hash mismatch: ${relativePath}`);
}
console.log(`three-vendor-ok assets=${Object.keys(assets).length}`);

View File

@@ -0,0 +1,17 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const probe = path.join(root, "tools/web/probe-topology-collapse.mjs");
const output = execFileSync(process.execPath, [probe], { cwd: root, encoding: "utf8" }).trim();
const results = JSON.parse(output.split("\n").at(-1) ?? "[]");
const expected = new Set(["mesh:OpenQuad", "mesh:OpenNgon", "mesh:NonManifold"]);
for (const result of results) {
if (!expected.delete(result.meshId) || result.result !== 0 || result.triangles <= 0) {
throw new Error(`Topology Collapse failed: ${output}`);
}
}
if (expected.size > 0) throw new Error(`Topology Collapse fixture missing meshes: ${[...expected].join(", ")}`);
console.log(`topology-collapse-ok ${JSON.stringify(results)}`);

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${repo_root}/tools/web/emscripten-env.sh"
build_dir="${repo_root}/build_web"
emcmake cmake -S "${repo_root}/web/engine" -B "${build_dir}" -G Ninja -DCMAKE_BUILD_TYPE=Release -DWEB_ENGINE_THREADS=OFF
cmake --build "${build_dir}" --target web_engine
test -s "${build_dir}/web_engine.js"
printf 'web-cmake-ok build=%s\n' "${build_dir}"

View File

@@ -0,0 +1,79 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(root, "release");
if (path.basename(releaseRoot) !== "release" || path.dirname(releaseRoot) !== root) throw new Error("unsafe release target");
const bundle = path.join(releaseRoot, "blender-web-offline");
fs.rmSync(bundle, { recursive: true, force: true });
fs.mkdirSync(bundle, { recursive: true });
fs.cpSync(path.join(root, "web/dist"), path.join(bundle, "app"), { recursive: true });
fs.copyFileSync(path.join(root, "blender-5.2.0/COPYING"), path.join(bundle, "COPYING"));
fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(bundle, "third-party-notices.json"));
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt"));
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt"));
fs.writeFileSync(path.join(bundle, "README.txt"), [
"Blender Web offline release",
"",
"Serve app/ from any local static HTTP server. The application has no runtime CDN dependency.",
"Opening index.html directly is unsupported because browsers restrict module Workers and WASM under file://.",
"See SOURCE_OFFER.txt and third-party-notices.json for licensing and corresponding source.",
"",
].join("\n"));
fs.writeFileSync(path.join(bundle, "SOURCE_OFFER.txt"), [
"Corresponding source for this GPL-2.0-or-later WebEngine distribution is provided in",
"blender-web-corresponding-source.tar.gz alongside this archive. It contains the Blender 5.2",
"source, Web application/protocol source, Web build scripts, dependency lockfile and build documentation.",
"The source archive is covered by SHA256SUMS.txt.",
"",
].join("\n"));
function sha256(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
function walk(directory, base = directory) {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...walk(absolute, base));
else files.push(path.relative(base, absolute).replaceAll(path.sep, "/"));
}
return files;
}
const manifest = walk(bundle).map((relative) => ({ path: relative, bytes: fs.statSync(path.join(bundle, relative)).size, sha256: sha256(path.join(bundle, relative)) }));
fs.writeFileSync(path.join(bundle, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, files: manifest }, null, 2)}\n`);
function deterministicArchive(output, cwd, entries) {
const tarPath = output.replace(/\.gz$/, "");
fs.rmSync(output, { force: true });
fs.rmSync(tarPath, { force: true });
execFileSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-cf", tarPath, "-C", cwd, ...entries]);
execFileSync("gzip", ["-n", "-f", tarPath]);
}
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
deterministicArchive(binaryArchive, releaseRoot, ["blender-web-offline"]);
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
deterministicArchive(sourceArchive, root, [
"blender-5.2.0",
"web/app",
"web/protocol",
"web/package.json",
"web/package-lock.json",
"web/tsconfig.json",
"web/eslint.config.js",
"web/playwright.config.ts",
"web/playwright.release.config.ts",
"tools/web",
"docs/web",
"docs/status",
"docs/PROJECT_STATUS_AND_NEXT_WORK.md",
"WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md",
]);
const sums = [binaryArchive, sourceArchive].map((file) => `${sha256(file)} ${path.basename(file)}`).join("\n") + "\n";
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), sums);
process.stdout.write(`offline-release-ok binary=${fs.statSync(binaryArchive).size} source=${fs.statSync(sourceArchive).size} sha256=${sha256(binaryArchive)}\n`);

View File

@@ -0,0 +1,92 @@
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const fixtures = process.argv.slice(2);
const fixtureNames = fixtures.length > 0 ? fixtures : [
"empty.blend",
"basic_scene.blend",
"rigged_shape_scene.blend",
];
function readFixture(name) {
if (name.includes("/")) return fs.readFileSync(name);
return fs.readFileSync(new URL(`../../tests/files/web/${name}`, import.meta.url));
}
function openBlend(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
return engine._web_engine_open_blend(handle, pointer, bytes.byteLength);
}
finally {
engine._free(pointer);
}
}
function evaluate(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
const result = engine._web_engine_evaluate_depsgraph(handle, dataOut, lengthOut);
const messagePointer = engine._web_engine_last_error_message();
const message = messagePointer ? engine.UTF8ToString(messagePointer) : "";
if (result !== 0) return { result, message };
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return { result, report: JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length))) };
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
if (engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut) !== 0) return null;
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
for (const fixtureName of fixtureNames) {
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} stage=create\n`);
try {
const engine = await factory({ wasmBinary: fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url)) });
const handle = engine._web_engine_create();
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} stage=open\n`);
const openResult = openBlend(engine, handle, readFixture(fixtureName));
if (openResult !== 0) {
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} open=${openResult} message=${engine.UTF8ToString(engine._web_engine_last_error_message())}\n`);
engine._web_engine_destroy(handle);
continue;
}
if (process.env.DIAGNOSE_SNAPSHOT === "1") {
const scene = snapshot(engine, handle);
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} snapshot=${JSON.stringify(scene?.meshes?.map((mesh) => ({ id: mesh.id, modifiers: mesh.modifierStack })) ?? [])}\n`);
}
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} stage=evaluate\n`);
const evaluation = evaluate(engine, handle);
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} result=${JSON.stringify(evaluation)}\n`);
engine._web_engine_destroy(handle);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stdout.write(`depsgraph-diagnostic fixture=${fixtureName} exception=${message}\n`);
if (error instanceof Error && error.stack) {
const wasmFrames = error.stack.split("\n")
.filter((line) => line.includes("wasm-function") || line.trimStart().startsWith("at "))
.filter((line) => !line.includes("web_engine.js:9"))
.slice(0, 16);
for (const frame of wasmFrames) process.stdout.write(`depsgraph-diagnostic stack=${frame.trim()}\n`);
}
}
}

28
tools/web/emscripten-env.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
WEB_EMSCRIPTEN_VERSION="3.1.69"
web_workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if command -v emcc >/dev/null 2>&1; then
detected_version="$(emcc --version | awk '{for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) {print $i; exit}}')"
if [[ "${detected_version}" != "${WEB_EMSCRIPTEN_VERSION}" ]]; then
echo "Expected Emscripten ${WEB_EMSCRIPTEN_VERSION}, found ${detected_version}" >&2
exit 1
fi
else
echo "emcc was not found; install or activate emsdk ${WEB_EMSCRIPTEN_VERSION}" >&2
exit 1
fi
export WEB_EMSCRIPTEN_VERSION
export EM_CONFIG="${web_workspace_root}/.emscripten-web"
export EM_CACHE="${web_workspace_root}/.emcache"
export WEB_EMSCRIPTEN_CFLAGS="-sENVIRONMENT=web,worker -sMODULARIZE=1 -sEXPORT_ES6=1"
export WEB_EMSCRIPTEN_LDFLAGS="-sALLOW_MEMORY_GROWTH=1 -sFILESYSTEM=1"
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
emcc --version | head -3
node --version
cmake --version | head -1
fi

View File

@@ -0,0 +1,28 @@
import sys
import bpy
def main(output_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("AnimationMesh")
mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
obj = bpy.data.objects.new("AnimatedObject", mesh)
bpy.context.collection.objects.link(obj)
obj.location = (0.0, 0.0, 0.0)
obj.keyframe_insert(data_path="location", frame=1, index=-1)
obj.location = (2.0, 3.0, 4.0)
obj.keyframe_insert(data_path="location", frame=10, index=-1)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 10
scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b --python generate-animation-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,92 @@
import sys
import bpy
def main(output_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [
(-1.0, -1.0, 0.0),
(1.0, -1.0, 0.0),
(1.0, 1.0, 0.0),
(-1.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
]
faces = [(0, 1, 2, 3), (0, 3, 4)]
mesh = bpy.data.meshes.new("AttributeMesh")
mesh.from_pydata(vertices, [], faces)
mesh.update()
uv_layer = mesh.uv_layers.new(name="UVMap")
uv_values = [
(0.0, 0.0),
(1.0, 0.0),
(1.0, 1.0),
(0.0, 1.0),
(0.0, 1.0),
(0.5, 0.0),
(1.0, 1.0),
]
for loop, uv in zip(uv_layer.data, uv_values):
loop.uv = uv
color_layer = mesh.color_attributes.new(name="CornerColor", type="FLOAT_COLOR", domain="CORNER")
colors = [
(1.0, 0.0, 0.0, 1.0),
(0.0, 1.0, 0.0, 1.0),
(0.0, 0.0, 1.0, 1.0),
(1.0, 1.0, 0.0, 1.0),
(1.0, 0.0, 1.0, 1.0),
(0.0, 1.0, 1.0, 1.0),
(1.0, 1.0, 1.0, 1.0),
]
for color, value in zip(color_layer.data, colors):
color.color = value
first = bpy.data.materials.new("AttributeRed")
first.diffuse_color = (1.0, 0.1, 0.1, 1.0)
first.use_nodes = True
principled = first.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = (0.8, 0.05, 0.02, 1.0)
principled.inputs["Metallic"].default_value = 0.25
principled.inputs["Roughness"].default_value = 0.35
if principled.inputs.get("IOR"):
principled.inputs["IOR"].default_value = 1.33
if principled.inputs.get("Alpha"):
principled.inputs["Alpha"].default_value = 0.8
if principled.inputs.get("Emission Color"):
principled.inputs["Emission Color"].default_value = (0.02, 0.01, 0.0, 1.0)
elif principled.inputs.get("Emission"):
principled.inputs["Emission"].default_value = (0.02, 0.01, 0.0, 1.0)
image = bpy.data.images.new("AttributeTexture", width=2, height=2)
image.source = "FILE"
image.filepath = "textures/attribute.png"
texture = first.node_tree.nodes.new("ShaderNodeTexImage")
texture.image = image
first.node_tree.links.new(texture.outputs["Color"], principled.inputs["Base Color"])
second = bpy.data.materials.new("AttributeBlue")
second.diffuse_color = (0.1, 0.1, 1.0, 1.0)
mesh.materials.append(first)
mesh.materials.append(second)
mesh.polygons[0].material_index = 0
mesh.polygons[1].material_index = 1
obj = bpy.data.objects.new("AttributeMeshObject", mesh)
bpy.context.collection.objects.link(obj)
obj.scale = (-1.0, 1.0, 1.0)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 24
scene.render.fps = 24
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b --python generate-attribute-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,47 @@
import json
import pathlib
import sys
import bpy
def main(blend_path: str, output_path: str) -> None:
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(blend_path).resolve()), load_ui=False)
obj = bpy.data.objects.get("RiggedShapeObject")
if obj is None:
raise RuntimeError("RiggedShapeObject is missing")
# This golden isolates Blender's shape-key plus armature deformation. The saved fixture still
# keeps Decimate enabled so modifier undo/redo tests exercise its normal default state.
if obj.modifiers.get("Preview Decimate") is not None:
obj.modifiers["Preview Decimate"].show_viewport = False
scene = bpy.context.scene
scene.frame_set(scene.frame_current)
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
positions = [coordinate for vertex in mesh.vertices for coordinate in vertex.co]
result = {
"schemaVersion": 1,
"fixture": "rigged_shape_scene.blend",
"evaluator": "Blender Depsgraph",
"blenderVersion": bpy.app.version_string,
"frame": scene.frame_current,
"object": obj.name,
"mesh": obj.data.name,
"vertexCount": len(mesh.vertices),
"polygonCount": len(mesh.polygons),
"positions": positions,
"tolerance": {"maxPositionError": 1e-5, "rmsPositionError": 1e-6},
"modifierStack": [{"name": modifier.name, "type": modifier.type, "showViewport": modifier.show_viewport} for modifier in obj.modifiers],
}
finally:
evaluated.to_mesh_clear()
pathlib.Path(output_path).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 2:
raise SystemExit("usage: blender -b --python generate-blender-deformation-golden.py -- input.blend output.json")
arguments = sys.argv[sys.argv.index("--") + 1:]
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,49 @@
import json
import pathlib
import sys
import bpy
def main(blend_path: str, output_path: str) -> None:
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(blend_path).resolve()), load_ui=False)
scene = bpy.context.scene
object_name = "AnimatedObject"
obj = bpy.data.objects.get(object_name)
if obj is None:
raise RuntimeError(f"{object_name} is missing")
frames = [1, 5, 10]
samples = []
for frame in frames:
scene.frame_set(frame)
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
samples.append({
"frame": frame,
"worldMatrix": [value for row in evaluated.matrix_world for value in row],
"positions": [coordinate for vertex in mesh.vertices for coordinate in vertex.co],
})
finally:
evaluated.to_mesh_clear()
result = {
"schemaVersion": 1,
"fixture": pathlib.Path(blend_path).name,
"evaluator": "Blender Depsgraph",
"blenderVersion": bpy.app.version_string,
"object": object_name,
"mesh": obj.data.name,
"frames": samples,
"tolerance": {"maxPositionError": 1e-5, "rmsPositionError": 1e-6, "maxMatrixError": 1e-5},
}
pathlib.Path(output_path).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 2:
raise SystemExit("usage: blender -b --python generate-frame-evaluation-golden.py -- input.blend output.json")
arguments = sys.argv[sys.argv.index("--") + 1:]
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,119 @@
import os
import pathlib
import sys
import bpy
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def save_generated_png(path, name, color):
image = bpy.data.images.new(name, width=8, height=8, alpha=True)
image.generated_color = color
image.filepath_raw = str(path)
image.file_format = "PNG"
image.save()
bpy.data.images.remove(image)
def material_object(name, image, x):
mesh = bpy.data.meshes.new(f"{name}Mesh")
mesh.from_pydata([(-1, -1, 0), (1, -1, 0), (0, 1, 0)], [], [(0, 1, 2)])
obj = bpy.data.objects.new(name, mesh)
obj.location.x = x
bpy.context.scene.collection.objects.link(obj)
material = bpy.data.materials.new(f"{name}Material")
material.use_nodes = True
texture = material.node_tree.nodes.new("ShaderNodeTexImage")
texture.image = image
material.node_tree.links.new(
texture.outputs["Color"], material.node_tree.nodes["Principled BSDF"].inputs["Base Color"])
mesh.materials.append(material)
def generate_library(path):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("LinkedLibraryTexture", width=16, height=8, alpha=True)
image.generated_color = (0.1, 0.7, 0.3, 1.0)
image.use_fake_user = True
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
def generate_matrix(output_dir, library_path, tile_paths):
bpy.ops.wm.read_factory_settings(use_empty=True)
generated = bpy.data.images.new("GeneratedTexture", width=12, height=6, alpha=True)
generated.generated_type = "COLOR_GRID"
generated.generated_color = (0.2, 0.4, 0.8, 1.0)
material_object("GeneratedImageObject", generated, -4.5)
udim = bpy.data.images.load(str(tile_paths[0]), check_existing=False)
udim.name = "UDIMTexture"
udim.source = "TILED"
udim.filepath = str(tile_paths[0]).replace("1001", "<UDIM>")
if udim.tiles.get(1002) is None:
udim.tiles.new(1002, label="Second Tile")
udim.pack()
material_object("UDIMImageObject", udim, -1.5)
missing = bpy.data.images.new("MissingExternalTexture", width=1, height=1)
missing.source = "FILE"
missing.filepath = "//missing/not-present.png"
material_object("MissingImageObject", missing, 1.5)
with bpy.data.libraries.load(str(library_path), link=True) as (data_from, data_to):
if "LinkedLibraryTexture" not in data_from.images:
raise RuntimeError("linked resource library image is missing")
data_to.images = ["LinkedLibraryTexture"]
linked = data_to.images[0]
material_object("LinkedImageObject", linked, 4.5)
output = output_dir / "image_resource_matrix.blend"
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=False)
print(
"image-resource-generated "
f"fixture={output} udim_tiles={[tile.number for tile in udim.tiles]} "
f"udim_packed={len(udim.packed_files)}")
def generate_corrupt(output_dir, png_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.load(str(png_path), check_existing=False)
image.name = "CorruptPackedTexture"
image.pack()
material_object("CorruptPackedImageObject", image, 0)
output = output_dir / "corrupt_packed_image.blend"
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=False)
data = output.read_bytes()
signature_offset = data.find(PNG_SIGNATURE)
if signature_offset < 0 or data.find(PNG_SIGNATURE, signature_offset + 1) >= 0:
raise RuntimeError("expected exactly one packed PNG signature in corrupt fixture")
output.write_bytes(data[:signature_offset] + b"BROKEN!!" + data[signature_offset + 8:])
print(f"image-resource-corrupted fixture={output} payloadOffset={signature_offset}")
def main(output_directory):
output_dir = pathlib.Path(output_directory).resolve()
support_dir = output_dir / "resources"
support_dir.mkdir(parents=True, exist_ok=True)
tile_1001 = support_dir / "udim_1001.png"
tile_1002 = support_dir / "udim_1002.png"
corrupt_png = support_dir / "corrupt_source.png"
bpy.ops.wm.read_factory_settings(use_empty=True)
save_generated_png(tile_1001, "Tile1001Source", (1.0, 0.1, 0.1, 1.0))
save_generated_png(tile_1002, "Tile1002Source", (0.1, 0.2, 1.0, 1.0))
save_generated_png(corrupt_png, "CorruptSource", (0.8, 0.2, 0.7, 1.0))
library_path = support_dir / "image_resource_library.blend"
generate_library(library_path)
generate_matrix(output_dir, library_path, (tile_1001, tile_1002))
generate_corrupt(output_dir, corrupt_png)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python generate-image-resource-fixtures.py -- output-directory")
main(arguments[0])

View File

@@ -0,0 +1,83 @@
import json
import os
import sys
import bpy
def mesh_object(name, x):
mesh = bpy.data.meshes.new(f"{name}Mesh")
mesh.from_pydata(
[(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1),
(-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)],
[],
[(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1),
(1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7)],
)
obj = bpy.data.objects.new(name, mesh)
obj.location.x = x
bpy.context.scene.collection.objects.link(obj)
return obj
def main(output_dir):
bpy.ops.wm.read_factory_settings(use_empty=True)
cycle_a = mesh_object("CycleA", -3)
cycle_b = mesh_object("CycleB", 3)
missing = mesh_object("MissingTarget", 0)
ordered = mesh_object("OrderedStack", 0)
ordered.location.y = 4
shrink_a = cycle_a.modifiers.new("Cycle A to B", "SHRINKWRAP")
shrink_a.target = cycle_b
shrink_a.show_viewport = True
shrink_a.show_render = False
shrink_a.show_in_editmode = True
shrink_a.show_on_cage = False
shrink_b = cycle_b.modifiers.new("Cycle B to A", "SHRINKWRAP")
shrink_b.target = cycle_a
shrink_b.show_viewport = True
shrink_b.show_render = True
shrink_b.show_in_editmode = False
shrink_b.show_on_cage = True
missing.modifiers.new("Missing Lattice Target", "LATTICE")
mirror = ordered.modifiers.new("Ordered Mirror", "MIRROR")
mirror.show_viewport = True
mirror.show_render = True
mirror.show_in_editmode = True
mirror.show_on_cage = True
bevel = ordered.modifiers.new("Ordered Bevel", "BEVEL")
bevel.width = 0.1
bevel.segments = 2
bevel.show_viewport = False
bevel.show_render = True
bevel.show_in_editmode = False
bevel.show_on_cage = False
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 10
output_path = os.path.join(output_dir, "modifier_dependency_cycle.blend")
bpy.ops.wm.save_as_mainfile(filepath=output_path)
golden = {
"taskId": "N-002",
"blenderVersion": bpy.app.version_string,
"fixture": os.path.basename(output_path),
"cycleObjects": ["CycleA", "CycleB"],
"missingTarget": {"object": "MissingTarget", "modifier": "Missing Lattice Target"},
"orderedStack": {"object": "OrderedStack", "modifiers": ["Ordered Mirror", "Ordered Bevel"]},
}
golden_dir = os.path.join(os.path.dirname(output_dir), "..", "golden", "N-002")
os.makedirs(golden_dir, exist_ok=True)
with open(os.path.join(golden_dir, "modifier_dependency_cycle.json"), "w", encoding="utf-8") as handle:
json.dump(golden, handle, indent=2, sort_keys=True)
handle.write("\n")
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
main(os.path.abspath(args[0]))

View File

@@ -0,0 +1,152 @@
import json
import math
import os
import sys
import bpy
def mesh_object(name, vertices, edges, faces, location):
mesh = bpy.data.meshes.new(f"{name}Mesh")
mesh.from_pydata(vertices, edges, faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
obj.location = location
bpy.context.scene.collection.objects.link(obj)
return obj
def modifier_type_code(modifier):
items = bpy.types.Modifier.bl_rna.properties["type"].enum_items
return items[modifier.type].value
def evaluated_mesh(obj, depsgraph):
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
mesh.calc_loop_triangles()
return {
"object": obj.name,
"vertexCount": len(mesh.vertices),
"triangleCount": len(mesh.loop_triangles),
"positions": [value for vertex in mesh.vertices for value in vertex.co],
"indices": [index for triangle in mesh.loop_triangles for index in triangle.vertices],
}
finally:
evaluated.to_mesh_clear()
def main(output_dir):
bpy.ops.wm.read_factory_settings(use_empty=True)
edge_split = mesh_object(
"ExtendedEdgeSplit",
[(0, 0, 0), (2, 0, 0), (0, 2, 0), (0, 0, 2)],
[],
[(0, 1, 2), (0, 3, 1)],
(-3, 0, 0),
)
edge_modifier = edge_split.modifiers.new("Native Edge Split", "EDGE_SPLIT")
edge_modifier.use_edge_angle = True
edge_modifier.use_edge_sharp = False
edge_modifier.split_angle = math.radians(30)
screw = mesh_object(
"ExtendedScrew",
[(1, 0, -1), (1, 0, 1)],
[(0, 1)],
[],
(3, 0, 0),
)
screw_modifier = screw.modifiers.new("Native Screw", "SCREW")
screw_modifier.axis = "Z"
screw_modifier.angle = math.tau
screw_modifier.steps = 8
screw_modifier.render_steps = 12
screw_modifier.iterations = 1
screw_modifier.use_merge_vertices = False
disabled = screw.modifiers.new("Disabled Edge Split", "EDGE_SPLIT")
disabled.show_viewport = False
disabled.show_render = True
disabled.show_in_editmode = True
displace = mesh_object(
"ExtendedDisplace",
[(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1),
(-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)],
[],
[(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1),
(1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7)],
(7, 0, 0),
)
displace_modifier = displace.modifiers.new("Native Constant Displace", "DISPLACE")
displace_modifier.direction = "Z"
displace_modifier.space = "LOCAL"
displace_modifier.strength = 0.75
displace_modifier.mid_level = 0.25
blocked_displace = mesh_object(
"ExtendedDisplaceBlocked",
[(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0)],
[],
[(0, 1, 2, 3)],
(11, 0, 0),
)
blocked_modifier = blocked_displace.modifiers.new("Blocked RGB Displace", "DISPLACE")
blocked_modifier.direction = "RGB_TO_XYZ"
mesh_object(
"ExtendedDisplaceBlockedBaseline",
[(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0)],
[],
[(0, 1, 2, 3)],
(15, 0, 0),
)
scene = bpy.context.scene
scene.frame_set(1)
depsgraph = bpy.context.evaluated_depsgraph_get()
objects = [edge_split, screw, displace, blocked_displace]
golden = {
"schemaVersion": 1,
"taskId": "N-002",
"blenderVersion": bpy.app.version_string,
"fixture": "modifier_extended_native.blend",
"frame": scene.frame_current,
"objects": [
{
"name": obj.name,
"modifiers": [
{
"name": modifier.name,
"type": modifier.type,
"typeCode": modifier_type_code(modifier),
"showViewport": modifier.show_viewport,
"showRender": modifier.show_render,
"showEditmode": modifier.show_in_editmode,
"showOnCage": modifier.show_on_cage,
}
for modifier in obj.modifiers
],
}
for obj in objects
],
"meshes": [evaluated_mesh(obj, depsgraph) for obj in objects],
"tolerance": {"maxPositionError": 1e-5, "rmsPositionError": 1e-6},
}
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, golden["fixture"])
bpy.ops.wm.save_as_mainfile(filepath=output_path)
golden_dir = os.path.abspath(os.path.join(output_dir, "..", "..", "golden", "N-002"))
os.makedirs(golden_dir, exist_ok=True)
with open(os.path.join(golden_dir, "modifier_extended_native.json"), "w", encoding="utf-8") as handle:
json.dump(golden, handle, indent=2, sort_keys=True)
handle.write("\n")
print(f"modifier-extended-generated fixture={output_path}")
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
main(os.path.abspath(args[0]))

View File

@@ -0,0 +1,366 @@
import math
import os
import sys
import bpy
def reset_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 24
scene.render.fps = 24
return scene
def mesh_object(name, vertices, faces, location=(0.0, 0.0, 0.0)):
mesh = bpy.data.meshes.new(f"{name}Mesh")
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
obj.location = location
return obj
def cube_object(name, location=(0.0, 0.0, 0.0), scale=1.0):
vertices = [
(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1),
(-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1),
]
faces = [
(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1),
(1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7),
]
obj = mesh_object(name, vertices, faces, location)
obj.scale = (scale, scale, scale)
return obj
def plane_grid(name, size=2.0, z=0.0, location=(0.0, 0.0, 0.0)):
vertices = [(-size, -size, z), (0, -size, z), (size, -size, z),
(-size, 0, z), (0, 0, z), (size, 0, z),
(-size, size, z), (0, size, z), (size, size, z)]
faces = [(0, 1, 4, 3), (1, 2, 5, 4), (3, 4, 7, 6), (4, 5, 8, 7)]
return mesh_object(name, vertices, faces, location)
def select_active(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
def generate_fixture(output_path):
reset_scene()
x = -12.0
subsurf = cube_object("GenerateSubsurf", (x, 0, 0))
mod = subsurf.modifiers.new("Subdivision", "SUBSURF")
mod.levels = 1
mod.render_levels = 1
x += 4
mirror = mesh_object("GenerateMirror", [(0.2, -1, -1), (1.8, -1, -1), (1.8, 1, -1),
(0.2, 1, -1), (0.2, -1, 1), (1.8, -1, 1),
(1.8, 1, 1), (0.2, 1, 1)],
[(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1),
(1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7)], (x, 0, 0))
mod = mirror.modifiers.new("Mirror X", "MIRROR")
mod.use_axis[0] = True
mod.use_clip = True
x += 4
array = cube_object("GenerateArray", (x, 0, 0), 0.6)
mod = array.modifiers.new("Array Three", "ARRAY")
mod.count = 3
mod.relative_offset_displace = (1.5, 0.0, 0.0)
x += 4
bevel = cube_object("GenerateBevel", (x, 0, 0))
mod = bevel.modifiers.new("Bevel", "BEVEL")
mod.width = 0.2
mod.segments = 2
x += 4
solidify = plane_grid("GenerateSolidify", 1.0, location=(x, 0, 0))
mod = solidify.modifiers.new("Solidify", "SOLIDIFY")
mod.thickness = 0.25
x += 4
triangulate = cube_object("GenerateTriangulate", (x, 0, 0))
triangulate.modifiers.new("Triangulate", "TRIANGULATE")
x += 4
weld = mesh_object("GenerateWeld", [(-1, -1, 0), (1, -1, 0), (1, 1, 0),
(-1, -1, 0), (1, 1, 0), (-1, 1, 0)],
[(0, 1, 2), (3, 4, 5)], (x, 0, 0))
mod = weld.modifiers.new("Weld", "WELD")
mod.merge_threshold = 0.001
x += 4
boolean_source = cube_object("GenerateBoolean", (x, 0, 0))
boolean_target = cube_object("GenerateBooleanTarget", (x + 0.75, 0, 0), 0.75)
boolean_target.display_type = "WIRE"
mod = boolean_source.modifiers.new("Boolean Difference", "BOOLEAN")
mod.operation = "DIFFERENCE"
mod.solver = "EXACT"
mod.object = boolean_target
wireframe = cube_object("GenerateWireframe", (x + 4, 0, 0))
mod = wireframe.modifiers.new("Wireframe", "WIREFRAME")
mod.thickness = 0.15
mod.use_replace = True
mod.use_boundary = True
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
def deform_fixture(output_path):
reset_scene()
lattice_data = bpy.data.lattices.new("DeformLatticeTargetData")
lattice_data.points_u = 2
lattice_data.points_v = 2
lattice_data.points_w = 2
lattice_target = bpy.data.objects.new("DeformLatticeTarget", lattice_data)
bpy.context.collection.objects.link(lattice_target)
lattice_target.location = (-8, 0, 0)
lattice_target.scale = (1.5, 1.5, 1.5)
lattice_data.points[-1].co_deform.z += 0.75
lattice_source = cube_object("DeformLattice", (-8, 0, 0))
mod = lattice_source.modifiers.new("Lattice", "LATTICE")
mod.object = lattice_target
mod.strength = 0.8
hook_source = plane_grid("DeformHook", 1.0, location=(-3, 0, 0))
hook_target = bpy.data.objects.new("DeformHookTarget", None)
bpy.context.collection.objects.link(hook_target)
hook_target.location = (-3, 0, 1.25)
mod = hook_source.modifiers.new("Hook", "HOOK")
mod.object = hook_target
mod.vertex_indices_set([4, 5, 7, 8])
mod.strength = 0.7
shrink_target = plane_grid("DeformShrinkwrapTarget", 1.5, z=0.0, location=(2, 0, 0))
shrink_source = plane_grid("DeformShrinkwrap", 1.0, z=0.8, location=(2, 0, 0))
shrink_source.data.vertices[4].co.z = 1.4
mod = shrink_source.modifiers.new("Shrinkwrap", "SHRINKWRAP")
mod.target = shrink_target
mod.wrap_method = "NEAREST_SURFACEPOINT"
mod.offset = 0.1
simple = cube_object("DeformSimple", (7, 0, 0))
mod = simple.modifiers.new("Simple Twist", "SIMPLE_DEFORM")
mod.deform_method = "TWIST"
mod.deform_axis = "Z"
mod.angle = math.radians(35.0)
mesh_deform = cube_object("DeformMeshDeform", (12, 0, 0))
mesh_cage = cube_object("DeformMeshCage", (12, 0, 0), 1.4)
for polygon in mesh_cage.data.polygons:
polygon.flip()
mesh_cage.data.update()
mesh_cage.display_type = "WIRE"
mod = mesh_deform.modifiers.new("Mesh Deform", "MESH_DEFORM")
mod.object = mesh_cage
select_active(mesh_deform)
bpy.ops.object.meshdeform_bind(modifier=mod.name)
if not mod.is_bound:
raise RuntimeError("Mesh Deform fixture failed to bind")
for vertex in mesh_cage.data.vertices:
if vertex.co.z > 0.0:
vertex.co.z += 0.75
mesh_cage.data.update()
surface_deform = plane_grid("DeformSurface", 1.0, z=0.5, location=(17, 0, 0))
surface_target = plane_grid("DeformSurfaceTarget", 1.5, location=(17, 0, 0))
mod = surface_deform.modifiers.new("Surface Deform", "SURFACE_DEFORM")
mod.target = surface_target
select_active(surface_deform)
bpy.ops.object.surfacedeform_bind(modifier=mod.name)
if not mod.is_bound:
raise RuntimeError("Surface Deform fixture failed to bind")
surface_target.data.vertices[4].co.z += 0.75
surface_target.data.update()
cast = cube_object("DeformCast", (22, 0, 0))
mod = cast.modifiers.new("Cast Sphere", "CAST")
mod.cast_type = "SPHERE"
mod.factor = 0.75
mod.use_radius_as_size = False
mod.size = 1.25
smooth = plane_grid("DeformSmooth", 1.5, location=(27, 0, 0))
smooth.data.vertices[4].co.z = 1.5
mod = smooth.modifiers.new("Smooth", "SMOOTH")
mod.factor = 0.5
mod.iterations = 2
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
def deform_curve_fixture(output_path):
reset_scene()
curve_data = bpy.data.curves.new("DeformCurveTargetData", "CURVE")
curve_data.dimensions = "3D"
spline = curve_data.splines.new("BEZIER")
spline.bezier_points.add(2)
for point, coordinate in zip(spline.bezier_points, [(-2, 0, 0), (0, 0.75, 0), (2, 0, 1)]):
point.co = coordinate
point.handle_left_type = "AUTO"
point.handle_right_type = "AUTO"
curve_target = bpy.data.objects.new("DeformCurveTarget", curve_data)
bpy.context.collection.objects.link(curve_target)
curve_source = plane_grid("DeformCurve", 1.5)
modifier = curve_source.modifiers.new("Curve", "CURVE")
modifier.object = curve_target
modifier.deform_axis = "POS_X"
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
def physics_fixture(output_path):
scene = reset_scene()
scene.frame_end = 12
build = cube_object("PhysicsBuild", (-4, 0, 0))
mod = build.modifiers.new("Build", "BUILD")
mod.frame_start = 1
mod.frame_duration = 10
mod.use_random_order = False
wave = plane_grid("PhysicsWave", 2.0, location=(2, 0, 0))
mod = wave.modifiers.new("Wave", "WAVE")
mod.height = 0.4
mod.width = 1.2
mod.speed = 0.25
mod.start_position_x = 0.0
mod.start_position_y = 0.0
cloth = plane_grid("PhysicsClothDisabled", 1.0, location=(7, 0, 1))
mod = cloth.modifiers.new("Cloth Disabled", "CLOTH")
mod.show_viewport = False
soft = plane_grid("PhysicsSoftBodyDisabled", 1.0, location=(11, 0, 1))
select_active(soft)
bpy.ops.object.modifier_add(type="SOFT_BODY")
soft.modifiers[-1].name = "Soft Body Disabled"
soft.modifiers[-1].show_viewport = False
collision = cube_object("PhysicsCollisionDisabled", (15, 0, 0))
select_active(collision)
bpy.ops.object.modifier_add(type="COLLISION")
collision.modifiers[-1].name = "Collision Disabled"
collision.modifiers[-1].show_viewport = False
scene.frame_set(6)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
def geometry_nodes_fixture(output_path):
reset_scene()
obj = cube_object("GeometryNodesObject")
group = bpy.data.node_groups.new("WebGeometryNodes", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
input_node = group.nodes.new("NodeGroupInput")
output_node = group.nodes.new("NodeGroupOutput")
transform = group.nodes.new("GeometryNodeTransform")
transform.inputs["Translation"].default_value = (0.25, 0.5, 1.0)
transform.inputs["Rotation"].default_value = (0.0, 0.0, math.radians(15.0))
group.links.new(input_node.outputs["Geometry"], transform.inputs["Geometry"])
group.links.new(transform.outputs["Geometry"], output_node.inputs["Geometry"])
modifier = obj.modifiers.new("Geometry Nodes Transform", "NODES")
modifier.node_group = group
set_position_object = cube_object("GeometryNodesSetPosition", (4, 0, 0))
set_position_group = bpy.data.node_groups.new("WebGeometryNodesSetPosition", "GeometryNodeTree")
set_position_group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
set_position_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
set_position_input = set_position_group.nodes.new("NodeGroupInput")
set_position_output = set_position_group.nodes.new("NodeGroupOutput")
set_position = set_position_group.nodes.new("GeometryNodeSetPosition")
set_position.inputs["Offset"].default_value = (0.0, 0.0, 1.0)
set_position_group.links.new(set_position_input.outputs["Geometry"], set_position.inputs["Geometry"])
set_position_group.links.new(set_position.outputs["Geometry"], set_position_output.inputs["Geometry"])
modifier = set_position_object.modifiers.new("Geometry Nodes Set Position", "NODES")
modifier.node_group = set_position_group
simulation = cube_object("GeometryNodesSimulation", (8, 0, 0))
simulation_group = bpy.data.node_groups.new("WebGeometryNodesSimulation", "GeometryNodeTree")
simulation_group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
simulation_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
simulation_input = simulation_group.nodes.new("NodeGroupInput")
simulation_output = simulation_group.nodes.new("NodeGroupOutput")
zone_input = simulation_group.nodes.new("GeometryNodeSimulationInput")
zone_output = simulation_group.nodes.new("GeometryNodeSimulationOutput")
zone_input.pair_with_output(zone_output)
simulation_group.links.new(simulation_input.outputs["Geometry"], zone_input.inputs["Geometry"])
simulation_group.links.new(zone_input.outputs["Geometry"], zone_output.inputs["Geometry"])
simulation_group.links.new(zone_output.outputs["Geometry"], simulation_output.inputs["Geometry"])
modifier = simulation.modifiers.new("Geometry Nodes Simulation", "NODES")
modifier.node_group = simulation_group
cube_object("GeometryNodesSimulationBaseline", (12, 0, 0))
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
def grease_pencil_fixture(output_path):
reset_scene()
grease_pencil = bpy.data.grease_pencils.new("GreasePencilData")
layer = grease_pencil.layers.new("Lines", set_active=True)
drawing = layer.frames.new(1).drawing
drawing.add_strokes([4])
for point, coordinate in zip(drawing.strokes[0].points,
[(-1.5, 0, 0), (-0.5, 0.5, 0), (0.5, -0.25, 0), (1.5, 0.25, 0)]):
point.position = coordinate
point.radius = 0.05
point.opacity = 0.9
obj = bpy.data.objects.new("GreasePencilObject", grease_pencil)
bpy.context.collection.objects.link(obj)
modifier_types = sorted(item.identifier for item in bpy.types.Modifier.bl_rna.properties["type"].enum_items
if item.identifier.startswith("GREASE_PENCIL_") or item.identifier == "LINEART")
for modifier_type in modifier_types:
modifier = obj.modifiers.new(modifier_type.replace("GREASE_PENCIL_", "GP ").replace("_", " ").title(),
modifier_type)
modifier.show_viewport = False
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
GENERATORS = {
"generate": generate_fixture,
"deform": deform_fixture,
"deform_curve": deform_curve_fixture,
"physics": physics_fixture,
"geometry_nodes": geometry_nodes_fixture,
"grease_pencil": grease_pencil_fixture,
}
def main():
if "--" not in sys.argv:
raise SystemExit("usage: blender -b --python generate-modifier-fixtures.py -- output-directory")
args = sys.argv[sys.argv.index("--") + 1:]
if not args:
raise SystemExit("missing output directory")
output_directory = os.path.abspath(args[0])
os.makedirs(output_directory, exist_ok=True)
requested = args[1:] or list(GENERATORS)
for category in requested:
if category not in GENERATORS:
raise SystemExit(f"unknown fixture category: {category}")
output_path = os.path.join(output_directory, f"modifier_{category}_scene.blend")
GENERATORS[category](output_path)
print(f"fixture-generated category={category} path={output_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,144 @@
import json
import os
import sys
import bpy
FIXTURES = [
"modifier_generate_scene.blend",
"modifier_deform_scene.blend",
"modifier_deform_curve_scene.blend",
"modifier_physics_scene.blend",
"modifier_geometry_nodes_scene.blend",
"modifier_grease_pencil_scene.blend",
]
def modifier_type_code(modifier):
items = bpy.types.Modifier.bl_rna.properties["type"].enum_items
item = items.get(modifier.type)
return item.value if item is not None else -1
def modifier_record(modifier):
record = {
"name": modifier.name,
"type": modifier.type,
"typeCode": modifier_type_code(modifier),
"showViewport": modifier.show_viewport,
"showRender": modifier.show_render,
"showEditmode": modifier.show_in_editmode,
"showOnCage": modifier.show_on_cage,
}
targets = []
for field in ("object", "target", "auxiliary_target"):
if hasattr(modifier, field):
target = getattr(modifier, field)
if target is not None and target.name not in targets:
targets.append(target.name)
if targets:
record["targetObjects"] = targets
if modifier.type == "NODES" and modifier.node_group is not None:
record["nodeGroup"] = modifier.node_group.name
return record
def evaluated_mesh_record(obj, depsgraph):
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
mesh.calc_loop_triangles()
return {
"object": obj.name,
"sourceMesh": obj.data.name,
"vertexCount": len(mesh.vertices),
"triangleCount": len(mesh.loop_triangles),
"positions": [component for vertex in mesh.vertices for component in vertex.co],
"indices": [index for triangle in mesh.loop_triangles for index in triangle.vertices],
"worldMatrix": [value for row in evaluated.matrix_world for value in row],
}
finally:
evaluated.to_mesh_clear()
def grease_pencil_record(obj):
layers = []
for layer in obj.data.layers:
frames = []
for frame in layer.frames:
drawing = frame.drawing
frames.append({
"frame": frame.frame_number,
"strokeCount": len(drawing.strokes),
"pointCount": sum(len(stroke.points) for stroke in drawing.strokes),
"positions": [component for stroke in drawing.strokes for point in stroke.points
for component in point.position],
})
layers.append({"name": layer.name, "frames": frames})
return {"object": obj.name, "data": obj.data.name, "layers": layers}
def node_group_records():
return [{
"name": group.name,
"type": group.bl_idname,
"nodes": [{"name": node.name, "type": node.bl_idname} for node in group.nodes],
"links": len(group.links),
} for group in bpy.data.node_groups]
def golden_for_fixture(path):
bpy.ops.wm.open_mainfile(filepath=path)
scene = bpy.context.scene
scene.frame_set(scene.frame_current)
depsgraph = bpy.context.evaluated_depsgraph_get()
objects = []
meshes = []
grease_pencils = []
for obj in sorted(bpy.data.objects, key=lambda item: item.name):
objects.append({
"name": obj.name,
"type": obj.type,
"modifiers": [modifier_record(modifier) for modifier in obj.modifiers],
})
if obj.type == "MESH":
meshes.append(evaluated_mesh_record(obj, depsgraph))
elif obj.type == "GREASEPENCIL":
grease_pencils.append(grease_pencil_record(obj))
return {
"schemaVersion": 1,
"blenderVersion": bpy.app.version_string,
"fixture": os.path.basename(path),
"frame": scene.frame_current,
"objects": objects,
"meshes": meshes,
"nodeGroups": node_group_records(),
"greasePencils": grease_pencils,
"tolerance": {"maxPositionError": 1e-5, "rmsPositionError": 1e-6},
}
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) < 2:
raise SystemExit("usage: blender -b --python generate-modifier-goldens.py -- fixture-directory output-directory")
args = sys.argv[sys.argv.index("--") + 1:]
fixture_directory, output_directory = args[:2]
fixture_directory = os.path.abspath(fixture_directory)
output_directory = os.path.abspath(output_directory)
os.makedirs(output_directory, exist_ok=True)
requested = args[2:] or FIXTURES
for fixture in requested:
if fixture not in FIXTURES:
raise SystemExit(f"unknown modifier fixture: {fixture}")
source = os.path.join(fixture_directory, fixture)
golden = golden_for_fixture(source)
output = os.path.join(output_directory, fixture.replace(".blend", ".json"))
with open(output, "w", encoding="utf-8") as handle:
json.dump(golden, handle, indent=2, sort_keys=True)
handle.write("\n")
print(f"modifier-golden-generated fixture={fixture} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,82 @@
import json
import os
import sys
import bpy
TYPE_NAMES = {
"CURVE": "CURVE",
"SURFACE": "SURFACE",
"FONT": "FONT",
"META": "METABALL",
}
def rounded(value):
return round(float(value), 7)
def geometry_record(obj, depsgraph):
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
mesh.calc_loop_triangles()
positions = [component for vertex in mesh.vertices for component in vertex.co]
edge_indices = [index for edge in mesh.edges for index in edge.vertices]
indices = [index for triangle in mesh.loop_triangles for index in triangle.vertices]
point_count = len(mesh.vertices)
bounds_min = [min((vertex.co[axis] for vertex in mesh.vertices), default=0.0) for axis in range(3)]
bounds_max = [max((vertex.co[axis] for vertex in mesh.vertices), default=0.0) for axis in range(3)]
centroid = [
sum((vertex.co[axis] for vertex in mesh.vertices), 0.0) / point_count
if point_count else 0.0
for axis in range(3)
]
return {
"object": obj.name,
"sourceType": TYPE_NAMES[obj.type],
"vertexCount": point_count,
"edgeCount": len(mesh.edges),
"triangleCount": len(mesh.loop_triangles),
"boundsMin": [rounded(value) for value in bounds_min],
"boundsMax": [rounded(value) for value in bounds_max],
"centroid": [rounded(value) for value in centroid],
"surfaceArea": rounded(sum((triangle.area for triangle in mesh.loop_triangles), 0.0)),
"positionMoment": rounded(sum((index + 1) * value for index, value in enumerate(positions))),
"edgeIndexMoment": sum((index + 1) * (value + 1) for index, value in enumerate(edge_indices)),
"indexMoment": sum((index + 1) * (value + 1) for index, value in enumerate(indices)),
}
finally:
evaluated.to_mesh_clear()
def main(output_path):
depsgraph = bpy.context.evaluated_depsgraph_get()
records = [
geometry_record(obj, depsgraph)
for obj in bpy.context.scene.objects
if obj.type in TYPE_NAMES
]
records.sort(key=lambda record: record["object"])
payload = {
"schemaVersion": 1,
"blenderVersion": bpy.app.version_string,
"fixture": os.path.basename(bpy.data.filepath),
"tolerance": {
"coordinate": 0.00001,
"surfaceArea": 0.0001,
"positionMoment": 0.001,
},
"geometries": records,
}
with open(output_path, "w", encoding="ascii") as output:
json.dump(payload, output, indent=2, sort_keys=True)
output.write("\n")
print(f"nonmesh-desktop-golden-generated output={os.path.abspath(output_path)} geometries={len(records)}")
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b fixture.blend --python generate-nonmesh-desktop-golden.py -- output.json")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,183 @@
import os
import sys
import bpy
def link_object(name, data):
obj = bpy.data.objects.new(name, data)
bpy.context.scene.collection.objects.link(obj)
return obj
def build_curve():
data = bpy.data.curves.new("WebCurveData", "CURVE")
data.dimensions = "3D"
data.resolution_u = 8
poly = data.splines.new("POLY")
poly.points.add(3)
for point, co in zip(poly.points, ((-2.0, 0.0, 0.0, 1.0), (-0.8, 0.7, 0.2, 1.0), (0.6, -0.4, 0.4, 1.0), (2.0, 0.2, 0.0, 1.0))):
point.co = co
bezier = data.splines.new("BEZIER")
bezier.bezier_points.add(2)
for point, co in zip(bezier.bezier_points, ((-1.5, -1.0, 0.0), (0.0, -1.8, 0.5), (1.5, -1.0, 0.0))):
point.co = co
point.handle_left_type = "AUTO"
point.handle_right_type = "AUTO"
return link_object("WebCurveObject", data)
def build_surface():
bpy.ops.surface.primitive_nurbs_surface_surface_add(enter_editmode=False)
obj = bpy.context.object
obj.name = "WebSurfaceObject"
data = obj.data
data.name = "WebSurfaceData"
data.resolution_u = 4
data.resolution_v = 4
spline = data.splines[0]
spline.order_u = 4
spline.order_v = 4
spline.use_endpoint_u = True
spline.use_endpoint_v = True
for index, point in enumerate(spline.points):
u = index % 4
v = index // 4
x = -1.0 + 2.0 * u / 3.0
y = v / 3.0
z = 0.35 * (1.0 if u in (1, 2) else 0.0) * (1.0 if v in (1, 2) else 0.0)
point.co = (x, y, z, 1.0)
return obj
def build_text():
data = bpy.data.curves.new("WebFontData", "FONT")
data.body = "Web Blender"
data.align_x = "CENTER"
data.size = 0.7
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
font_path = os.path.join(root, "blender-5.2.0", "release", "datafiles", "bfont.pfb")
regular = bpy.data.fonts.load(font_path, check_existing=False)
alternate = bpy.data.fonts.load(font_path, check_existing=False)
regular.name = "WebFontRegular"
alternate.name = "WebFontAlternate"
data.font = regular
data.font_bold = alternate
data.font_italic = regular
data.font_bold_italic = alternate
return link_object("WebFontObject", data)
def build_metaball():
data = bpy.data.metaballs.new("WebMetaballData")
data.resolution = 0.2
first = data.elements.new()
first.co = (-0.65, 0.0, 0.4)
first.radius = 0.75
second = data.elements.new()
second.co = (0.65, 0.0, 0.4)
second.radius = 0.55
return link_object("WebMetaballObject", data)
def build_point_cloud():
mesh = bpy.data.meshes.new("WebPointSeed")
mesh.from_pydata(((-1.5, 1.4, 0.0), (-0.5, 1.7, 0.2), (0.5, 1.4, 0.0), (1.5, 1.7, 0.2)), [], [])
obj = link_object("WebPointCloudObject", mesh)
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.convert(target="POINTCLOUD")
obj.data.name = "WebPointCloudData"
weight = obj.data.attributes.new("web_weight", "FLOAT", "POINT")
for index, item in enumerate(weight.data):
item.value = 0.25 + index * 0.25
radius = obj.data.attributes.get("radius") or obj.data.attributes.new("radius", "FLOAT", "POINT")
for index, item in enumerate(radius.data):
item.value = 0.05 + index * 0.01
return obj
def build_hair():
surface_mesh = bpy.data.meshes.new("WebHairSurfaceData")
surface_mesh.from_pydata(((-2.0, 3.0, -0.1), (2.0, 3.0, -0.1), (2.0, 4.0, -0.1), (-2.0, 4.0, -0.1)), (), ((0, 1, 2, 3),))
surface = link_object("WebHairSurface", surface_mesh)
surface.hide_viewport = True
surface.hide_render = True
legacy = bpy.data.curves.new("WebHairSeed", "CURVE")
legacy.dimensions = "3D"
spline = legacy.splines.new("POLY")
spline.points.add(3)
for point, co in zip(spline.points, ((-1.2, 3.2, 0.0, 1.0), (-0.5, 3.4, 0.5, 1.0), (0.4, 3.3, 0.8, 1.0), (1.2, 3.5, 1.0, 1.0))):
point.co = co
obj = link_object("WebHairObject", legacy)
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.convert(target="CURVES")
if legacy != obj.data:
bpy.data.curves.remove(legacy, do_unlink=True)
obj.data.name = "WebHairData"
obj.data.surface = surface
radius = obj.data.attributes.get("radius") or obj.data.attributes.new("radius", "FLOAT", "POINT")
for index, item in enumerate(radius.data):
item.value = 0.02 + index * 0.004
density = obj.data.attributes.new("web_density", "FLOAT", "CURVE")
density.data[0].value = 0.75
return obj
def build_curves():
legacy = bpy.data.curves.new("WebCurvesSeed", "CURVE")
legacy.dimensions = "3D"
spline = legacy.splines.new("BEZIER")
spline.bezier_points.add(3)
for point, co in zip(spline.bezier_points, ((-1.5, 2.5, 0.0), (-0.5, 2.8, 0.3), (0.5, 2.5, 0.0), (1.5, 2.8, 0.3))):
point.co = co
point.handle_left_type = "AUTO"
point.handle_right_type = "AUTO"
obj = link_object("WebCurvesObject", legacy)
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.convert(target="CURVES")
if legacy != obj.data:
bpy.data.curves.remove(legacy, do_unlink=True)
obj.data.name = "WebCurvesData"
radius = obj.data.attributes.get("radius") or obj.data.attributes.new("radius", "FLOAT", "POINT")
for index, item in enumerate(radius.data):
item.value = 0.03 + index * 0.005
color = obj.data.attributes.new("web_color", "FLOAT_COLOR", "POINT")
for index, item in enumerate(color.data):
item.color = (index / 4.0, 0.4, 0.8, 1.0)
return obj
def build_volume():
data = bpy.data.volumes.new("WebVolumeData")
data.filepath = "//missing_web_volume.vdb"
return link_object("WebVolumeObject", data)
def main(output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
build_curve()
build_surface()
build_text()
build_metaball()
build_point_cloud()
build_curves()
build_hair()
bpy.ops.file.pack_all()
build_volume()
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 24
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(output_path))
print(f"nonmesh-fixture-generated output={os.path.abspath(output_path)}")
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b --python generate-nonmesh-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,36 @@
import pathlib
import sys
import bpy
def main(output_path: str, image_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("PackedImageMesh")
mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
mesh.update()
obj = bpy.data.objects.new("PackedImageObject", mesh)
bpy.context.collection.objects.link(obj)
material = bpy.data.materials.new("PackedImageMaterial")
material.use_nodes = True
texture = material.node_tree.nodes.new("ShaderNodeTexImage")
texture.image = bpy.data.images.load(str(pathlib.Path(image_path).resolve()), check_existing=False)
texture.image.name = "PackedTexture"
material.node_tree.links.new(texture.outputs["Color"], material.node_tree.nodes["Principled BSDF"].inputs["Base Color"])
mesh.materials.append(material)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.file.pack_all()
if not texture.image.packed_file:
raise RuntimeError("Blender did not pack the texture")
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 2:
raise SystemExit("usage: blender -b --python generate-packed-image-fixture.py -- output.blend image.png")
arguments = sys.argv[sys.argv.index("--") + 1:]
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,130 @@
import math
import sys
import bpy
def keyframe_rotation(target, frame, degrees):
target.rotation_euler[2] = math.radians(degrees)
target.keyframe_insert(data_path="rotation_euler", index=2, frame=frame)
def main(output_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("PoseConstraintMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2), (0, 2, 3)],
)
mesh.update()
mesh_object = bpy.data.objects.new("PoseConstraintMeshObject", mesh)
bpy.context.collection.objects.link(mesh_object)
mesh_object.location = (0.35, -0.2, 0.15)
mesh_object.rotation_euler = (math.radians(7.0), math.radians(-11.0), math.radians(5.0))
mesh_object.scale = (1.2, 0.8, 1.1)
scaled_parent = bpy.data.objects.new("ScaledRigParent", None)
bpy.context.collection.objects.link(scaled_parent)
scaled_parent.location = (-0.25, 0.3, 0.1)
scaled_parent.rotation_euler = (math.radians(4.0), math.radians(3.0), math.radians(-8.0))
scaled_parent.scale = (1.35, 0.7, 1.15)
armature_data = bpy.data.armatures.new("PoseConstraintArmature")
armature_object = bpy.data.objects.new("PoseConstraintArmatureObject", armature_data)
bpy.context.collection.objects.link(armature_object)
armature_object.parent = scaled_parent
armature_object.location = (-0.15, 0.1, -0.05)
armature_object.rotation_euler = (math.radians(-6.0), math.radians(9.0), math.radians(12.0))
armature_object.scale = (0.9, 1.1, 1.05)
bpy.context.view_layer.objects.active = armature_object
armature_object.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
root = armature_data.edit_bones.new("Root")
root.head = (0.0, 0.0, 0.0)
root.tail = (0.0, 0.0, 1.0)
mid = armature_data.edit_bones.new("Mid")
mid.head = (0.0, 0.0, 1.0)
mid.tail = (0.0, 0.0, 2.0)
mid.parent = root
mid.use_connect = True
tip = armature_data.edit_bones.new("Tip")
tip.head = (0.0, 0.0, 2.0)
tip.tail = (0.0, 0.0, 3.0)
tip.parent = mid
tip.use_connect = True
auxiliary = armature_data.edit_bones.new("Auxiliary")
auxiliary.head = (0.5, 0.0, 0.5)
auxiliary.tail = (0.5, 0.0, 1.25)
auxiliary.parent = root
bpy.ops.object.mode_set(mode="POSE")
pose_root = armature_object.pose.bones["Root"]
pose_root.rotation_mode = "XYZ"
for frame, degrees in ((1, 0.0), (5, 18.0), (10, 36.0), (15, 48.0)):
pose_root.rotation_euler[1] = math.radians(degrees)
pose_root.keyframe_insert(data_path="rotation_euler", index=1, frame=frame)
bpy.ops.object.mode_set(mode="OBJECT")
target = bpy.data.objects.new("PoseConstraintTarget", None)
bpy.context.collection.objects.link(target)
target.location = (0.0, 0.0, 1.0)
for frame, degrees in ((1, 0.0), (5, 35.0), (10, 70.0), (15, 95.0)):
keyframe_rotation(target, frame, degrees)
copy_rotation = armature_object.pose.bones["Auxiliary"].constraints.new("COPY_ROTATION")
copy_rotation.name = "Animated Copy Rotation"
copy_rotation.target = target
copy_rotation.owner_space = "WORLD"
copy_rotation.target_space = "WORLD"
copy_rotation.use_x = False
copy_rotation.use_y = False
copy_rotation.use_z = True
copy_rotation.influence = 0.75
ik_target = bpy.data.objects.new("PoseIKTarget", None)
bpy.context.collection.objects.link(ik_target)
pole_target = bpy.data.objects.new("PoseIKPole", None)
bpy.context.collection.objects.link(pole_target)
pole_target.location = (1.5, -1.0, 1.25)
for frame, location in (
(1, (0.35, 0.15, 2.65)),
(5, (0.75, 0.25, 2.45)),
(10, (1.0, -0.15, 2.15)),
(15, (0.45, -0.55, 2.55)),
):
ik_target.location = location
ik_target.keyframe_insert(data_path="location", frame=frame)
ik = armature_object.pose.bones["Tip"].constraints.new("IK")
ik.name = "Animated Two Bone IK"
ik.target = ik_target
ik.pole_target = pole_target
ik.chain_count = 2
ik.iterations = 128
ik.influence = 0.85
root_group = mesh_object.vertex_groups.new(name="Root")
mid_group = mesh_object.vertex_groups.new(name="Mid")
tip_group = mesh_object.vertex_groups.new(name="Tip")
auxiliary_group = mesh_object.vertex_groups.new(name="Auxiliary")
root_group.add([0, 1], 1.0, "REPLACE")
root_group.add([2, 3], 0.25, "REPLACE")
mid_group.add([0, 1], 0.0, "REPLACE")
mid_group.add([2, 3], 0.35, "REPLACE")
tip_group.add([0, 1], 0.0, "REPLACE")
tip_group.add([2, 3], 0.40, "REPLACE")
auxiliary_group.add([0, 1, 2, 3], 0.05, "REPLACE")
armature_modifier = mesh_object.modifiers.new(name="Armature Deform", type="ARMATURE")
armature_modifier.object = armature_object
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 15
scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b --python generate-pose-constraint-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,88 @@
import json
import pathlib
import sys
import bpy
CONSTRAINT_TYPE_CODES = {
"IK": 3,
"COPY_ROTATION": 8,
}
def flatten_matrix(matrix):
return [value for row in matrix for value in row]
def flatten_matrix_column_major(matrix):
return [matrix[row][column] for column in range(4) for row in range(4)]
def main(blend_path: str, output_path: str) -> None:
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(blend_path).resolve()), load_ui=False)
scene = bpy.context.scene
armature_object = bpy.data.objects.get("PoseConstraintArmatureObject")
mesh_object = bpy.data.objects.get("PoseConstraintMeshObject")
if armature_object is None or mesh_object is None:
raise RuntimeError("pose constraint fixture objects are missing")
frames = [1, 5, 10, 15]
samples = []
constraint_metadata = {}
for pose_bone in armature_object.pose.bones:
constraint_metadata[pose_bone.name] = [
{
"name": constraint.name,
"typeCode": CONSTRAINT_TYPE_CODES.get(constraint.type),
"influence": constraint.influence,
"targetObject": constraint.target.name if constraint.target else None,
"poleTargetObject": constraint.pole_target.name if constraint.type == "IK" and constraint.pole_target else None,
}
for constraint in pose_bone.constraints
]
for frame in frames:
scene.frame_set(frame)
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated_armature = armature_object.evaluated_get(depsgraph)
evaluated_mesh_object = mesh_object.evaluated_get(depsgraph)
mesh = evaluated_mesh_object.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
bones = {
pose_bone.name: flatten_matrix(pose_bone.matrix)
for pose_bone in evaluated_armature.pose.bones
}
samples.append({
"frame": frame,
"bones": bones,
"positions": [coordinate for vertex in mesh.vertices for coordinate in vertex.co],
})
finally:
evaluated_mesh_object.to_mesh_clear()
result = {
"schemaVersion": 1,
"fixture": pathlib.Path(blend_path).name,
"evaluator": "Blender Depsgraph",
"blenderVersion": bpy.app.version_string,
"armature": armature_object.data.name,
"armatureObject": armature_object.name,
"mesh": mesh_object.data.name,
"meshObject": mesh_object.name,
"constraintMetadata": constraint_metadata,
"meshBindMatrix": flatten_matrix_column_major(mesh_object.matrix_local),
"armatureWorldMatrix": flatten_matrix_column_major(armature_object.matrix_world),
"parentWorldMatrix": flatten_matrix_column_major(armature_object.parent.matrix_world),
"frames": frames,
"samples": samples,
"tolerance": {"maxPositionError": 1e-5, "rmsPositionError": 1e-6, "maxMatrixError": 1e-5},
}
pathlib.Path(output_path).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 2:
raise SystemExit("usage: blender -b --python generate-pose-constraint-golden.py -- input.blend output.json")
arguments = sys.argv[sys.argv.index("--") + 1:]
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,67 @@
import math
import sys
import bpy
def main(output_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("RiggedShapeMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2), (0, 2, 3)],
)
mesh.update()
obj = bpy.data.objects.new("RiggedShapeObject", mesh)
bpy.context.collection.objects.link(obj)
obj.location = (0.5, 0.25, 0.0)
armature_data = bpy.data.armatures.new("RiggedArmature")
armature_object = bpy.data.objects.new("RiggedArmatureObject", armature_data)
bpy.context.collection.objects.link(armature_object)
bpy.context.view_layer.objects.active = armature_object
armature_object.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
root_bone = armature_data.edit_bones.new("Root")
root_bone.head = (0.0, 0.0, -1.0)
root_bone.tail = (0.0, 0.0, 0.0)
tip_bone = armature_data.edit_bones.new("Tip")
tip_bone.head = (0.0, 0.0, 0.0)
tip_bone.tail = (0.0, 0.0, 1.0)
tip_bone.parent = root_bone
bpy.ops.object.mode_set(mode="OBJECT")
armature_object.location = (0.25, -0.5, 0.0)
armature_object.rotation_euler[2] = 0.15
root = obj.vertex_groups.new(name="Root")
tip = obj.vertex_groups.new(name="Tip")
root.add([0, 1], 1.0, "REPLACE")
root.add([2, 3], 0.25, "REPLACE")
tip.add([0, 1], 0.25, "REPLACE")
tip.add([2, 3], 1.0, "REPLACE")
obj.shape_key_add(name="Basis")
smile = obj.shape_key_add(name="Smile")
smile.data[2].co.z = 0.5
smile.data[3].co.z = 0.25
smile.value = 0.6
armature_object.pose.bones["Tip"].rotation_mode = "XYZ"
armature_object.pose.bones["Tip"].rotation_euler[1] = math.radians(27.0)
armature_modifier = obj.modifiers.new(name="Armature Deform", type="ARMATURE")
armature_modifier.object = armature_object
decimate = obj.modifiers.new(name="Preview Decimate", type="DECIMATE")
decimate.ratio = 0.75
decimate.show_render = False
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b --python generate-rigged-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,26 @@
import sys
import bpy
def add_mesh(name, vertices, faces):
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name + "Object", mesh)
bpy.context.collection.objects.link(obj)
return obj
def main(output_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
add_mesh("OpenQuad", [(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0)], [(0, 1, 2, 3)])
add_mesh("OpenNgon", [(0, 1, 0), (1, 0.3, 0), (0.6, -1, 0), (-0.6, -1, 0), (-1, 0.3, 0)], [(0, 1, 2, 3, 4)])
add_mesh("NonManifold", [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), (0, -1, 0)], [(0, 1, 2), (0, 3, 1), (0, 1, 4)])
bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True)
if __name__ == "__main__":
if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1:
raise SystemExit("usage: blender -b --python generate-topology-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source_dir="${repo_root}/build_web_blender6/bin"
target_dir="${repo_root}/web/app/public/vendor/blender"
source_vendor_dir="${repo_root}/web/app/src/vendor/blender"
mkdir -p "${target_dir}"
mkdir -p "${source_vendor_dir}"
test -s "${source_dir}/web_engine.js" && test -s "${source_dir}/web_engine.wasm"
cp "${source_dir}/web_engine.js" "${target_dir}/web_engine.js"
cp "${source_dir}/web_engine.wasm" "${target_dir}/web_engine.wasm"
cp "${source_dir}/web_engine.js" "${source_vendor_dir}/web_engine.js"
cp "${source_dir}/web_engine.wasm" "${source_vendor_dir}/web_engine.wasm"
wasm_hash="$(sha256sum "${target_dir}/web_engine.wasm" | cut -d ' ' -f 1)"
node "${repo_root}/tools/web/update-engine-manifest.mjs" \
"${repo_root}/web/app/public/engine-manifest.json" \
"${wasm_hash}"
sha256sum "${target_dir}/web_engine.js" "${target_dir}/web_engine.wasm"

View File

@@ -0,0 +1,55 @@
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const ratio = Number(process.argv[2]);
if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1) throw new Error("usage: probe-collapse-ratio.mjs RATIO");
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
const blend = fs.readFileSync(new URL("../../tests/files/web/basic_scene.blend", import.meta.url));
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
const text = new TextEncoder();
function open() {
const pointer = engine._malloc(blend.byteLength);
engine.HEAPU8.set(blend, pointer);
const result = engine._web_engine_open_blend(handle, pointer, blend.byteLength);
engine._free(pointer);
return result;
}
function snapshot() {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
const result = engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut);
if (result !== 0) throw new Error("snapshot failed");
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function apply(meshId, revision) {
const command = text.encode(JSON.stringify({
type: "decimateMesh",
meshId,
profile: { schemaVersion: 1, sourceMeshRevision: revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio, triangulate: false, useSymmetry: false },
}));
const pointer = engine._malloc(command.byteLength);
engine.HEAPU8.set(command, pointer);
const result = engine._web_engine_apply_command(handle, pointer, command.byteLength);
engine._free(pointer);
return result;
}
const opened = open();
if (opened !== 0) throw new Error(`open failed code=${opened} message=${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
const source = snapshot();
const mesh = source.meshes.find((candidate) => candidate.id === "mesh:Cube.001") ?? source.meshes.find((candidate) => candidate.geometryStatus === "available");
if (!mesh) throw new Error("cube mesh not found");
const result = apply(mesh.id, source.revision);
const message = engine.UTF8ToString(engine._web_engine_last_error_message());
let output = null;
if (result === 0) output = snapshot().meshes.find((candidate) => candidate.id === mesh.id);
console.log(JSON.stringify({ ratio, result, message, allocatedBytes: engine._web_engine_get_allocated_bytes(), output: output && { vertexCount: output.vertexCount, triangleCount: output.triangleCount, faceCount: output.faceCount } }));
engine._web_engine_destroy(handle);

View File

@@ -0,0 +1,36 @@
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
const blend = fs.readFileSync(new URL("../../tests/files/web/topology_scene.blend", import.meta.url));
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
const encoder = new TextEncoder();
function call(name, bytes) {
const pointer = engine._malloc(bytes.byteLength);
engine.HEAPU8.set(bytes, pointer);
try { return engine[name](handle, pointer, bytes.byteLength); }
finally { engine._free(pointer); }
}
function snapshot() {
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
try {
if (engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut) !== 0) throw new Error("snapshot failed");
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
} finally { engine._free(dataOut); engine._free(lengthOut); }
}
const openBytes = new Uint8Array(blend);
if (call("_web_engine_open_blend", openBytes) !== 0) throw new Error(engine.UTF8ToString(engine._web_engine_last_error_message()));
const source = snapshot();
const results = [];
for (const mesh of source.meshes.filter((candidate) => candidate.geometryStatus === "available")) {
if (call("_web_engine_open_blend", openBytes) !== 0) throw new Error(engine.UTF8ToString(engine._web_engine_last_error_message()));
const current = snapshot();
const currentCommand = encoder.encode(JSON.stringify({ type: "decimateMesh", meshId: mesh.id, profile: { schemaVersion: 1, sourceMeshRevision: current.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false } }));
const result = call("_web_engine_apply_command", currentCommand);
const output = result === 0 ? snapshot().meshes.find((candidate) => candidate.id === mesh.id) : undefined;
results.push({ meshId: mesh.id, result, message: engine.UTF8ToString(engine._web_engine_last_error_message()), vertices: output?.vertexCount ?? 0, triangles: output?.triangleCount ?? 0 });
}
console.log(JSON.stringify(results));
engine._web_engine_destroy(handle);

View File

@@ -0,0 +1,74 @@
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
const blend = fs.readFileSync(new URL("../../tests/files/web/basic_scene.blend", import.meta.url));
const expected = JSON.parse(fs.readFileSync(new URL("../../tests/golden/W-079/basic-scene-decimate.json", import.meta.url), "utf8"));
function readSnapshot(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
if (engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut) !== 0) throw new Error("golden snapshot request failed");
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function open(engine, handle) {
const pointer = engine._malloc(blend.byteLength);
engine.HEAPU8.set(blend, pointer);
const result = engine._web_engine_open_blend(handle, pointer, blend.byteLength);
engine._free(pointer);
if (result !== 0) throw new Error(`golden fixture open failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
function apply(engine, handle, command) {
const bytes = new TextEncoder().encode(JSON.stringify(command));
const pointer = engine._malloc(bytes.byteLength);
engine.HEAPU8.set(bytes, pointer);
const result = engine._web_engine_apply_command(handle, pointer, bytes.byteLength);
engine._free(pointer);
if (result !== 0) throw new Error(`golden command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
function sourceSummary(snapshot) {
const meshById = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh]));
return {
frame: [snapshot.frame.start, snapshot.frame.end],
objects: snapshot.nodes.map((node) => {
const mesh = node.dataId ? meshById.get(node.dataId) : undefined;
return { name: node.name, type: node.type, vertices: mesh?.vertexCount ?? 0, polygons: mesh?.faceCount ?? 0, materials: mesh?.materialSlotIds?.length ?? 0 };
}).sort((left, right) => left.name.localeCompare(right.name)),
};
}
const sourceEngine = await factory({ wasmBinary });
const sourceHandle = sourceEngine._web_engine_create();
open(sourceEngine, sourceHandle);
const source = sourceSummary(readSnapshot(sourceEngine, sourceHandle));
sourceEngine._web_engine_destroy(sourceHandle);
const collapse = {};
for (const ratio of [1, 0.9, 0.8, 0.75, 0.7, 0.65, 0.5, 0.25]) {
const engine = await factory({ wasmBinary });
const handle = engine._web_engine_create();
open(engine, handle);
const snapshot = readSnapshot(engine, handle);
const mesh = snapshot.meshes.find((candidate) => candidate.id === "mesh:Cube.001");
apply(engine, handle, { type: "decimateMesh", meshId: mesh.id, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio, triangulate: false, useSymmetry: false } });
const output = readSnapshot(engine, handle).meshes.find((candidate) => candidate.id === mesh.id);
collapse[String(ratio)] = { vertices: output.vertexCount, triangles: output.triangleCount ?? Math.floor((output.indices?.length ?? 0) / 3) };
engine._web_engine_destroy(handle);
}
const actual = { fixture: expected.fixture, source, collapse };
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Blender golden mismatch\nexpected=${JSON.stringify(expected)}\nactual=${JSON.stringify(actual)}`);
}
console.log(`blender-golden-ok fixture=${expected.fixture} ratios=${Object.keys(collapse).join(",")}`);

View File

@@ -0,0 +1,578 @@
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
const emptyBlend = fs.readFileSync(new URL("../../tests/files/web/empty.blend", import.meta.url));
const attributeBlend = fs.readFileSync(new URL("../../tests/files/web/attribute_scene.blend", import.meta.url));
const animationBlend = fs.readFileSync(new URL("../../tests/files/web/animation_scene.blend", import.meta.url));
const riggedShapeBlend = fs.readFileSync(new URL("../../tests/files/web/rigged_shape_scene.blend", import.meta.url));
const blend = fs.readFileSync(new URL("../../tests/files/web/basic_scene.blend", import.meta.url));
const invalidBlend = Uint8Array.from([0x42, 0x41, 0x44, 0x00]);
const engine = await factory({ wasmBinary });
function openFixture(engineHandle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
engine.HEAPU8.set(bytes, pointer);
const result = engine._web_engine_open_blend(engineHandle, pointer, bytes.byteLength);
engine._free(pointer);
return result;
}
function readHandleSnapshot(engineHandle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
const result = engine._web_engine_get_scene_snapshot(engineHandle, dataOut, lengthOut);
if (result !== 0) throw new Error("mode smoke snapshot request failed");
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const snapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
engine._free(dataOut);
engine._free(lengthOut);
return snapshot;
}
function readHandleDepsgraph(engineHandle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
const result = engine._web_engine_evaluate_depsgraph(engineHandle, dataOut, lengthOut);
if (result !== 0) {
const error = engine.UTF8ToString(engine._web_engine_last_error_message());
engine._free(dataOut);
engine._free(lengthOut);
return { unavailable: true, code: result, error };
}
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const report = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
engine._free(dataOut);
engine._free(lengthOut);
return report;
}
function applyHandleCommand(engineHandle, command) {
const bytes = new TextEncoder().encode(JSON.stringify(command));
const pointer = engine._malloc(bytes.byteLength);
engine.HEAPU8.set(bytes, pointer);
const result = engine._web_engine_apply_command(engineHandle, pointer, bytes.byteLength);
engine._free(pointer);
return result;
}
function saveHandleBlend(engineHandle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
const result = engine._web_engine_save_blend(engineHandle, dataOut, lengthOut);
if (result !== 0) {
const message = engine.UTF8ToString(engine._web_engine_last_error_message());
engine._free(dataOut);
engine._free(lengthOut);
throw new Error(`blend save failed: ${message}`);
}
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const bytes = engine.HEAPU8.slice(pointer, pointer + length);
engine._web_engine_free_buffer(pointer);
engine._free(dataOut);
engine._free(lengthOut);
return bytes;
}
const handle = engine._web_engine_create();
if (handle !== 1 || engine._web_engine_get_live_handles() !== 1) {
throw new Error(`unexpected engine state: handle=${handle}`);
}
const invalidInput = engine._malloc(invalidBlend.byteLength);
engine.HEAPU8.set(invalidBlend, invalidInput);
if (engine._web_engine_open_blend(handle, invalidInput, invalidBlend.byteLength) !== -4) {
throw new Error("invalid blend input should return BLEND_READ_FAILED");
}
engine._free(invalidInput);
const emptyInput = engine._malloc(emptyBlend.byteLength);
engine.HEAPU8.set(emptyBlend, emptyInput);
if (engine._web_engine_open_blend(handle, emptyInput, emptyBlend.byteLength) !== 0) {
throw new Error(`empty.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(emptyInput);
const attributeInput = engine._malloc(attributeBlend.byteLength);
engine.HEAPU8.set(attributeBlend, attributeInput);
if (engine._web_engine_open_blend(handle, attributeInput, attributeBlend.byteLength) !== 0) {
throw new Error(`attribute_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(attributeInput);
const attributeDataOut = engine._malloc(4);
const attributeLengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_snapshot(handle, attributeDataOut, attributeLengthOut) !== 0) {
throw new Error("attribute snapshot request failed");
}
const attributePointer = engine.HEAPU32[attributeDataOut >>> 2];
const attributeLength = engine.HEAPU32[attributeLengthOut >>> 2];
const attributeSnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(attributePointer, attributePointer + attributeLength)));
const attributeMesh = attributeSnapshot.meshes[0];
const attributeMaterial = attributeSnapshot.materials.find((material) => material.id === "material:AttributeRed");
const attributeImage = attributeSnapshot.images.find((image) => image.id === "image:AttributeTexture");
if (attributeMesh.geometryStatus !== "available" || attributeMesh.faceCount !== 2 || attributeMesh.triangleCount !== 3 ||
attributeMesh.normals?.length !== 15 || attributeMesh.uvs?.length !== 14 || attributeMesh.colors?.length !== 28 ||
attributeMesh.triangleCornerIndices?.length !== 9 || attributeMesh.triangleMaterialIndices?.join(",") !== "0,0,1" ||
attributeMesh.materialSlotIds?.join(",") !== "material:AttributeRed,material:AttributeBlue" ||
attributeMesh.bounds?.min?.join(",") !== "-1,-1,0" || attributeMesh.bounds?.max?.join(",") !== "1,1,1" ||
!attributeMaterial || Math.abs(attributeMaterial.metallic - 0.25) > 1e-6 || Math.abs(attributeMaterial.roughness - 0.35) > 1e-6 ||
Math.abs(attributeMaterial.alpha - 0.8) > 1e-6 || Math.abs(attributeMaterial.ior - 1.33) > 1e-6 ||
attributeMaterial.imageIds?.join(",") !== "image:AttributeTexture" || !attributeMaterial.warnings?.includes("linked_input_not_evaluated:Base Color") ||
attributeImage?.mimeType !== "image/png" || !attributeImage.sourcePath?.endsWith("textures/attribute.png")) {
throw new Error(`unexpected attribute SceneIR: ${JSON.stringify(attributeMesh)}`);
}
engine._free(attributeDataOut);
engine._free(attributeLengthOut);
const animationInput = engine._malloc(animationBlend.byteLength);
engine.HEAPU8.set(animationBlend, animationInput);
if (engine._web_engine_open_blend(handle, animationInput, animationBlend.byteLength) !== 0) {
throw new Error(`animation_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(animationInput);
const animationDataOut = engine._malloc(4);
const animationLengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_snapshot(handle, animationDataOut, animationLengthOut) !== 0) {
throw new Error("animation snapshot request failed");
}
const animationPointer = engine.HEAPU32[animationDataOut >>> 2];
const animationLength = engine.HEAPU32[animationLengthOut >>> 2];
const animationSnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(animationPointer, animationPointer + animationLength)));
const animation = animationSnapshot.animations.find((item) => item.targetId === "object:AnimatedObject");
if (animationSnapshot.frame.start !== 1 || animationSnapshot.frame.end !== 10 || !animation ||
animation.frameStart !== 1 || animation.frameEnd !== 10 || animation.channels.length !== 3 ||
animation.channels.some((channel) => channel.keyframes.length !== 2) ||
animation.channels.find((channel) => channel.path === "location[2]")?.keyframes[1]?.value?.[0] !== 4) {
throw new Error(`unexpected animation SceneIR: ${JSON.stringify(animationSnapshot.animations)}`);
}
engine._free(animationDataOut);
engine._free(animationLengthOut);
const riggedInput = engine._malloc(riggedShapeBlend.byteLength);
engine.HEAPU8.set(riggedShapeBlend, riggedInput);
if (engine._web_engine_open_blend(handle, riggedInput, riggedShapeBlend.byteLength) !== 0) {
throw new Error(`rigged_shape_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(riggedInput);
const riggedSnapshot = readHandleSnapshot(handle);
const riggedMesh = riggedSnapshot.meshes.find((mesh) => mesh.id === "mesh:RiggedShapeMesh");
const riggedArmature = riggedSnapshot.armatures?.find((armature) => armature.id === "armature:RiggedArmature");
const riggedModifier = riggedMesh?.modifierStack?.find((modifier) => modifier.name === "Preview Decimate");
const riggedArmatureModifier = riggedMesh?.modifierStack?.find((modifier) => modifier.type === "ARMATURE");
if (!riggedMesh || riggedMesh.skinWeights?.boneNames?.join(",") !== "Root,Tip" ||
riggedMesh.skinWeights.indices?.length !== 16 || riggedMesh.skinWeights.weights?.length !== 16 ||
riggedMesh.skinWeights.armatureId !== riggedArmature?.id || riggedMesh.skinWeights.jointIds?.length !== 2 ||
riggedArmature?.bones?.length !== 2 || riggedArmature.bones[1]?.parentId !== riggedArmature.bones[0]?.id ||
riggedArmature.bones.some((bone) => bone.restMatrix.length !== 16 || bone.poseMatrix?.length !== 16) ||
riggedMesh.skinWeights.bindMatrix?.[12] === 0 ||
riggedMesh.shapeKeys?.length !== 1 || riggedMesh.shapeKeys[0]?.name !== "Smile" ||
riggedMesh.shapeKeys[0]?.positions?.[8] !== 0.5 || !riggedModifier ||
riggedModifier.type !== "DECIMATE" || riggedModifier.parameters?.ratio !== 0.75 ||
riggedArmatureModifier?.parameters?.armatureObjectId !== "object:RiggedArmatureObject" ||
riggedArmatureModifier?.parameters?.targetObjectIds?.[0] !== "object:RiggedArmatureObject" ||
riggedArmatureModifier?.dependsOn?.[0] !== "object:RiggedArmatureObject" ||
!riggedModifier.enabled || riggedModifier.showRender) {
throw new Error(`unexpected rigged SceneIR: ${JSON.stringify(riggedMesh)}`);
}
const riggedDepsgraph = readHandleDepsgraph(handle);
if (riggedDepsgraph.unavailable) {
if (riggedDepsgraph.code !== -3 || !riggedDepsgraph.error.includes("headless frame evaluation is memory-safe")) {
throw new Error(`Depsgraph did not fail safely: ${JSON.stringify(riggedDepsgraph)}`);
}
}
else {
const evaluatedRiggedMesh = riggedDepsgraph.meshes?.find((mesh) => mesh.sourceMeshId === "mesh:RiggedShapeMesh");
if (riggedDepsgraph.engine !== "BlenderDepsgraph" || riggedDepsgraph.status !== "EVALUATED" ||
riggedDepsgraph.meshObjectCount < 1 || !evaluatedRiggedMesh ||
evaluatedRiggedMesh.positions?.length !== evaluatedRiggedMesh.vertexCount * 3 ||
evaluatedRiggedMesh.indices?.length !== evaluatedRiggedMesh.triangleCount * 3 ||
evaluatedRiggedMesh.modifierCount < 1) {
throw new Error(`unexpected Blender Depsgraph report: ${JSON.stringify(riggedDepsgraph)}`);
}
}
const modifierUuid = riggedModifier.uuid;
if (applyHandleCommand(handle, { type: "setModifierEnabled", meshId: riggedMesh.id, modifierUuid, enabled: false }) !== 0) {
throw new Error(`setModifierEnabled failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const modifierDisabled = readHandleSnapshot(handle);
if (modifierDisabled.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== false ||
modifierDisabled.revision !== riggedSnapshot.revision + 1) {
throw new Error(`modifier disable did not update SceneIR: ${JSON.stringify(modifierDisabled.meshes[0]?.modifierStack)}`);
}
const modifierSaveDataOut = engine._malloc(4);
const modifierSaveLengthOut = engine._malloc(4);
if (engine._web_engine_save_blend(handle, modifierSaveDataOut, modifierSaveLengthOut) !== 0) {
throw new Error(`modifier authoritative save failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const modifierSavedPointer = engine.HEAPU32[modifierSaveDataOut >>> 2];
const modifierSavedLength = engine.HEAPU32[modifierSaveLengthOut >>> 2];
const modifierSaved = engine.HEAPU8.slice(modifierSavedPointer, modifierSavedPointer + modifierSavedLength);
engine._web_engine_free_buffer(modifierSavedPointer);
engine._free(modifierSaveDataOut);
engine._free(modifierSaveLengthOut);
const reopenedModifierHandle = engine._web_engine_create();
if (openFixture(reopenedModifierHandle, modifierSaved) !== 0) {
throw new Error(`modifier save could not be reopened: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const reopenedModifierSnapshot = readHandleSnapshot(reopenedModifierHandle);
if (reopenedModifierSnapshot.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== false) {
throw new Error("modifier save lost disabled state");
}
engine._web_engine_destroy(reopenedModifierHandle);
if (engine._web_engine_undo(handle) !== 0) throw new Error("modifier undo failed");
const modifierUndone = readHandleSnapshot(handle);
if (modifierUndone.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== true) {
throw new Error("modifier undo did not restore enabled state");
}
const modifierUndoSaved = saveHandleBlend(handle);
const modifierUndoHandle = engine._web_engine_create();
if (openFixture(modifierUndoHandle, modifierUndoSaved) !== 0 ||
readHandleSnapshot(modifierUndoHandle).meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== true) {
throw new Error("modifier undo did not restore an authoritative, reopenable Main");
}
engine._web_engine_destroy(modifierUndoHandle);
if (engine._web_engine_redo(handle) !== 0) throw new Error("modifier redo failed");
const modifierRedone = readHandleSnapshot(handle);
if (modifierRedone.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== false) {
throw new Error("modifier redo did not restore disabled state");
}
saveHandleBlend(handle);
const armatureModifier = modifierRedone.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.type === "ARMATURE");
if (!armatureModifier || applyHandleCommand(handle, { type: "setModifierEnabled", meshId: riggedMesh.id, modifierUuid: armatureModifier.uuid, enabled: false }) !== 0) {
throw new Error(`armature modifier disable failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const skinSource = readHandleSnapshot(handle);
const skinProfile = {
schemaVersion: 1,
sourceMeshRevision: skinSource.revision,
attributePolicy: "PRESERVE",
mode: "COLLAPSE",
ratio: 0.5,
vertexGroup: null,
vertexGroupFactor: 1,
vertexGroupInvert: false,
triangulate: false,
useSymmetry: false,
symmetryAxis: 0,
symmetryTolerance: 1e-4,
skinPolicy: { maxInfluences: 4, minWeight: 0.001, maxPositionError: 0.001, shapeKeys: "PRESERVE" },
};
const { skinPolicy: ignoredSkinPolicy, ...skinlessProfile } = skinProfile;
if (applyHandleCommand(handle, { type: "decimateMesh", meshId: riggedMesh.id, profile: skinlessProfile }) !== -3 ||
!engine.UTF8ToString(engine._web_engine_last_error_message()).includes("requires skinPolicy")) {
throw new Error("skinless decimate was not safely blocked");
}
if (applyHandleCommand(handle, { type: "decimateMesh", meshId: riggedMesh.id, profile: skinProfile }) !== 0) {
throw new Error(`skin-aware decimate failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const skinDecimated = readHandleSnapshot(handle).meshes.find((mesh) => mesh.id === riggedMesh.id);
if (!skinDecimated?.skinWeights || skinDecimated.skinWeights.indices.length !== skinDecimated.vertexCount * 4 ||
skinDecimated.skinWeights.weights.length !== skinDecimated.vertexCount * 4 ||
skinDecimated.skinWeights.jointIds?.join(",") !== riggedMesh.skinWeights.jointIds?.join(",") ||
skinDecimated.skinWeights.bindMatrix?.join(",") !== riggedMesh.skinWeights.bindMatrix?.join(",") ||
skinDecimated.shapeKeys?.some((shape) => shape.positions.length !== skinDecimated.vertexCount * 3) ||
!skinDecimated.shapeKeys?.[0]?.positions.some((value) => Math.abs(value) > 0)) {
throw new Error(`skin-aware decimate did not remap attributes: ${JSON.stringify(skinDecimated)}`);
}
for (let vertex = 0; vertex < skinDecimated.vertexCount; vertex++) {
const total = skinDecimated.skinWeights.weights.slice(vertex * 4, vertex * 4 + 4).reduce((sum, weight) => sum + weight, 0);
if (Math.abs(total - 1) > 1e-5) throw new Error(`skin weights are not normalized at vertex ${vertex}`);
}
const skinSaveDataOut = engine._malloc(4);
const skinSaveLengthOut = engine._malloc(4);
if (engine._web_engine_save_blend(handle, skinSaveDataOut, skinSaveLengthOut) !== -3 ||
!engine.UTF8ToString(engine._web_engine_last_error_message()).includes("BLEND_WRITE_REQUIRES_MAIN_AUTHORITY")) {
throw new Error("skin/shape-key decimate should remain structurally blocked from save");
}
engine._free(skinSaveDataOut);
engine._free(skinSaveLengthOut);
const input = engine._malloc(blend.byteLength);
engine.HEAPU8.set(blend, input);
if (engine._web_engine_open_blend(handle, input, blend.byteLength) !== 0) {
throw new Error(`basic_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(input);
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut) !== 0) {
throw new Error("snapshot request failed");
}
const snapshotPointer = engine.HEAPU32[dataOut >>> 2];
const snapshotLength = engine.HEAPU32[lengthOut >>> 2];
const snapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(snapshotPointer, snapshotPointer + snapshotLength)));
if (snapshot.schemaVersion !== 1 || snapshot.nodes.length !== 3 || snapshot.meshes[0].vertexCount !== 8 ||
snapshot.meshes[0].geometryStatus !== "available" || snapshot.meshes[0].positions.length !== 24 || snapshot.meshes[0].indices.length !== 36 ||
snapshot.nodes.some((node) => node.worldMatrix.length !== 16 || typeof node.selectable !== "boolean") ||
!Array.isArray(snapshot.images) || !Array.isArray(snapshot.animations) ||
snapshot.cameras[0]?.sensorWidthMm !== 36 || snapshot.cameras[0]?.sensorHeightMm !== 24 ||
snapshot.lights[0]?.areaSize !== 5 || snapshot.lights[0]?.areaSizeY !== 1 ||
snapshot.scenes[0]?.worldId !== "world:World" || snapshot.worlds[0]?.id !== "world:World") {
throw new Error(`unexpected SceneIR: ${JSON.stringify({ nodes: snapshot.nodes.length, meshes: snapshot.meshes.length })}`);
}
if (engine._web_engine_apply_command(handle, 0, 0) !== -2) {
throw new Error("empty command input should return INVALID_ARGUMENT");
}
const frameCommand = new TextEncoder().encode(JSON.stringify({ type: "setFrame", frame: 12 }));
const frameCommandPointer = engine._malloc(frameCommand.byteLength);
engine.HEAPU8.set(frameCommand, frameCommandPointer);
if (engine._web_engine_apply_command(handle, frameCommandPointer, frameCommand.byteLength) !== 0) {
throw new Error(`setFrame command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(frameCommandPointer);
const frameDeltaDataOut = engine._malloc(4);
const frameDeltaLengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_delta(handle, frameDeltaDataOut, frameDeltaLengthOut) !== 0) {
throw new Error("setFrame SceneDelta request failed");
}
const frameDeltaPointer = engine.HEAPU32[frameDeltaDataOut >>> 2];
const frameDeltaLength = engine.HEAPU32[frameDeltaLengthOut >>> 2];
const frameDelta = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(frameDeltaPointer, frameDeltaPointer + frameDeltaLength)));
const frameRevision = snapshot.revision + 1;
if (frameDelta.baseRevision !== snapshot.revision || frameDelta.nextRevision !== frameRevision || frameDelta.frame?.current !== 12) {
throw new Error(`unexpected setFrame SceneDelta: ${JSON.stringify(frameDelta)}`);
}
engine._free(frameDeltaDataOut);
engine._free(frameDeltaLengthOut);
const visibilityCommand = new TextEncoder().encode(JSON.stringify({ type: "setObjectVisibility", objectId: "object:BasicCube", visible: false }));
const visibilityCommandPointer = engine._malloc(visibilityCommand.byteLength);
engine.HEAPU8.set(visibilityCommand, visibilityCommandPointer);
if (engine._web_engine_apply_command(handle, visibilityCommandPointer, visibilityCommand.byteLength) !== 0) {
throw new Error(`setObjectVisibility command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(visibilityCommandPointer);
const visibilityRevision = frameRevision + 1;
const visibilityDataOut = engine._malloc(4);
const visibilityLengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_snapshot(handle, visibilityDataOut, visibilityLengthOut) !== 0) {
throw new Error("visibility snapshot request failed");
}
const visibilitySnapshotPointer = engine.HEAPU32[visibilityDataOut >>> 2];
const visibilitySnapshotLength = engine.HEAPU32[visibilityLengthOut >>> 2];
const visibilitySnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(visibilitySnapshotPointer, visibilitySnapshotPointer + visibilitySnapshotLength)));
if (visibilitySnapshot.revision !== visibilityRevision || visibilitySnapshot.nodes.find((node) => node.id === "object:BasicCube")?.visible !== false) {
throw new Error(`unexpected visibility snapshot: ${JSON.stringify(visibilitySnapshot)}`);
}
engine._free(visibilityDataOut);
engine._free(visibilityLengthOut);
const authoritySaveDataOut = engine._malloc(4);
const authoritySaveLengthOut = engine._malloc(4);
if (engine._web_engine_save_blend(handle, authoritySaveDataOut, authoritySaveLengthOut) !== 0) {
throw new Error(`authoritative save request failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const authoritySavedPointer = engine.HEAPU32[authoritySaveDataOut >>> 2];
const authoritySavedLength = engine.HEAPU32[authoritySaveLengthOut >>> 2];
if (authoritySavedPointer === 0 || authoritySavedLength === 0) {
throw new Error("authoritative save produced an empty buffer");
}
const authoritySaved = engine.HEAPU8.slice(authoritySavedPointer, authoritySavedPointer + authoritySavedLength);
engine._web_engine_free_buffer(authoritySavedPointer);
engine._free(authoritySaveDataOut);
engine._free(authoritySaveLengthOut);
const reopenedAuthorityHandle = engine._web_engine_create();
if (openFixture(reopenedAuthorityHandle, authoritySaved) !== 0) {
throw new Error(`authoritative save could not be reopened: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const reopenedAuthoritySnapshot = readHandleSnapshot(reopenedAuthorityHandle);
if (reopenedAuthoritySnapshot.frame?.current !== 12 ||
reopenedAuthoritySnapshot.nodes.find((node) => node.id === "object:BasicCube")?.visible !== false) {
throw new Error(`authoritative save lost edits: ${JSON.stringify(reopenedAuthoritySnapshot.frame)}`);
}
engine._web_engine_destroy(reopenedAuthorityHandle);
const decimateMesh = visibilitySnapshot.meshes.find((mesh) => mesh.id === visibilitySnapshot.nodes.find((node) => node.id === "object:BasicCube")?.dataId) ?? visibilitySnapshot.meshes[0];
const decimateProfile = {
schemaVersion: 1,
sourceMeshRevision: visibilitySnapshot.revision,
attributePolicy: "PRESERVE",
mode: "COLLAPSE",
ratio: 0.5,
vertexGroup: null,
vertexGroupFactor: 1,
vertexGroupInvert: false,
triangulate: false,
useSymmetry: false,
symmetryAxis: 0,
symmetryTolerance: 1e-4,
};
const decimateCommand = new TextEncoder().encode(JSON.stringify({ type: "decimateMesh", meshId: decimateMesh.id, profile: decimateProfile }));
const decimateCommandPointer = engine._malloc(decimateCommand.byteLength);
engine.HEAPU8.set(decimateCommand, decimateCommandPointer);
if (engine._web_engine_apply_command(handle, decimateCommandPointer, decimateCommand.byteLength) !== 0) {
throw new Error(`decimateMesh command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
engine._free(decimateCommandPointer);
const decimateDataOut = engine._malloc(4);
const decimateLengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_snapshot(handle, decimateDataOut, decimateLengthOut) !== 0) {
throw new Error("decimate snapshot request failed");
}
const decimatePointer = engine.HEAPU32[decimateDataOut >>> 2];
const decimateLength = engine.HEAPU32[decimateLengthOut >>> 2];
const decimateSnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(decimatePointer, decimatePointer + decimateLength)));
const decimatedMesh = decimateSnapshot.meshes.find((mesh) => mesh.id === decimateMesh.id);
const decimateRevision = visibilityRevision + 1;
if (decimateSnapshot.revision !== decimateRevision || !decimatedMesh || decimatedMesh.triangleCount <= 0 ||
decimatedMesh.triangleCount >= decimateMesh.triangleCount ||
decimatedMesh.vertexCount >= decimateMesh.vertexCount || decimatedMesh.positions?.length !== decimatedMesh.vertexCount * 3 ||
decimatedMesh.indices?.length !== decimatedMesh.triangleCount * 3 || decimatedMesh.indices.some((index) => index >= decimatedMesh.vertexCount) ||
decimatedMesh.normals?.length !== decimatedMesh.vertexCount * 3 ||
decimatedMesh.triangleCornerIndices?.length !== decimatedMesh.triangleCount * 3 ||
decimatedMesh.uvs?.length !== decimatedMesh.triangleCount * 6 ||
decimatedMesh.triangleMaterialIndices?.length !== decimatedMesh.triangleCount) {
throw new Error(`unexpected decimated mesh: ${JSON.stringify(decimatedMesh)}`);
}
engine._free(decimateDataOut);
engine._free(decimateLengthOut);
engine._free(dataOut);
engine._free(lengthOut);
const decimateSaved = saveHandleBlend(handle);
const decimateSavedHandle = engine._web_engine_create();
if (openFixture(decimateSavedHandle, decimateSaved) !== 0 ||
readHandleSnapshot(decimateSavedHandle).meshes.find((mesh) => mesh.id === decimateMesh.id)?.triangleCount !== decimatedMesh.triangleCount) {
throw new Error("authoritative decimate did not survive save/reopen");
}
engine._web_engine_destroy(decimateSavedHandle);
if (engine._web_engine_undo(handle) !== 0) throw new Error("authoritative decimate undo failed");
const decimateUndone = readHandleSnapshot(handle);
if (decimateUndone.meshes.find((mesh) => mesh.id === decimateMesh.id)?.triangleCount !== decimateMesh.triangleCount) {
throw new Error("snapshot-only undo did not restore original geometry");
}
saveHandleBlend(handle);
if (engine._web_engine_redo(handle) !== 0) throw new Error("authoritative decimate redo failed");
const decimateRedone = readHandleSnapshot(handle);
if (decimateRedone.meshes.find((mesh) => mesh.id === decimateMesh.id)?.triangleCount !== decimatedMesh.triangleCount) {
throw new Error("snapshot-only redo did not restore decimated geometry");
}
if (applyHandleCommand(handle, { type: "setFrame", frame: 2 }) !== 0) {
throw new Error("Main command failed after authoritative decimate redo");
}
saveHandleBlend(handle);
const postHistoryRevision = decimateRevision + 3;
const emptyAgainInput = engine._malloc(emptyBlend.byteLength);
engine.HEAPU8.set(emptyBlend, emptyAgainInput);
if (engine._web_engine_open_blend(handle, emptyAgainInput, emptyBlend.byteLength) !== 0) {
throw new Error("second empty.blend open failed");
}
engine._free(emptyAgainInput);
const deltaDataOut = engine._malloc(4);
const deltaLengthOut = engine._malloc(4);
if (engine._web_engine_get_scene_delta(handle, deltaDataOut, deltaLengthOut) !== 0) {
throw new Error("native SceneDelta request failed");
}
const deltaPointer = engine.HEAPU32[deltaDataOut >>> 2];
const deltaLength = engine.HEAPU32[deltaLengthOut >>> 2];
const delta = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(deltaPointer, deltaPointer + deltaLength)));
if (delta.schemaVersion !== 1 || delta.baseRevision !== postHistoryRevision || delta.nextRevision !== postHistoryRevision + 1 ||
!delta.nodes?.removed?.includes("object:BasicCube")) {
throw new Error(`unexpected native SceneDelta: ${JSON.stringify(delta)}`);
}
engine._free(deltaDataOut);
engine._free(deltaLengthOut);
for (const modeProfile of [
{ mode: "UNSUBDIV", iterations: 1 },
{ mode: "DISSOLVE_PLANAR", angleLimit: 0.08726646, useDissolveBoundaries: false, delimit: ["MATERIAL"] },
]) {
const modeHandle = engine._web_engine_create();
if (openFixture(modeHandle, blend) !== 0) throw new Error(`${modeProfile.mode} fixture open failed`);
const modeSource = readHandleSnapshot(modeHandle);
const modeMesh = modeSource.meshes[0];
const modeResult = applyHandleCommand(modeHandle, {
type: "decimateMesh",
meshId: modeMesh.id,
profile: { schemaVersion: 1, sourceMeshRevision: modeSource.revision, attributePolicy: "PRESERVE", ...modeProfile },
});
if (modeResult !== 0) {
throw new Error(`${modeProfile.mode} failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const modeSnapshot = readHandleSnapshot(modeHandle);
const modeOutput = modeSnapshot.meshes[0];
if (modeSnapshot.revision !== modeSource.revision + 1 || !modeOutput || modeOutput.triangleCount <= 0 ||
modeOutput.positions?.length !== modeOutput.vertexCount * 3 || modeOutput.indices?.length !== modeOutput.triangleCount * 3 ||
modeOutput.normals?.length !== modeOutput.vertexCount * 3 || modeOutput.uvs?.length !== modeOutput.triangleCount * 6 ||
modeOutput.triangleMaterialIndices?.length !== modeOutput.triangleCount) {
throw new Error(`unexpected ${modeProfile.mode} result: ${JSON.stringify(modeOutput)}`);
}
saveHandleBlend(modeHandle);
engine._web_engine_destroy(modeHandle);
}
const modelingHandle = engine._web_engine_create();
if (openFixture(modelingHandle, emptyBlend) !== 0) throw new Error("modeling fixture open failed");
const modelingStart = readHandleSnapshot(modelingHandle);
if (applyHandleCommand(modelingHandle, {
type: "createPrimitive",
primitive: "CUBE",
name: "WebCube",
location: [1, 2, 3],
}) !== 0) {
throw new Error(`createPrimitive failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
const created = readHandleSnapshot(modelingHandle);
const cubeNode = created.nodes.find((node) => node.name === "WebCube");
const cubeMesh = created.meshes.find((mesh) => mesh.id === cubeNode?.dataId);
if (created.revision !== modelingStart.revision + 1 || !cubeNode || !cubeMesh ||
cubeMesh.vertexCount !== 8 || cubeMesh.triangleCount !== 12 ||
JSON.stringify(cubeNode.transform.translation) !== JSON.stringify([1, 2, 3])) {
throw new Error(`unexpected created primitive: ${JSON.stringify({ node: cubeNode, mesh: cubeMesh })}`);
}
if (applyHandleCommand(modelingHandle, {
type: "setObjectTransform",
objectId: cubeNode.id,
translation: [2, 3, 4],
rotationEuler: [0.1, 0.2, 0.3],
scale: [1.5, 0.75, 2],
}) !== 0) throw new Error("setObjectTransform failed");
if (applyHandleCommand(modelingHandle, {
type: "translateMeshVertices",
meshId: cubeMesh.id,
vertexIndices: [0, 1],
offset: [0, 0, 0.5],
}) !== 0) throw new Error("translateMeshVertices failed");
if (applyHandleCommand(modelingHandle, {
type: "duplicateObject",
objectId: cubeNode.id,
offset: [0.5, 0, 0],
}) !== 0) throw new Error("duplicateObject failed");
const duplicated = readHandleSnapshot(modelingHandle);
const duplicateNode = duplicated.nodes.find((node) => node.id !== cubeNode.id && node.name.startsWith("WebCube"));
if (!duplicateNode || duplicated.nodes.length !== 2 || duplicated.meshes.length !== 2) {
throw new Error(`duplicateObject did not create independent object/mesh IDs: ${JSON.stringify(duplicated.nodes)}`);
}
if (applyHandleCommand(modelingHandle, { type: "deleteObject", objectId: duplicateNode.id }) !== 0) {
throw new Error("deleteObject failed");
}
const deleted = readHandleSnapshot(modelingHandle);
if (deleted.nodes.some((node) => node.id === duplicateNode.id)) throw new Error("deleteObject left a scene node");
if (engine._web_engine_undo(modelingHandle) !== 0 ||
!readHandleSnapshot(modelingHandle).nodes.some((node) => node.id === duplicateNode.id)) {
throw new Error("modeling undo failed");
}
if (engine._web_engine_redo(modelingHandle) !== 0 ||
readHandleSnapshot(modelingHandle).nodes.some((node) => node.id === duplicateNode.id)) {
throw new Error("modeling redo failed");
}
const modelingSaved = saveHandleBlend(modelingHandle);
const modelingReopenHandle = engine._web_engine_create();
if (openFixture(modelingReopenHandle, modelingSaved) !== 0) throw new Error("modeling save/reopen failed");
const reopenedModel = readHandleSnapshot(modelingReopenHandle);
const reopenedCube = reopenedModel.nodes.find((node) => node.name === "WebCube");
if (!reopenedCube || JSON.stringify(reopenedCube.transform.translation) !== JSON.stringify([2, 3, 4]) ||
reopenedModel.nodes.length !== 1) {
throw new Error(`modeling edits did not survive save/reopen: ${JSON.stringify(reopenedModel.nodes)}`);
}
engine._web_engine_destroy(modelingReopenHandle);
engine._web_engine_destroy(modelingHandle);
engine._web_engine_destroy(handle);
console.log(`web-engine-blend-ok handle=${handle} nodes=${snapshot.nodes.length} vertices=${snapshot.meshes[0].vertexCount} bytes=${authoritySavedLength} authoritativeDecimate=ok modeling=ok`);

View File

@@ -0,0 +1,12 @@
import fs from "node:fs";
const [manifestPath, wasmHash] = process.argv.slice(2);
if (!manifestPath || !/^[a-f0-9]{64}$/.test(wasmHash ?? "")) {
throw new Error("usage: update-engine-manifest.mjs <manifest.json> <wasm-sha256>");
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const resource = manifest.wasm?.find((entry) => entry.id === "web-engine-bootstrap");
if (!resource) throw new Error("web-engine-bootstrap resource is missing from the engine manifest");
resource.sha256 = wasmHash;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);