Add Chromium-only Blender WebEngine parity work
This commit is contained in:
BIN
tools/web/__pycache__/generate-modifier-fixtures.cpython-313.pyc
Normal file
BIN
tools/web/__pycache__/generate-modifier-fixtures.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tools/web/__pycache__/generate-modifier-goldens.cpython-313.pyc
Normal file
BIN
tools/web/__pycache__/generate-modifier-goldens.cpython-313.pyc
Normal file
Binary file not shown.
144
tools/web/blender-check-glb-roundtrip.py
Normal file
144
tools/web/blender-check-glb-roundtrip.py
Normal 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])
|
||||
62
tools/web/blender-check-nonmesh-glb-roundtrip.py
Normal file
62
tools/web/blender-check-nonmesh-glb-roundtrip.py
Normal 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])
|
||||
81
tools/web/blender-check-nonmesh-usd-roundtrip.py
Normal file
81
tools/web/blender-check-nonmesh-usd-roundtrip.py
Normal 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])
|
||||
20
tools/web/build-web-engine-variants.sh
Executable file
20
tools/web/build-web-engine-variants.sh
Executable 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
|
||||
424
tools/web/check-authoring-roundtrip.mjs
Normal file
424
tools/web/check-authoring-roundtrip.mjs
Normal 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
26
tools/web/check-baseline.sh
Executable 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'
|
||||
19
tools/web/check-collapse-ratios.mjs
Normal file
19
tools/web/check-collapse-ratios.mjs
Normal 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)}`);
|
||||
81
tools/web/check-depsgraph.mjs
Normal file
81
tools/web/check-depsgraph.mjs
Normal 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
18
tools/web/check-dual-engine.sh
Executable 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
26
tools/web/check-emscripten.sh
Executable 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"
|
||||
80
tools/web/check-frame-evaluation.mjs
Normal file
80
tools/web/check-frame-evaluation.mjs
Normal 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`);
|
||||
124
tools/web/check-glb-blender-roundtrip.mjs
Normal file
124
tools/web/check-glb-blender-roundtrip.mjs
Normal 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 });
|
||||
}
|
||||
137
tools/web/check-grease-pencil-roundtrip.mjs
Normal file
137
tools/web/check-grease-pencil-roundtrip.mjs
Normal 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");
|
||||
91
tools/web/check-image-resources.mjs
Normal file
91
tools/web/check-image-resources.mjs
Normal 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");
|
||||
146
tools/web/check-lighting-roundtrip.mjs
Normal file
146
tools/web/check-lighting-roundtrip.mjs
Normal 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
59
tools/web/check-local-deps.sh
Executable 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"
|
||||
134
tools/web/check-main-roundtrip.mjs
Normal file
134
tools/web/check-main-roundtrip.mjs
Normal 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");
|
||||
37
tools/web/check-malicious-blends.mjs
Normal file
37
tools/web/check-malicious-blends.mjs
Normal 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`);
|
||||
17
tools/web/check-manifest-assets.mjs
Normal file
17
tools/web/check-manifest-assets.mjs
Normal 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}`);
|
||||
26
tools/web/check-modifier-capability-matrix.mjs
Normal file
26
tools/web/check-modifier-capability-matrix.mjs
Normal 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`);
|
||||
53
tools/web/check-modifier-cycle.mjs
Normal file
53
tools/web/check-modifier-cycle.mjs
Normal 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");
|
||||
265
tools/web/check-modifier-goldens.mjs
Normal file
265
tools/web/check-modifier-goldens.mjs
Normal 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`);
|
||||
101
tools/web/check-nonmesh-desktop-golden.mjs
Normal file
101
tools/web/check-nonmesh-desktop-golden.mjs
Normal 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}`);
|
||||
186
tools/web/check-nonmesh-glb-blender-roundtrip.mjs
Normal file
186
tools/web/check-nonmesh-glb-blender-roundtrip.mjs
Normal 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 });
|
||||
}
|
||||
301
tools/web/check-nonmesh-roundtrip.mjs
Normal file
301
tools/web/check-nonmesh-roundtrip.mjs
Normal 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");
|
||||
165
tools/web/check-nonmesh-usd-blender-roundtrip.mjs
Normal file
165
tools/web/check-nonmesh-usd-blender-roundtrip.mjs
Normal 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 });
|
||||
}
|
||||
15
tools/web/check-offline-reproducibility.mjs
Normal file
15
tools/web/check-offline-reproducibility.mjs
Normal 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`);
|
||||
166
tools/web/check-paint-roundtrip.mjs
Normal file
166
tools/web/check-paint-roundtrip.mjs
Normal 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");
|
||||
113
tools/web/check-pose-constraint-goldens.mjs
Normal file
113
tools/web/check-pose-constraint-goldens.mjs
Normal 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`);
|
||||
41
tools/web/check-release-package.mjs
Normal file
41
tools/web/check-release-package.mjs
Normal 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`);
|
||||
81
tools/web/check-release-performance.mjs
Normal file
81
tools/web/check-release-performance.mjs
Normal 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`);
|
||||
17
tools/web/check-three-vendor.mjs
Normal file
17
tools/web/check-three-vendor.mjs
Normal 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}`);
|
||||
17
tools/web/check-topology-collapse.mjs
Normal file
17
tools/web/check-topology-collapse.mjs
Normal 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)}`);
|
||||
11
tools/web/configure-web-engine.sh
Executable file
11
tools/web/configure-web-engine.sh
Executable 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}"
|
||||
79
tools/web/create-offline-release.mjs
Normal file
79
tools/web/create-offline-release.mjs
Normal 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`);
|
||||
92
tools/web/diagnose-depsgraph.mjs
Normal file
92
tools/web/diagnose-depsgraph.mjs
Normal 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
28
tools/web/emscripten-env.sh
Executable 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
|
||||
28
tools/web/generate-animation-fixture.py
Normal file
28
tools/web/generate-animation-fixture.py
Normal 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])
|
||||
92
tools/web/generate-attribute-fixture.py
Normal file
92
tools/web/generate-attribute-fixture.py
Normal 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])
|
||||
47
tools/web/generate-blender-deformation-golden.py
Normal file
47
tools/web/generate-blender-deformation-golden.py
Normal 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])
|
||||
49
tools/web/generate-frame-evaluation-golden.py
Normal file
49
tools/web/generate-frame-evaluation-golden.py
Normal 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])
|
||||
119
tools/web/generate-image-resource-fixtures.py
Normal file
119
tools/web/generate-image-resource-fixtures.py
Normal 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])
|
||||
83
tools/web/generate-modifier-cycle-fixture.py
Normal file
83
tools/web/generate-modifier-cycle-fixture.py
Normal 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]))
|
||||
152
tools/web/generate-modifier-extended-fixture.py
Normal file
152
tools/web/generate-modifier-extended-fixture.py
Normal 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]))
|
||||
366
tools/web/generate-modifier-fixtures.py
Normal file
366
tools/web/generate-modifier-fixtures.py
Normal 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()
|
||||
144
tools/web/generate-modifier-goldens.py
Normal file
144
tools/web/generate-modifier-goldens.py
Normal 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()
|
||||
82
tools/web/generate-nonmesh-desktop-golden.py
Normal file
82
tools/web/generate-nonmesh-desktop-golden.py
Normal 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])
|
||||
183
tools/web/generate-nonmesh-fixture.py
Normal file
183
tools/web/generate-nonmesh-fixture.py
Normal 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])
|
||||
36
tools/web/generate-packed-image-fixture.py
Normal file
36
tools/web/generate-packed-image-fixture.py
Normal 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])
|
||||
130
tools/web/generate-pose-constraint-fixture.py
Normal file
130
tools/web/generate-pose-constraint-fixture.py
Normal 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])
|
||||
88
tools/web/generate-pose-constraint-golden.py
Normal file
88
tools/web/generate-pose-constraint-golden.py
Normal 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])
|
||||
67
tools/web/generate-rigged-fixture.py
Normal file
67
tools/web/generate-rigged-fixture.py
Normal 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])
|
||||
26
tools/web/generate-topology-fixture.py
Normal file
26
tools/web/generate-topology-fixture.py
Normal 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])
|
||||
19
tools/web/install-web-engine-assets.sh
Executable file
19
tools/web/install-web-engine-assets.sh
Executable 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"
|
||||
55
tools/web/probe-collapse-ratio.mjs
Normal file
55
tools/web/probe-collapse-ratio.mjs
Normal 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);
|
||||
36
tools/web/probe-topology-collapse.mjs
Normal file
36
tools/web/probe-topology-collapse.mjs
Normal 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);
|
||||
74
tools/web/run-blender-golden.mjs
Normal file
74
tools/web/run-blender-golden.mjs
Normal 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(",")}`);
|
||||
578
tools/web/run-web-engine-smoke.mjs
Normal file
578
tools/web/run-web-engine-smoke.mjs
Normal 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`);
|
||||
12
tools/web/update-engine-manifest.mjs
Normal file
12
tools/web/update-engine-manifest.mjs
Normal 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`);
|
||||
Reference in New Issue
Block a user