Advance M8-M11 parity workflows
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-17 04:37:07 -04:00
parent 7c16b279ae
commit 0fe8d2bb56
324 changed files with 31920 additions and 863 deletions

View File

@@ -18,7 +18,7 @@ target_include_directories(vdb_toolchain INTERFACE "${OPENVDB_INCLUDE_DIR}" "${T
target_compile_definitions(vdb_toolchain INTERFACE NANOVDB_USE_OPENVDB)
target_link_libraries(vdb_toolchain INTERFACE "${OPENVDB_LIBRARY}" "${TBB_LIBRARY}")
foreach(target vdb_fixture_generator vdb_to_nanovdb)
foreach(target vdb_fixture_generator vdb_to_nanovdb vdb_volume_golden)
add_executable(${target} "${target}.cc")
target_link_libraries(${target} PRIVATE vdb_toolchain)
set_target_properties(${target} PROPERTIES

View File

@@ -0,0 +1,86 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb"));
const source = path.join(resourceRoot, "generated", "generated-smoke.vdb");
const generator = path.join(root, "build_vdb_tools", "vdb_volume_golden");
const committedRoot = path.join(root, "tests", "golden", "M8-19");
const check = process.argv.includes("--check");
const outputRoot = check ? fs.mkdtempSync(path.join(os.tmpdir(), "vdb-volume-golden-")) : committedRoot;
const outputPrefix = path.join(outputRoot, "generated-smoke");
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
if (!fs.existsSync(generator)) throw new Error(`VDB_VOLUME_GOLDEN_TOOL_MISSING: ${generator}`);
if (!fs.existsSync(source)) throw new Error(`VDB_VOLUME_GOLDEN_SOURCE_MISSING: ${source}`);
fs.mkdirSync(outputRoot, { recursive: true });
try {
const result = spawnSync(generator, [source, outputPrefix], { encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr || `vdb_volume_golden exited ${result.status}`);
const native = JSON.parse(result.stdout.trim());
const axes = ["X", "Y", "Z"];
const images = axes.map((axis) => {
const fileName = `generated-smoke-${axis.toLowerCase()}.rgba`;
const file = path.join(outputRoot, fileName);
const bytes = fs.readFileSync(file);
let alphaPixels = 0;
for (let index = 3; index < bytes.byteLength; index += 4) if (bytes[index] > 0) alphaPixels++;
return { axis, file: fileName, byteLength: bytes.byteLength, sha256: sha256(file), alphaPixels };
});
const manifest = {
schemaVersion: 1,
taskId: "M8-19",
source: {
resourceId: "generated-smoke-vdb",
sha256: sha256(source),
grid: native.grid,
activeVoxelCount: native.activeVoxelCount,
indexBounds: native.indexBounds,
},
native: {
openVDBVersion: native.openVDBVersion,
generatorSourceSha256: sha256(path.join(root, "tools", "vdb", "vdb_volume_golden.cc")),
},
renderContract: {
shaderSemanticVersion: "volume-wgsl-v1",
width: native.width,
height: native.height,
format: "RGBA8_UNORM",
interpolation: "LINEAR",
densityScale: 1,
emissionScale: 0,
anisotropy: 0,
color: [0.72, 0.78, 0.86],
axes,
thresholds: {
maxChannelError: 2,
meanAbsoluteError: 0.1,
rmsError: 0.5,
alphaCoverageDeltaRatio: 0.002,
},
},
images,
};
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
const manifestFile = path.join(outputRoot, "manifest.json");
fs.writeFileSync(manifestFile, manifestText);
if (check) {
for (const image of images) {
const expected = fs.readFileSync(path.join(committedRoot, image.file));
const actual = fs.readFileSync(path.join(outputRoot, image.file));
if (!expected.equals(actual)) throw new Error(`VDB_VOLUME_GOLDEN_MISMATCH: ${image.file}`);
}
const expectedManifest = fs.readFileSync(path.join(committedRoot, "manifest.json"), "utf8");
if (expectedManifest !== manifestText) throw new Error("VDB_VOLUME_GOLDEN_MISMATCH: manifest.json");
}
process.stdout.write(`vdb-volume-golden-${check ? "check" : "generated"} axes=3 bytes=${images.reduce((total, image) => total + image.byteLength, 0)} sha256=${images.map((image) => image.sha256).join(",")}\n`);
}
finally {
if (check) fs.rmSync(outputRoot, { recursive: true, force: true });
}

View File

@@ -0,0 +1,179 @@
#include <openvdb/openvdb.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
namespace fs = std::filesystem;
namespace {
constexpr int kImageSize = 64;
constexpr std::array<float, 3> kColor = {0.72f, 0.78f, 0.86f};
float sample_linear(const openvdb::FloatGrid::ConstAccessor &accessor,
const std::array<float, 3> &position)
{
const std::array<int, 3> base = {
static_cast<int>(std::floor(position[0])),
static_cast<int>(std::floor(position[1])),
static_cast<int>(std::floor(position[2])),
};
const std::array<float, 3> fraction = {
position[0] - static_cast<float>(base[0]),
position[1] - static_cast<float>(base[1]),
position[2] - static_cast<float>(base[2]),
};
float value = 0.0f;
for (int x = 0; x < 2; ++x) {
for (int y = 0; y < 2; ++y) {
for (int z = 0; z < 2; ++z) {
const float weight = (x ? fraction[0] : 1.0f - fraction[0]) *
(y ? fraction[1] : 1.0f - fraction[1]) *
(z ? fraction[2] : 1.0f - fraction[2]);
value += accessor.getValue(openvdb::Coord(base[0] + x, base[1] + y, base[2] + z)) *
weight;
}
}
}
return value;
}
uint8_t pack_unorm(float value)
{
return static_cast<uint8_t>(std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f));
}
std::vector<uint8_t> render_axis(const openvdb::FloatGrid &density,
const openvdb::CoordBBox &bounds,
int view_axis)
{
const auto accessor = density.getConstAccessor();
const openvdb::Coord minimum = bounds.min();
const openvdb::Coord maximum = bounds.max();
const std::array<int, 3> plane_a = view_axis == 0 ? std::array<int, 3>{1, 2, 0} :
view_axis == 1 ? std::array<int, 3>{0, 2, 1} :
std::array<int, 3>{0, 1, 2};
const int ray_axis = plane_a[2];
const int ray_min = minimum[ray_axis];
const int ray_max = maximum[ray_axis];
const int ray_count = std::max(1, ray_max - ray_min + 1);
const int stride = std::max(1, (ray_count + 255) / 256);
const float voxel_size = std::max(0.01f, static_cast<float>(density.voxelSize()[ray_axis]));
const float phase = 1.0f / 12.5663706f;
const float source_scale = 0.5f + 8.0f * phase;
std::vector<uint8_t> pixels(kImageSize * kImageSize * 4, 0);
for (int y = 0; y < kImageSize; ++y) {
for (int x = 0; x < kImageSize; ++x) {
const std::array<int, 2> plane_min = {minimum[plane_a[0]], minimum[plane_a[1]]};
const std::array<int, 2> plane_max = {maximum[plane_a[0]], maximum[plane_a[1]]};
const std::array<float, 2> extent = {
static_cast<float>(plane_max[0] - plane_min[0] + 1),
static_cast<float>(plane_max[1] - plane_min[1] + 1),
};
const std::array<float, 2> plane_position = {
static_cast<float>(plane_min[0]) +
((static_cast<float>(x) + 0.5f) / static_cast<float>(kImageSize)) * extent[0] - 0.5f,
static_cast<float>(plane_min[1]) +
((static_cast<float>(y) + 0.5f) / static_cast<float>(kImageSize)) * extent[1] - 0.5f,
};
float transmittance = 1.0f;
std::array<float, 3> radiance = {0.0f, 0.0f, 0.0f};
for (int ray = ray_min; ray <= ray_max; ray += stride) {
std::array<float, 3> position{};
position[plane_a[0]] = plane_position[0];
position[plane_a[1]] = plane_position[1];
position[ray_axis] = static_cast<float>(ray) + 0.5f;
const float sampled_density = std::max(0.0f, sample_linear(accessor, position));
const float alpha = 1.0f - std::exp(-sampled_density * voxel_size * static_cast<float>(stride));
for (int channel = 0; channel < 3; ++channel) {
radiance[channel] += transmittance * alpha * kColor[channel] * source_scale;
}
transmittance *= 1.0f - alpha;
if (transmittance < 0.005f) {
break;
}
}
const size_t offset = static_cast<size_t>(y * kImageSize + x) * 4;
pixels[offset] = pack_unorm(radiance[0]);
pixels[offset + 1] = pack_unorm(radiance[1]);
pixels[offset + 2] = pack_unorm(radiance[2]);
pixels[offset + 3] = pack_unorm(1.0f - transmittance);
}
}
return pixels;
}
void write_bytes(const fs::path &path, const std::vector<uint8_t> &bytes)
{
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("failed to create golden image: " + path.string());
}
output.write(reinterpret_cast<const char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!output) {
throw std::runtime_error("failed to write golden image: " + path.string());
}
}
} // namespace
int main(int argc, char **argv)
{
if (argc != 3) {
std::cerr << "usage: vdb_volume_golden INPUT.vdb OUTPUT_PREFIX\n";
return 2;
}
try {
openvdb::initialize();
const fs::path input = fs::absolute(argv[1]);
const fs::path output_prefix = fs::absolute(argv[2]);
if (input.extension() != ".vdb") {
throw std::runtime_error("input extension must be .vdb");
}
fs::create_directories(output_prefix.parent_path());
openvdb::io::File file(input.string());
file.open(false);
const openvdb::GridBase::Ptr base = file.readGrid("density");
file.close();
const openvdb::FloatGrid::Ptr density = openvdb::gridPtrCast<openvdb::FloatGrid>(base);
if (!density || density->getGridClass() != openvdb::GRID_FOG_VOLUME) {
throw std::runtime_error("density must be an OpenVDB FloatGrid fog volume");
}
const openvdb::CoordBBox bounds = density->evalActiveVoxelBoundingBox();
if (bounds.empty()) {
throw std::runtime_error("density grid has no active voxels");
}
const std::array<const char *, 3> names = {"x", "y", "z"};
for (int axis = 0; axis < 3; ++axis) {
write_bytes(output_prefix.string() + "-" + names[axis] + ".rgba",
render_axis(*density, bounds, axis));
}
std::cout << "{\"schemaVersion\":1,\"openVDBVersion\":\""
<< openvdb::getLibraryVersionString() << "\",\"grid\":\"density\",\"width\":"
<< kImageSize << ",\"height\":" << kImageSize << ",\"activeVoxelCount\":"
<< density->activeVoxelCount() << ",\"indexBounds\":{\"min\":["
<< bounds.min().x() << ',' << bounds.min().y() << ',' << bounds.min().z()
<< "],\"max\":[" << bounds.max().x() << ',' << bounds.max().y() << ','
<< bounds.max().z() << "]}}\n";
openvdb::uninitialize();
return 0;
}
catch (const std::exception &error) {
std::cerr << "VDB_VOLUME_GOLDEN_FAILED: " << error.what() << '\n';
return 1;
}
}

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8"));
const fixturePath = path.join(root, golden.fixture.path);
const generatorPath = path.join(root, golden.fixture.generator);
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
assert.equal(sha256(fs.readFileSync(fixturePath)), golden.fixture.sha256);
assert.equal(sha256(fs.readFileSync(generatorPath)), golden.fixture.generatorSha256);
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), new RegExp(`^Blender ${golden.fixture.blenderVersion.replaceAll(".", "\\.")}\\b`, "m"));
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m11-compositor-golden-"));
const requireFromWeb = createRequire(path.join(root, "web/package.json"));
const ts = requireFromWeb("typescript");
for (const [sourceName, outputName] of [["capability-gates.ts", "capability-gates.mjs"], ["compositor.ts", "compositor.mjs"]]) {
const sourcePath = path.join(root, "web/protocol", sourceName);
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const output = sourceName === "compositor.ts" ? transpiled.outputText.replaceAll('from "./capability-gates"', 'from "./capability-gates.mjs"') : transpiled.outputText;
fs.writeFileSync(path.join(temporary, outputName), output);
}
const compositor = await import(pathToFileURL(path.join(temporary, "compositor.mjs")));
const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm"));
const fixture = fs.readFileSync(fixturePath);
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 snapshot(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(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); }
}
try {
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
try {
open(engine, handle, fixture);
const value = snapshot(engine, handle);
for (const candidate of golden.cases) {
const scene = value.scenes.find((item) => item.name === candidate.scene);
assert.equal(scene?.compositorStatus, "AVAILABLE", `${candidate.scene} has no Main compositor graph`);
assert.deepEqual(scene.compositorGraph.nodes.map((node) => node.type), candidate.nodeTypes);
assert.deepEqual(compositor.compileCompositorWebGPUPlan(scene.compositorGraph).instructions.map((instruction) => instruction.type), candidate.nodeTypes);
const cpu = compositor.executeCompositorGraph(scene.compositorGraph, new Map(), { width: golden.width, height: golden.height });
assert.deepEqual(Array.from(cpu.composite.data.slice(0, 4)), candidate.pixel);
assert.equal(sha256(new Uint8Array(cpu.composite.data.buffer)), candidate.float32Sha256);
}
}
finally { engine._web_engine_destroy(handle); }
process.stdout.write(`compositor-node-golden-ok fixture=${golden.fixture.sha256} scenes=${golden.cases.length} allowlist=${golden.allowlist.join(",")} cpu-hashes=4\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const reportPath = path.join(repoRoot, "release/soak-reports/editing.json");
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
assert.equal(report.schemaVersion, 1);
assert.equal(report.status, "READY");
assert.equal(report.profile, "FORMAL");
assert.equal(report.requiredDurationMs, 1_800_000);
assert.ok(report.configuredDurationMs >= report.requiredDurationMs);
assert.ok(report.actualDurationMs >= report.requiredDurationMs);
assert.ok(report.cycles > 100);
assert.equal(report.autosaves, report.cycles);
assert.ok(report.reopens >= 20);
assert.ok(report.finalRevision >= report.initialRevision + report.cycles * 2);
assert.match(report.finalSha256, /^[a-f0-9]{64}$/);
assert.ok(report.finalBytes > 0);
assert.ok(report.finalSnapshotCount <= report.limits.maxSnapshots);
assert.equal(report.downloadCount, 1);
assert.deepEqual(report.pageErrors, []);
assert.equal(report.failure, null);
assert.ok(report.observed.heapGrowthBytes <= report.limits.maxHeapGrowthBytes);
assert.ok(report.observed.storageGrowthBytes <= report.limits.maxStorageGrowthBytes);
assert.ok(report.resourceSamples.length >= report.reopens + 1);
assert.equal(report.resourceSamples[0].label, "baseline");
assert.equal(report.resourceSamples.at(-1).label, "final");
process.stdout.write(`editing-soak-report-ok durationMs=${report.actualDurationMs} cycles=${report.cycles} autosaves=${report.autosaves} reopens=${report.reopens} revision=${report.finalRevision} sha256=${report.finalSha256} heapGrowth=${report.observed.heapGrowthBytes} storageGrowth=${report.observed.storageGrowthBytes}\n`);

View File

@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const golden = JSON.parse(fs.readFileSync(new URL("tests/golden/M10-03/geometry-node-evaluator.json", root), "utf8"));
const fixture = fs.readFileSync(new URL(golden.fixture, root));
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
assert.equal(golden.schemaVersion, 1);
assert.equal(golden.blenderVersion, "5.2.0 LTS");
assert.equal(crypto.createHash("sha256").update(fixture).digest("hex"), golden.fixtureSha256);
assert.equal(golden.allowlist.length, 16);
assert.deepEqual(Object.keys(golden.nodeCoverage).sort(), [...golden.allowlist].sort());
for (const nodeType of golden.allowlist) {
assert.ok(golden.nodeCoverage[nodeType].length > 0, `${nodeType} has no desktop fixture coverage`);
}
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(pointer);
}
}
function output(engine, handle, fn) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(fn(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
assert.ok(pointer > 0 && length > 0);
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function errors(expected, actual) {
assert.equal(actual.length, expected.length);
return actual.map((value, index) => value - expected[index]);
}
function compareFloatArray(expected, actual, maximum, rmsMaximum, label) {
const delta = errors(expected, actual);
const maxError = Math.max(0, ...delta.map((value) => Math.abs(value)));
const rmsError = delta.length === 0 ? 0 : Math.sqrt(
delta.reduce((sum, value) => sum + value * value, 0) / delta.length,
);
assert.ok(maxError <= maximum, `${label} max error ${maxError} exceeds ${maximum}`);
assert.ok(rmsError <= rmsMaximum, `${label} RMS error ${rmsError} exceeds ${rmsMaximum}`);
return { maxError, rmsError };
}
function bounds(positions) {
const result = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
for (let index = 0; index < positions.length; index += 3) {
for (let axis = 0; axis < 3; axis++) {
result.min[axis] = Math.min(result.min[axis], positions[index + axis]);
result.max[axis] = Math.max(result.max[axis], positions[index + axis]);
}
}
if (positions.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] };
return result;
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
assert.ok(handle > 0);
try {
open(engine, handle, fixture);
const snapshot = output(engine, handle, engine._web_engine_get_scene_snapshot);
const report = output(engine, handle, engine._web_engine_evaluate_depsgraph);
assert.equal(report.engine, "BlenderDepsgraph");
assert.equal(report.status, "EVALUATED");
const graphs = new Map((snapshot.geometryNodeGraphs ?? []).map((graph) => [graph.name, graph]));
const meshes = new Map(report.meshes.map((mesh) => [mesh.objectId, mesh]));
let maximumPositionError = 0;
let maximumRmsError = 0;
for (const expectedCase of golden.cases) {
const graph = graphs.get(expectedCase.graph);
assert.ok(graph, `${expectedCase.name} graph is missing from the Main snapshot`);
assert.deepEqual(graph.nodes.map((node) => node.type).sort(), [...expectedCase.nodeTypes].sort(),
`${expectedCase.name} node inventory`);
const actual = meshes.get(`object:${expectedCase.name}`);
assert.ok(actual, `${expectedCase.name} evaluated mesh is missing`);
assert.equal(actual.vertexCount, expectedCase.mesh.vertexCount, `${expectedCase.name} vertex count`);
assert.equal(actual.triangleCount, expectedCase.mesh.triangleCount, `${expectedCase.name} triangle count`);
assert.deepEqual(actual.indices, expectedCase.mesh.indices, `${expectedCase.name} topology`);
assert.equal(actual.modifiers.length, 1, `${expectedCase.name} modifier count`);
assert.equal(actual.modifiers[0].status, "EVALUATED", `${expectedCase.name} modifier status`);
const positionError = compareFloatArray(
expectedCase.mesh.positions,
actual.positions,
golden.tolerance.maxPositionError,
golden.tolerance.rmsPositionError,
`${expectedCase.name} positions`,
);
maximumPositionError = Math.max(maximumPositionError, positionError.maxError);
maximumRmsError = Math.max(maximumRmsError, positionError.rmsError);
const actualBounds = bounds(actual.positions);
compareFloatArray(expectedCase.mesh.bounds.min, actualBounds.min,
golden.tolerance.boundsError, golden.tolerance.boundsError, `${expectedCase.name} minimum bounds`);
compareFloatArray(expectedCase.mesh.bounds.max, actualBounds.max,
golden.tolerance.boundsError, golden.tolerance.boundsError, `${expectedCase.name} maximum bounds`);
assert.deepEqual(Object.keys(actual.attributes ?? {}).sort(),
Object.keys(expectedCase.mesh.attributes).sort(), `${expectedCase.name} attribute names`);
for (const [name, expectedAttribute] of Object.entries(expectedCase.mesh.attributes)) {
const actualAttribute = actual.attributes[name];
assert.equal(actualAttribute.domain, expectedAttribute.domain, `${expectedCase.name}/${name} domain`);
assert.equal(actualAttribute.dataType, expectedAttribute.dataType, `${expectedCase.name}/${name} data type`);
compareFloatArray(expectedAttribute.values, actualAttribute.values,
golden.tolerance.maxAttributeError, golden.tolerance.maxAttributeError,
`${expectedCase.name}/${name} values`);
}
}
process.stdout.write(
`geometry-node-evaluator-ok nodes=${golden.allowlist.length} cases=${golden.cases.length} ` +
`max-position-error=${maximumPositionError} max-rms-error=${maximumRmsError} attributes=passed\n`,
);
}
finally {
engine._web_engine_destroy(handle);
}

View File

@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const expected = JSON.parse(fs.readFileSync(new URL("tests/golden/M10-01/geometry-node-main-reader.json", root), "utf8"));
const fixture = fs.readFileSync(new URL(expected.fixture, root));
const desktopGoldenBytes = fs.readFileSync(new URL(expected.desktopGolden, root));
const desktopGolden = JSON.parse(desktopGoldenBytes.toString("utf8"));
assert.equal(crypto.createHash("sha256").update(fixture).digest("hex"), expected.fixtureSha256);
assert.equal(crypto.createHash("sha256").update(desktopGoldenBytes).digest("hex"), expected.desktopGoldenSha256);
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 socketDefault(graph, key) {
const separator = key.indexOf(":");
const nodeType = key.slice(0, separator);
const socketName = key.slice(separator + 1);
const node = graph.nodes.find((candidate) => candidate.type === nodeType);
assert.ok(node, `missing node ${nodeType} in ${graph.name}`);
const socket = node.sockets.find((candidate) => candidate.name === socketName);
assert.ok(socket, `missing socket ${nodeType}:${socketName}`);
return socket.defaultValue;
}
function assertGraphs(scene) {
const graphs = scene.geometryNodeGraphs;
assert.equal(graphs?.length, expected.graphs.length);
const desktopByName = new Map(desktopGolden.nodeGroups.map((graph) => [graph.name, graph]));
const expectedByName = new Map(expected.graphs.map((graph) => [graph.name, graph]));
for (const graph of graphs) {
const manifest = expectedByName.get(graph.name);
const desktop = desktopByName.get(graph.name);
assert.ok(manifest && desktop, `unexpected graph ${graph.name}`);
assert.equal(graph.schemaVersion, 1);
assert.equal(graph.id, `node-group:${graph.name}`);
assert.match(graph.graphHash, /^[0-9a-f]{64}$/);
assert.deepEqual(graph.nodes.map((node) => node.type), manifest.nodeTypes);
const nodeKey = (node) => `${node.name}\u0000${node.type}`;
assert.deepEqual(
[...graph.nodes].map((node) => ({ name: node.name, type: node.type })).sort((a, b) => nodeKey(a).localeCompare(nodeKey(b))),
[...desktop.nodes].sort((a, b) => nodeKey(a).localeCompare(nodeKey(b))),
);
assert.equal(graph.links.length, manifest.linkCount);
assert.equal(graph.links.length, desktop.links);
assert.deepEqual(graph.interfaceInputs.map((socket) => [socket.direction, socket.dataType]), [["INPUT", "GEOMETRY"]]);
assert.deepEqual(graph.interfaceOutputs.map((socket) => [socket.direction, socket.dataType]), [["OUTPUT", "GEOMETRY"]]);
assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length);
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
for (const node of graph.nodes) {
assert.match(node.id, /^geometry-node:[1-9][0-9]*$/);
assert.equal(node.type.includes("Undefined["), false);
assert.equal(node.sockets.some((socket) => socket.id.endsWith(":__extend__")), false);
assert.equal(new Set(node.sockets.map((socket) => socket.id)).size, node.sockets.length);
}
for (const link of graph.links) {
const fromNode = nodeById.get(link.fromNodeId);
const toNode = nodeById.get(link.toNodeId);
assert.ok(fromNode?.sockets.some((socket) => socket.id === link.fromSocketId && socket.direction === "OUTPUT"));
assert.ok(toNode?.sockets.some((socket) => socket.id === link.toSocketId && socket.direction === "INPUT"));
}
for (const [key, value] of Object.entries(manifest.defaults)) {
assert.deepEqual(socketDefault(graph, key), value);
}
}
return graphs;
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const initialGraphs = assertGraphs(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);
const reopenedGraphs = assertGraphs(snapshot(engine, reopened));
assert.deepEqual(reopenedGraphs, initialGraphs);
engine._web_engine_destroy(reopened);
process.stdout.write("geometry-node-main-reader-ok graphs=3 nodes=10 links=7 defaults=9 stable-id=passed graph-hash=passed desktop-golden=passed save-reopen=passed simulation-preserved=passed\n");

View File

@@ -64,11 +64,19 @@ function assertFixture(scene) {
assert.equal(data?.pointCount, 4);
const layer = data.layers[0];
const stroke = layer.frames[0].drawing.strokes[0];
assert.equal(data.activeLayerId, layer.id);
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.equal(stroke.id, "grease-pencil-stroke:GreasePencilData:0:0");
assert.deepEqual(stroke.points.map((point) => point.id), [
"grease-pencil-point:GreasePencilData:0:0:0",
"grease-pencil-point:GreasePencilData:0:0:1",
"grease-pencil-point:GreasePencilData:0:0:2",
"grease-pencil-point:GreasePencilData:0:0:3",
]);
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");
@@ -85,9 +93,13 @@ command(engine, handle, { type: "createGreasePencilLayer", dataId, name: "Web Dr
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" });
command(engine, handle, { type: "moveGreasePencilLayer", dataId, layerId, direction: "BOTTOM", baseRevision: edited.revision });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layers[0].id, layerId);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers[1].id, layerId);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers[0].id, layerId);
command(engine, handle, { type: "insertGreasePencilFrame", dataId, layerId, frame: 10, duration: 4 });
const webStrokes = [{
cyclic: true,
@@ -107,8 +119,18 @@ assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].drawi
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 });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.pointCount, 7);
const sourceFrame = edited.layers.find((layer) => layer.id === layerId).frames[0];
command(engine, handle, { type: "moveGreasePencilFrame", dataId, layerId, frame: 10, targetFrame: 12, drawingId: sourceFrame.drawing.id, baseRevision: snapshot(engine, handle).revision });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].frame, 12);
assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].drawing.id, sourceFrame.drawing.id);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers.find((layer) => layer.id === layerId).frames[0].frame, 10);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers.find((layer) => layer.id === layerId).frames[0].frame, 12);
command(engine, handle, { type: "removeGreasePencilFrame", dataId, layerId, frame: 12 });
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);
@@ -130,8 +152,15 @@ 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.deepEqual(reopenedData.layers.map((layer) => layer.name), ["Web Drafts", "Lines"]);
assert.equal(reopenedData.layers.find((layer) => layer.name === "Web Drafts").frames[0].frame, 12);
assert.equal(reopenedData.layers.find((layer) => layer.name === "Web Drafts").frames[0].drawing.id, sourceFrame.drawing.id);
assert.equal(reopenedStroke.cyclic, true);
assert.deepEqual(reopenedStroke.points[2].position, [2, 0, 0]);
close(reopenedStroke.points[2].radius, 0.3, "reopened radius");
assert.deepEqual(
reopenedData.layers.find((layer) => layer.name === "Lines").frames[0].drawing.strokes[0].points.map((point) => point.id),
original.layers[0].frames[0].drawing.strokes[0].points.map((point) => point.id),
);
engine._web_engine_destroy(reopened);
process.stdout.write("grease-pencil-roundtrip-ok layer-frame-stroke=passed undo-redo=passed save-reopen=passed\n");

View File

@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(root, "web/protocol/scene-ir.ts");
const source = ts.createSourceFile(sourcePath, fs.readFileSync(sourcePath, "utf8"), ts.ScriptTarget.Latest, true);
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-01/lighting-field-parity.json"), "utf8"));
function interfaceDeclaration(name) {
const declaration = source.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name);
assert.ok(declaration, `missing interface ${name}`);
return declaration;
}
function leafTypePaths(type, prefix) {
if (!ts.isTypeLiteralNode(type)) return [prefix];
return type.members.filter(ts.isPropertySignature).flatMap((member) =>
leafTypePaths(member.type, `${prefix}.${member.name.getText(source)}`));
}
function interfaceLeaves(name, include = () => true) {
return interfaceDeclaration(name).members.filter((member) =>
ts.isPropertySignature(member) && include(member.name.getText(source))).flatMap((member) =>
leafTypePaths(member.type, member.name.getText(source))).sort();
}
const expected = new Map([
["CAMERA", interfaceLeaves("CameraIR")],
["LIGHT", interfaceLeaves("LightIR")],
["WORLD", interfaceLeaves("WorldIR")],
["SCENE_COLOR_MANAGEMENT", interfaceLeaves("SceneIR", (name) => name === "renderEngine" || name === "colorManagement")],
]);
const stages = ["reader", "writer", "viewportMain", "viewportOffscreen"];
const stageStates = new Set(["VERIFIED", "PARTIAL", "METADATA_ONLY", "BLOCKED", "NOT_APPLICABLE"]);
const parityStates = new Set(["COMPLETE", "PARTIAL", "BLOCKED"]);
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.task, "M11-01");
assert.equal(manifest.blenderVersion, "5.2.0");
assert.deepEqual(manifest.domains.map((domain) => domain.domain), [...expected.keys()]);
let total = 0;
const parityCounts = { COMPLETE: 0, PARTIAL: 0, BLOCKED: 0 };
for (const domain of manifest.domains) {
assert.deepEqual(domain.fields.map((field) => field.path).sort(), expected.get(domain.domain));
assert.equal(new Set(domain.fields.map((field) => field.path)).size, domain.fields.length);
assert.ok(Array.isArray(domain.evidence) && domain.evidence.length >= 4);
for (const evidence of domain.evidence) assert.ok(fs.existsSync(path.join(root, evidence)), `${domain.domain} evidence is missing: ${evidence}`);
for (const field of domain.fields) {
for (const stage of stages) assert.ok(stageStates.has(field[stage]), `${domain.domain}.${field.path}.${stage} is invalid`);
assert.ok(parityStates.has(field.parity), `${domain.domain}.${field.path}.parity is invalid`);
if (field.parity === "COMPLETE") {
assert.ok(stages.every((stage) => field[stage] === "VERIFIED" || field[stage] === "NOT_APPLICABLE"), `${domain.domain}.${field.path} overclaims COMPLETE`);
}
parityCounts[field.parity] += 1;
total += 1;
}
}
const requiredBlocks = [
["CAMERA", "orthoScale", "viewportMain"],
["CAMERA", "depthOfField.enabled", "viewportMain"],
["LIGHT", "areaSpread", "viewportMain"],
["LIGHT", "sunAngle", "viewportOffscreen"],
["WORLD", "environmentRotation", "reader"],
["SCENE_COLOR_MANAGEMENT", "colorManagement.displayDevice", "viewportMain"],
["SCENE_COLOR_MANAGEMENT", "colorManagement.gamma", "viewportOffscreen"],
];
for (const [domainName, fieldPath, stage] of requiredBlocks) {
const field = manifest.domains.find((domain) => domain.domain === domainName)?.fields.find((candidate) => candidate.path === fieldPath);
assert.equal(field?.[stage], "BLOCKED", `${domainName}.${fieldPath}.${stage} must remain BLOCKED`);
}
assert.equal(total, 60);
process.stdout.write(`lighting-field-parity-ok fields=${total} complete=${parityCounts.COMPLETE} partial=${parityCounts.PARTIAL} blocked=${parityCounts.BLOCKED}\n`);

View File

@@ -115,6 +115,13 @@ function assertEdited(scene) {
assert.equal(light.useTemperature, true);
close(light.temperature, 5000, "light temperature");
assert.deepEqual(light.color, [0.25, 0.5, 0.75]);
close(light.radius, 0.3, "light radius");
close(light.spotAngle, 1.1, "light spot angle");
close(light.spotBlend, 0.25, "light spot blend");
close(light.areaSize, 3, "light area size");
close(light.areaSizeY, 2, "light area size y");
close(light.areaSpread, 2.4, "light area spread");
close(light.sunAngle, 0.1, "light sun angle");
assert.equal(world.mist.enabled, true);
assert.equal(world.mist.type, "LINEAR");
close(world.mist.start, 2, "mist start");
@@ -151,7 +158,8 @@ cameraEdited = snapshot(engine, handle);
assert.equal(cameraEdited.cameras.find((item) => item.id === before.camera.id).projection, "ORTHOGRAPHIC");
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,
temperature: 5000, useTemperature: true, radius: 0.3, spotAngle: 1.1, spotBlend: 0.25,
areaSize: 3, areaSizeY: 2, areaSpread: 2.4, sunAngle: 0.1,
} });
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: {

View File

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

View File

@@ -151,14 +151,30 @@ close(vertexWeight(snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id
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, {
apply(engine, weightHandle, {
type: "setVertexWeights",
objectId,
vertexGroup: "WebPaintGroup",
indices: [3],
values: [0.5],
values: [0.4],
limit: 2,
normalize: true,
});
weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId);
close(vertexWeight(weightedMesh, 3, "WebPaintGroup"), 0.4 / 1.4, "limited normalized vertex 3 weight");
apply(engine, weightHandle, {
type: "setVertexWeights",
objectId,
vertexGroup: "WebPaintGroup",
indices: [0],
values: [0.9],
mirror: true,
}, "CAPABILITY_MISSING");
mirrorAxis: 0,
mirrorTolerance: 1e-4,
});
weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId);
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.9, "mirrored vertex 0 weight");
close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.9, "mirrored vertex 1 weight");
const savedWeights = output(engine, weightHandle, engine._web_engine_save_blend, true);
engine._web_engine_destroy(weightHandle);
@@ -166,8 +182,9 @@ const reopenedWeights = engine._web_engine_create();
open(engine, reopenedWeights, savedWeights);
weightedMesh = snapshot(engine, reopenedWeights).meshes.find((mesh) => mesh.id === meshId);
assertVertexGroup(weightedMesh, "WebPaintGroup");
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "reopened vertex 0 paint weight");
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.9, "reopened mirrored vertex 0 paint weight");
close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.9, "reopened mirrored vertex 1 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");
process.stdout.write("paint-roundtrip-ok vertex-color=passed vertex-weight-normalize=passed vertex-weight-limit=passed vertex-weight-mirror=passed mirror-gate=passed undo-redo=passed save-reopen=passed\n");

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
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 manifestPath = path.join(root, "tests/golden/M11-04/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const fixture = path.join(root, manifest.source.fixture);
const reference = path.join(path.dirname(manifestPath), manifest.reference.file);
const generator = path.join(root, manifest.source.generator);
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.task, "M11-04");
assert.equal(manifest.reference.colorSpace, "SRGB8");
assert.equal(manifest.reference.alphaMode, "STRAIGHT");
assert.equal(manifest.reference.width, 256);
assert.equal(manifest.reference.height, 256);
assert.equal(sha256(fixture), manifest.source.fixtureSha256);
assert.equal(sha256(generator), manifest.source.generatorSha256);
assert.equal(sha256(reference), manifest.reference.sha256);
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m11-render-reference-"));
try {
const generatedFixture = path.join(temporaryRoot, "reference.blend");
const generatedReference = path.join(temporaryRoot, "reference.png");
execFileSync(blender, [
"--background",
"--factory-startup",
"--python",
generator,
"--",
generatedFixture,
generatedReference,
reference,
], { cwd: root, stdio: "pipe" });
assert.ok(fs.statSync(generatedFixture).size > 0, "desktop reference fixture was not generated");
assert.ok(fs.statSync(generatedReference).size > 0, "desktop reference render was not generated");
}
finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
console.log(`render-reference-ok blender=5.2.0 fixture=${manifest.source.fixtureSha256} image=${manifest.reference.sha256}`);

View File

@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const fixture = path.join(root, "tests/files/web/m11_render_reference.blend");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const renderScript = path.join(root, "tools/web/render-server-job.py");
const golden = JSON.parse(await fsp.readFile(path.join(root, "tests/golden/M11-06/server-render-job.json"), "utf8"));
const sourceBytes = await fsp.readFile(fixture);
const sourceArrayBuffer = sourceBytes.buffer.slice(sourceBytes.byteOffset, sourceBytes.byteOffset + sourceBytes.byteLength);
const buildSha256 = crypto.createHash("sha256").update(await fsp.readFile(blender)).digest("hex");
const versionOutput = (await execFileAsync(blender, ["--version"], { cwd: root })).stdout;
const versionLine = versionOutput.split(/\r?\n/).find((line) => line.startsWith("Blender ")) ?? "";
const buildVersion = versionLine.match(/^Blender\s+(5\.2\.[0-9]+)\b/)?.[1];
assert.equal(buildVersion, "5.2.0", `unexpected Blender build: ${versionOutput}`);
assert.equal(golden.schemaVersion, 1);
assert.equal(golden.task, "M11-06");
assert.equal(golden.blenderVersion, buildVersion);
assert.ok(fs.existsSync(fixture), "M11-04 source fixture is missing");
const requireFromWeb = createRequire(path.join(root, "web/package.json"));
const ts = requireFromWeb("typescript");
const protocolSource = fs.readFileSync(path.join(root, "web/protocol/server-render-job.ts"), "utf8")
.replace('import type { ErrorCode } from "./error";\n', "");
const transpiled = ts.transpileModule(protocolSource, {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: "server-render-job.ts",
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`);
const settings = {
renderEngine: "BLENDER_EEVEE",
frameStart: 1,
frameEnd: 1,
resolutionX: 256,
resolutionY: 256,
resolutionPercentage: 100,
samples: 1,
outputMime: "image/png",
transparent: false,
};
const build = { version: buildVersion, buildSha256 };
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "m11-server-render-job-"));
function json(response, status, value) {
const body = Buffer.from(`${JSON.stringify(value)}\n`);
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "Content-Length": body.length });
response.end(body);
}
const server = http.createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/v1/render/jobs") return json(response, 404, { code: "NOT_FOUND" });
const chunks = [];
let sourceByteLength = 0;
try {
for await (const chunk of request) {
chunks.push(chunk);
sourceByteLength += chunk.length;
if (sourceByteLength > 512 * 1024 * 1024) throw new Error("SERVER_RENDER_SOURCE_INVALID: upload exceeds budget");
}
const body = Buffer.concat(chunks);
const requestHeader = request.headers["x-render-request"];
if (typeof requestHeader !== "string") throw new Error("SERVER_RENDER_REQUEST_INVALID: missing request metadata");
const renderRequest = await protocol.verifyServerRenderJobRequest(JSON.parse(requestHeader), body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
if (renderRequest.blenderBuild.version !== build.version || renderRequest.blenderBuild.buildSha256 !== build.buildSha256) {
throw Object.assign(new Error("SERVER_RENDER_BUILD_MISMATCH: requested build is not the executable serving this job"), { code: "SERVER_RENDER_BUILD_MISMATCH" });
}
const fileStem = renderRequest.jobId.replace(/[^A-Za-z0-9_.-]/g, "_");
const sourcePath = path.join(temporary, `${fileStem}.blend`);
const outputPath = path.join(temporary, `${fileStem}.png`);
const settingsPath = path.join(temporary, `${fileStem}.json`);
await fsp.writeFile(sourcePath, body, { flag: "wx", mode: 0o600 });
await fsp.writeFile(settingsPath, `${JSON.stringify(renderRequest.settings)}\n`, { flag: "wx", mode: 0o600 });
await execFileAsync(blender, ["--background", "--factory-startup", "--python", renderScript, "--", sourcePath, outputPath, settingsPath], { cwd: root, maxBuffer: 2 * 1024 * 1024 });
const outputBytes = await fsp.readFile(outputPath);
const result = await protocol.createServerRenderJobResult(renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength));
json(response, 200, result);
} catch (error) {
json(response, 400, { code: error?.code ?? "SERVER_RENDER_FAILED", message: error instanceof Error ? error.message : String(error) });
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert.ok(address && typeof address !== "string");
const origin = `http://127.0.0.1:${address.port}`;
try {
const renderRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, build, settings, { jobId: "render:m11-06", sourceRevision: 11 });
const response = await fetch(`${origin}/v1/render/jobs`, {
method: "POST",
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) },
body: sourceBytes,
duplex: "half",
});
const responseText = await response.text();
assert.equal(response.status, 200, responseText);
const result = JSON.parse(responseText);
const outputPath = path.join(temporary, "verified-output.png");
const outputBytes = await fsp.readFile(path.join(temporary, "render_m11-06.png"));
await fsp.writeFile(outputPath, outputBytes);
await protocol.verifyServerRenderJobResult(result, renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength));
assert.equal(result.sourceBlendSha256, renderRequest.sourceBlendSha256);
assert.equal(result.settingsSha256, renderRequest.settingsSha256);
assert.equal(result.sourceBlendSha256, golden.sourceBlendSha256);
assert.equal(result.settingsSha256, golden.settingsSha256);
assert.equal(result.outputMime, golden.output.mime);
assert.ok(result.outputByteLength >= golden.output.minimumByteLength);
assert.match(result.outputSha256, new RegExp(golden.output.sha256Pattern));
const tamperedSource = Buffer.from(sourceBytes);
tamperedSource[0] ^= 0xff;
const sourceFailure = await fetch(`${origin}/v1/render/jobs`, {
method: "POST",
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) },
body: tamperedSource,
duplex: "half",
});
assert.equal(sourceFailure.status, 400);
assert.equal((await sourceFailure.json()).code, golden.tamperCodes.source);
const wrongBuildRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, { ...build, buildSha256: "b".repeat(64) }, settings, { jobId: "render:wrong-build", sourceRevision: 11 });
const buildFailure = await fetch(`${origin}/v1/render/jobs`, {
method: "POST",
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(wrongBuildRequest) },
body: sourceBytes,
duplex: "half",
});
assert.equal(buildFailure.status, 400);
assert.equal((await buildFailure.json()).code, golden.tamperCodes.build);
const settingsFailureRequest = { ...renderRequest, settings: { ...renderRequest.settings, samples: 2 } };
const settingsFailure = await fetch(`${origin}/v1/render/jobs`, {
method: "POST",
headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(settingsFailureRequest) },
body: sourceBytes,
duplex: "half",
});
assert.equal(settingsFailure.status, 400);
assert.equal((await settingsFailure.json()).code, golden.tamperCodes.settings);
const tamperedOutput = Buffer.from(outputBytes);
tamperedOutput[tamperedOutput.length - 1] ^= 0xff;
await assert.rejects(
protocol.verifyServerRenderJobResult(result, renderRequest, tamperedOutput.buffer.slice(tamperedOutput.byteOffset, tamperedOutput.byteOffset + tamperedOutput.byteLength)),
{ code: golden.tamperCodes.output },
);
process.stdout.write(`server-render-job-ok blender=${buildVersion} source=${renderRequest.sourceBlendSha256} settings=${renderRequest.settingsSha256} output=${result.outputSha256} bytes=${result.outputByteLength} source-hash=1 build-hash=1 settings-hash=1 output-hash=1 tamper=4\n`);
} finally {
await new Promise((resolve) => server.close(resolve));
await fsp.rm(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,119 @@
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/M9-12/weight-paint.json", root), "utf8"));
const fixture = 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));
function call(engine, handle, name, 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,
`${name}: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
finally {
engine._free(pointer);
}
}
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 snapshot(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0);
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 save(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_save_blend(handle, dataOut, lengthOut), 0);
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);
return bytes;
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function weightsByVertex(snapshotValue) {
const mesh = snapshotValue.meshes.find((item) => item.id === `mesh:${golden.mesh}`);
assert.ok(mesh?.skinWeights, "weight golden mesh has no skinWeights");
return golden.steps[0].vertices.map((expectedVertex) => {
const result = {};
for (const [groupIndex, groupName] of mesh.skinWeights.boneNames.entries()) {
const offset = expectedVertex.index * 4;
const slot = mesh.skinWeights.indices.slice(offset, offset + 4).findIndex((value) => value === groupIndex);
result[groupName] = slot < 0 ? 0 : mesh.skinWeights.weights[offset + slot];
}
return result;
});
}
function compareStep(actualSnapshot, expected, label) {
const actual = weightsByVertex(actualSnapshot);
assert.deepEqual(actual.map((weights) => Object.keys(weights).sort()), expected.vertices.map(() => expected.groups.slice().sort()), `${label} group schema`);
expected.vertices.forEach((vertex, index) => {
for (const group of expected.groups) {
const error = Math.abs((actual[index][group] ?? 0) - (vertex.weights[group] ?? 0));
assert.ok(error <= golden.tolerance, `${label} vertex=${vertex.index} group=${group} error=${error}`);
}
});
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
assert.ok(handle > 0);
try {
open(engine, handle, fixture);
call(engine, handle, "initial", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [0, 1], values: [0.75, 0.25] });
compareStep(snapshot(engine, handle), golden.steps[0], "initial");
call(engine, handle, "normalize", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [2], values: [0.5], normalize: true });
compareStep(snapshot(engine, handle), golden.steps[1], "normalize");
call(engine, handle, "limit-normalize", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [3], values: [0.4], limit: 2, normalize: true });
compareStep(snapshot(engine, handle), golden.steps[2], "limit-normalize");
call(engine, handle, "mirror", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [0], values: [0.9], mirror: true, mirrorAxis: 0, mirrorTolerance: 1e-4 });
compareStep(snapshot(engine, handle), golden.steps[3], "mirror");
const saved = save(engine, handle);
const reopened = engine._web_engine_create();
try {
open(engine, reopened, saved);
compareStep(snapshot(engine, reopened), golden.steps[3], "save-reopen");
}
finally {
engine._web_engine_destroy(reopened);
}
}
finally {
engine._web_engine_destroy(handle);
}
process.stdout.write(`weight-paint-golden-ok fixture=${golden.fixture} steps=${golden.steps.map((step) => step.name).join(",")} normalize=passed limit=passed mirror=passed save-reopen=passed\n`);

View File

@@ -0,0 +1,51 @@
import hashlib
import json
import os
import sys
import tempfile
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
if len(arguments) != 2:
raise SystemExit("usage: blender -b fixture.blend --python generate-curve-toggle-golden.py -- fixture.blend output.json")
fixture = os.path.abspath(arguments[0])
output = os.path.abspath(arguments[1])
curve = bpy.data.curves.get("WebCurveData")
if curve is None or len(curve.splines) < 1:
raise RuntimeError("WebCurveData fixture is missing its first spline")
before = [spline.use_cyclic_u for spline in curve.splines]
spline_types = [spline.type for spline in curve.splines]
spline_point_counts = [len(spline.bezier_points) if spline.type == "BEZIER" else len(spline.points) for spline in curve.splines]
curve.splines[0].use_cyclic_u = not curve.splines[0].use_cyclic_u
after = [spline.use_cyclic_u for spline in curve.splines]
with tempfile.TemporaryDirectory(prefix="m9-05-curve-toggle-") as temporary:
saved = os.path.join(temporary, "curve-toggle.blend")
bpy.ops.wm.save_as_mainfile(filepath=saved, check_existing=False)
bpy.ops.wm.open_mainfile(filepath=saved)
reopened = [spline.use_cyclic_u for spline in bpy.data.curves["WebCurveData"].splines]
with open(fixture, "rb") as source:
fixture_sha256 = hashlib.sha256(source.read()).hexdigest()
payload = {
"schemaVersion": 1,
"operator": "TOGGLE_CYCLIC",
"fixture": "tests/files/web/nonmesh_scene.blend",
"fixtureSha256": fixture_sha256,
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
"curveName": "WebCurveData",
"splineIndex": 0,
"splineTypes": spline_types,
"splinePointCounts": spline_point_counts,
"beforeCyclicU": before,
"afterCyclicU": after,
"reopenedCyclicU": reopened,
}
with open(output, "w", encoding="utf-8") as destination:
json.dump(payload, destination, indent=2, sort_keys=True)
destination.write("\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,369 @@
import json
import hashlib
import math
import os
import sys
import bpy
ALLOWLIST = [
"NodeGroupInput",
"NodeGroupOutput",
"GeometryNodeTransform",
"GeometryNodeSetPosition",
"GeometryNodeJoinGeometry",
"GeometryNodeSeparateGeometry",
"GeometryNodeRealizeInstances",
"GeometryNodeStoreNamedAttribute",
"FunctionNodeInputInt",
"FunctionNodeInputVector",
"FunctionNodeCompare",
"ShaderNodeValue",
"ShaderNodeMath",
"GeometryNodeObjectInfo",
"GeometryNodeCollectionInfo",
"GeometryNodeImageInfo",
]
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.frame_set(1)
return scene
def mesh_object(name, vertices, faces, location=(0.0, 0.0, 0.0), collection=None):
mesh = bpy.data.meshes.new(f"{name}Mesh")
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
(collection or bpy.context.collection).objects.link(obj)
obj.location = location
return obj
def cube_object(name, location=(0.0, 0.0, 0.0), collection=None):
return mesh_object(
name,
[
(-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),
],
location,
collection,
)
def tetra_object(name, location=(0.0, 0.0, 0.0), collection=None):
return mesh_object(
name,
[(0, 0, 1.5), (-1, -1, 0), (1, -1, 0), (0, 1, 0)],
[(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)],
location,
collection,
)
def geometry_group(name):
group = bpy.data.node_groups.new(name, "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")
group_input = group.nodes.new("NodeGroupInput")
group_output = group.nodes.new("NodeGroupOutput")
group_input.name = "Group Input"
group_output.name = "Group Output"
return group, group_input, group_output
def add_case(name, x, configure):
obj = cube_object(name, (x, 0, 0))
group, group_input, group_output = geometry_group(f"{name}Graph")
configure(group, group_input, group_output)
modifier = obj.modifiers.new(f"{name} Nodes", "NODES")
modifier.node_group = group
return obj, group
def link_geometry(group, source, target):
group.links.new(source.outputs["Geometry"], target.inputs["Geometry"])
def build_fixture(blend_path):
scene = reset_scene()
cases = []
def passthrough(group, group_input, group_output):
group.links.new(group_input.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_Passthrough", 0, passthrough))
def transform_case(group, group_input, group_output):
node = group.nodes.new("GeometryNodeTransform")
node.name = "Transform"
node.inputs["Translation"].default_value = (0.25, -0.5, 1.0)
node.inputs["Rotation"].default_value = (0.0, 0.0, math.radians(15.0))
node.inputs["Scale"].default_value = (1.25, 0.75, 1.5)
link_geometry(group, group_input, node)
group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_Transform", 4, transform_case))
def set_position_case(group, group_input, group_output):
node = group.nodes.new("GeometryNodeSetPosition")
node.name = "Set Position"
node.inputs["Offset"].default_value = (0.5, 0.25, -0.75)
link_geometry(group, group_input, node)
group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_SetPosition", 8, set_position_case))
def join_case(group, group_input, group_output):
node = group.nodes.new("GeometryNodeJoinGeometry")
node.name = "Join Geometry"
translated = group.nodes.new("GeometryNodeTransform")
translated.name = "Join Branch Transform"
translated.inputs["Translation"].default_value = (3.0, 0.0, 0.0)
group.links.new(group_input.outputs["Geometry"], node.inputs["Geometry"])
group.links.new(group_input.outputs["Geometry"], translated.inputs["Geometry"])
group.links.new(translated.outputs["Geometry"], node.inputs["Geometry"])
group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_Join", 12, join_case))
def separate_case(group, group_input, group_output):
separate = group.nodes.new("GeometryNodeSeparateGeometry")
separate.name = "Separate Geometry"
separate.domain = "POINT"
integer_a = group.nodes.new("FunctionNodeInputInt")
integer_a.name = "Integer A"
integer_a.integer = 2
integer_b = group.nodes.new("FunctionNodeInputInt")
integer_b.name = "Integer B"
integer_b.integer = 1
compare = group.nodes.new("FunctionNodeCompare")
compare.name = "Compare"
compare.data_type = "INT"
compare.operation = "GREATER_THAN"
link_geometry(group, group_input, separate)
group.links.new(integer_a.outputs["Integer"], compare.inputs["A"])
group.links.new(integer_b.outputs["Integer"], compare.inputs["B"])
group.links.new(compare.outputs["Result"], separate.inputs["Selection"])
group.links.new(separate.outputs["Selection"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_SeparateIntCompare", 16, separate_case))
external_collection = bpy.data.collections.new("M10GN_ExternalCollection")
scene.collection.children.link(external_collection)
tetra_object("M10GN_CollectionTetra", (-1.5, 0, 0), external_collection)
tetra_object("M10GN_CollectionTetraOffset", (1.5, 0, 0), external_collection)
def collection_case(group, _group_input, group_output):
collection_info = group.nodes.new("GeometryNodeCollectionInfo")
collection_info.name = "Collection Info"
collection_info.inputs["Collection"].default_value = external_collection
collection_info.inputs["Separate Children"].default_value = True
realize = group.nodes.new("GeometryNodeRealizeInstances")
realize.name = "Realize Instances"
group.links.new(collection_info.outputs["Instances"], realize.inputs["Geometry"])
group.links.new(realize.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_CollectionRealize", 20, collection_case))
def store_attribute_case(group, group_input, group_output):
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.name = "Store Named Attribute"
store.data_type = "FLOAT"
store.domain = "POINT"
store.inputs["Name"].default_value = "m10_value"
store.inputs["Value"].default_value = 0.375
link_geometry(group, group_input, store)
group.links.new(store.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_StoreAttribute", 24, store_attribute_case))
def vector_case(group, group_input, group_output):
vector = group.nodes.new("FunctionNodeInputVector")
vector.name = "Vector"
vector.vector = (0.125, 0.5, 1.25)
set_position = group.nodes.new("GeometryNodeSetPosition")
set_position.name = "Set Position"
link_geometry(group, group_input, set_position)
group.links.new(vector.outputs["Vector"], set_position.inputs["Offset"])
group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_InputVector", 28, vector_case))
def value_math_case(group, group_input, group_output):
first = group.nodes.new("ShaderNodeValue")
first.name = "Value A"
first.outputs["Value"].default_value = 0.25
second = group.nodes.new("ShaderNodeValue")
second.name = "Value B"
second.outputs["Value"].default_value = 0.5
math_node = group.nodes.new("ShaderNodeMath")
math_node.name = "Math Add"
math_node.operation = "ADD"
compare = group.nodes.new("FunctionNodeCompare")
compare.name = "Compare"
compare.data_type = "FLOAT"
compare.operation = "GREATER_THAN"
compare.inputs["B"].default_value = 0.5
set_position = group.nodes.new("GeometryNodeSetPosition")
set_position.name = "Set Position"
set_position.inputs["Offset"].default_value = (0.0, 0.0, 0.625)
link_geometry(group, group_input, set_position)
group.links.new(first.outputs["Value"], math_node.inputs[0])
group.links.new(second.outputs["Value"], math_node.inputs[1])
group.links.new(math_node.outputs["Value"], compare.inputs["A"])
group.links.new(compare.outputs["Result"], set_position.inputs["Selection"])
group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_ValueMath", 32, value_math_case))
object_target = tetra_object("M10GN_ObjectInfoTarget", (0.5, 0.25, 1.5))
def object_info_case(group, group_input, group_output):
object_info = group.nodes.new("GeometryNodeObjectInfo")
object_info.name = "Object Info"
object_info.inputs["Object"].default_value = object_target
set_position = group.nodes.new("GeometryNodeSetPosition")
set_position.name = "Set Position"
link_geometry(group, group_input, set_position)
group.links.new(object_info.outputs["Location"], set_position.inputs["Offset"])
group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_ObjectInfo", 36, object_info_case))
image = bpy.data.images.new("M10GN_Image", width=4, height=2, alpha=True)
def image_info_case(group, group_input, group_output):
image_info = group.nodes.new("GeometryNodeImageInfo")
image_info.name = "Image Info"
image_info.inputs["Image"].default_value = image
compare = group.nodes.new("FunctionNodeCompare")
compare.name = "Compare"
compare.data_type = "INT"
compare.operation = "GREATER_THAN"
compare.inputs["B"].default_value = 3
set_position = group.nodes.new("GeometryNodeSetPosition")
set_position.name = "Set Position"
set_position.inputs["Offset"].default_value = (0.0, -0.75, 0.8)
link_geometry(group, group_input, set_position)
group.links.new(image_info.outputs["Width"], compare.inputs["A"])
group.links.new(compare.outputs["Result"], set_position.inputs["Selection"])
group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"])
cases.append(add_case("M10GN_ImageInfo", 40, image_info_case))
bpy.ops.wm.save_as_mainfile(filepath=blend_path, compress=True)
return [obj.name for obj, _group in cases]
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()
positions = [component for vertex in mesh.vertices for component in vertex.co]
record = {
"object": obj.name,
"vertexCount": len(mesh.vertices),
"triangleCount": len(mesh.loop_triangles),
"positions": positions,
"indices": [index for triangle in mesh.loop_triangles for index in triangle.vertices],
"bounds": {
"min": [min(positions[axis::3]) for axis in range(3)] if positions else [0, 0, 0],
"max": [max(positions[axis::3]) for axis in range(3)] if positions else [0, 0, 0],
},
"attributes": {},
}
attribute = mesh.attributes.get("m10_value")
if attribute is not None:
record["attributes"]["m10_value"] = {
"domain": attribute.domain,
"dataType": attribute.data_type,
"values": [item.value for item in attribute.data],
}
return record
finally:
evaluated.to_mesh_clear()
def write_golden(blend_path, golden_path, case_names):
depsgraph = bpy.context.evaluated_depsgraph_get()
node_coverage = {}
cases = []
for case_name in case_names:
obj = bpy.data.objects[case_name]
group = obj.modifiers[0].node_group
node_types = [node.bl_idname for node in group.nodes]
cases.append({
"name": case_name,
"graph": group.name,
"nodeTypes": node_types,
"mesh": evaluated_mesh_record(obj, depsgraph),
})
for node_type in node_types:
node_coverage.setdefault(node_type, []).append(case_name)
missing = sorted(set(ALLOWLIST) - set(node_coverage))
unexpected = sorted(set(node_coverage) - set(ALLOWLIST))
if missing or unexpected:
raise RuntimeError(f"allowlist coverage mismatch missing={missing} unexpected={unexpected}")
output = {
"schemaVersion": 1,
"blenderVersion": bpy.app.version_string,
"fixture": os.path.relpath(
blend_path,
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(golden_path)))),
),
"fixtureSha256": hashlib.sha256(open(blend_path, "rb").read()).hexdigest(),
"allowlist": ALLOWLIST,
"nodeCoverage": node_coverage,
"tolerance": {
"maxPositionError": 1e-5,
"rmsPositionError": 1e-6,
"maxAttributeError": 1e-6,
"boundsError": 1e-5,
},
"cases": cases,
}
os.makedirs(os.path.dirname(golden_path), exist_ok=True)
with open(golden_path, "w", encoding="utf-8") as handle:
json.dump(output, handle, indent=2, sort_keys=True)
handle.write("\n")
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 2:
raise SystemExit(
"usage: blender -b --python generate-geometry-node-evaluator-golden.py -- fixture.blend golden.json"
)
blend_path, golden_path = [os.path.abspath(path) for path in sys.argv[sys.argv.index("--") + 1:]]
os.makedirs(os.path.dirname(blend_path), exist_ok=True)
case_names = build_fixture(blend_path)
write_golden(blend_path, golden_path, case_names)
print(f"geometry-node-evaluator-generated fixture={blend_path} golden={golden_path} cases={len(case_names)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
import hashlib
import json
import os
import sys
import tempfile
import bpy
def layer_state(grease_pencil):
return {
"layerOrder": [layer.name for layer in grease_pencil.layers],
"framesByLayer": {
layer.name: sorted(frame.frame_number for frame in layer.frames)
for layer in grease_pencil.layers
},
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
if len(arguments) != 2:
raise SystemExit("usage: blender -b fixture.blend --python generate-grease-pencil-reorder-golden.py -- fixture.blend output.json")
fixture = os.path.abspath(arguments[0])
output = os.path.abspath(arguments[1])
grease_pencil = bpy.data.grease_pencils.get("GreasePencilData")
if grease_pencil is None or len(grease_pencil.layers) != 1:
raise RuntimeError("GreasePencilData fixture must contain exactly one source layer")
source = layer_state(grease_pencil)
layer = grease_pencil.layers.new("Web Drafts", set_active=True)
layer.frames.new(1)
before = layer_state(grease_pencil)
grease_pencil.layers.move(layer, "DOWN")
moved = layer.frames.move(1, 12)
if moved is None:
raise RuntimeError("Blender refused the Grease Pencil frame move")
after = layer_state(grease_pencil)
with tempfile.TemporaryDirectory(prefix="m9-08-grease-pencil-reorder-") as temporary:
saved = os.path.join(temporary, "grease-pencil-reorder.blend")
bpy.ops.wm.save_as_mainfile(filepath=saved, check_existing=False)
bpy.ops.wm.open_mainfile(filepath=saved)
reopened = layer_state(bpy.data.grease_pencils["GreasePencilData"])
with open(fixture, "rb") as source_file:
fixture_sha256 = hashlib.sha256(source_file.read()).hexdigest()
payload = {
"schemaVersion": 1,
"fixture": "tests/files/web/modifier_grease_pencil_scene.blend",
"fixtureSha256": fixture_sha256,
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
"greasePencilName": "GreasePencilData",
"source": source,
"beforeReorder": before,
"afterReorder": after,
"reopened": reopened,
}
with open(output, "w", encoding="utf-8") as destination:
json.dump(payload, destination, indent=2, sort_keys=True)
destination.write("\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
import pathlib
import sys
import bpy
SCENES = (
("M11 Constant", (0.125, 0.25, 0.5, 0.75), ()),
("M11 Exposure", (0.125, 0.25, 0.5, 0.75), (("EXPOSURE", 1.0),)),
("M11 Invert", (0.125, 0.25, 0.5, 0.75), (("INVERT", None),)),
("M11 Chain", (0.125, 0.25, 0.5, 0.75), (("EXPOSURE", 1.0), ("INVERT", None))),
)
def add_graph(scene, color_value, operations):
tree = bpy.data.node_groups.new(f"{scene.name} Tree", "CompositorNodeTree")
scene.compositing_node_group = tree
tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")
color = tree.nodes.new("CompositorNodeRGB")
color.name = f"{scene.name} Constant"
color.outputs["Color"].default_value = color_value
previous = color.outputs["Color"]
for index, (operation, parameter) in enumerate(operations):
if operation == "EXPOSURE":
node = tree.nodes.new("CompositorNodeExposure")
node.inputs["Exposure"].default_value = parameter
else:
node = tree.nodes.new("CompositorNodeInvert")
node.name = f"{scene.name} {operation.title()} {index}"
tree.links.new(previous, node.inputs["Image" if operation == "EXPOSURE" else "Color"])
previous = node.outputs["Image" if operation == "EXPOSURE" else "Color"]
output = tree.nodes.new("NodeGroupOutput")
output.name = f"{scene.name} Composite"
tree.links.new(previous, output.inputs["Image"])
def main(output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
first = bpy.context.scene
for index, (name, color, operations) in enumerate(SCENES):
scene = first if index == 0 else bpy.data.scenes.new(name)
scene.name = name
add_graph(scene, color, operations)
output = pathlib.Path(output_path).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=True)
print(f"m11-compositor-allowlist fixture={output} scenes={len(SCENES)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python generate-m11-compositor-allowlist.py -- OUTPUT")
main(arguments[0])

View File

@@ -0,0 +1,99 @@
import math
import pathlib
import sys
import bpy
from mathutils import Vector
def configure_scene() -> bpy.types.Scene:
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.name = "M11 Render Reference"
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 256
scene.render.resolution_y = 256
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "8"
scene.render.film_transparent = False
scene.render.use_file_extension = True
scene.render.dither_intensity = 0
scene.eevee.taa_render_samples = 1
scene.view_settings.look = "None"
scene.view_settings.exposure = 0
scene.view_settings.gamma = 1
world = bpy.data.worlds.new("M11 Reference World")
world.use_nodes = True
background = world.node_tree.nodes.get("Background")
background.inputs["Color"].default_value = (0.0508760884, 0.0508760884, 0.0508760884, 1)
background.inputs["Strength"].default_value = 1
scene.world = world
bpy.ops.mesh.primitive_cube_add(size=3, location=(0, 0, 0))
cube = bpy.context.object
cube.name = "M11 Reference Cube"
material = bpy.data.materials.new("M11 Reference Black")
material.use_nodes = True
principled = material.node_tree.nodes.get("Principled BSDF")
principled.inputs["Base Color"].default_value = (0, 0, 0, 1)
principled.inputs["Metallic"].default_value = 0
principled.inputs["Roughness"].default_value = 1
principled.inputs["Specular IOR Level"].default_value = 0
cube.data.materials.append(material)
bpy.ops.object.camera_add()
camera = bpy.context.object
camera.name = "M11 Reference Camera"
camera.data.name = "M11 Reference Camera"
yaw = -math.pi / 4
pitch = 0.55
distance = 7
three_position = Vector((
distance * math.cos(pitch) * math.cos(yaw),
distance * math.cos(pitch) * math.sin(yaw),
distance * math.sin(pitch),
))
camera.location = (three_position.x, -three_position.z, three_position.y)
camera.rotation_euler = (-camera.location).to_track_quat("-Z", "Y").to_euler()
camera.data.type = "PERSP"
camera.data.lens = 50
camera.data.sensor_width = 36
camera.data.sensor_fit = "HORIZONTAL"
camera.data.clip_start = 0.1
camera.data.clip_end = 1000
scene.camera = camera
return scene
def main() -> None:
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) not in (2, 3):
raise SystemExit("usage: blender --background --factory-startup --python generate-m11-render-reference.py -- FIXTURE OUTPUT [EXPECTED]")
arguments = sys.argv[sys.argv.index("--") + 1:]
fixture_arg, output_arg = arguments[:2]
fixture = pathlib.Path(fixture_arg).resolve()
output = pathlib.Path(output_arg).resolve()
fixture.parent.mkdir(parents=True, exist_ok=True)
output.parent.mkdir(parents=True, exist_ok=True)
scene = configure_scene()
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), compress=True)
scene.render.filepath = str(output)
bpy.ops.render.render(write_still=True)
if len(arguments) == 3:
expected = pathlib.Path(arguments[2]).resolve()
actual_image = bpy.data.images.load(str(output), check_existing=False)
expected_image = bpy.data.images.load(str(expected), check_existing=False)
if actual_image.size[:] != expected_image.size[:]:
raise RuntimeError(f"reference dimensions differ: {actual_image.size[:]} != {expected_image.size[:]}")
actual_pixels = actual_image.pixels[:]
expected_pixels = expected_image.pixels[:]
maximum = max(abs(actual - reference) for actual, reference in zip(actual_pixels, expected_pixels))
if maximum != 0:
raise RuntimeError(f"reference decoded pixels differ: max={maximum}")
print(f"m11-render-reference-pixels width={actual_image.size[0]} height={actual_image.size[1]} max=0")
print(f"m11-render-reference fixture={fixture} output={output}")
main()

View File

@@ -0,0 +1,94 @@
import sys
import bpy
def mesh_object(name: str):
mesh = bpy.data.meshes.new(f"{name}Mesh")
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(name, mesh)
bpy.context.collection.objects.link(obj)
return obj
def location_action(name: str, axis: int):
action = bpy.data.actions.new(name)
slot = action.slots.new("OBJECT", "M10NlaTimeMapping")
layer = action.layers.new("M10 NLA Keys")
keyframe_strip = layer.strips.new(type="KEYFRAME")
channelbag = keyframe_strip.channelbags.new(slot)
curve = channelbag.fcurves.new(data_path="location", index=axis)
curve.keyframe_points.add(2)
curve.keyframe_points[0].co = (1.0, 0.0)
curve.keyframe_points[1].co = (11.0, 10.0)
for keyframe in curve.keyframe_points:
keyframe.interpolation = "LINEAR"
action.frame_start = 1.0
action.frame_end = 11.0
return action, slot
def configure_strip(strip, slot, *, frame_start, frame_end, scale, repeat, reverse):
strip.action_slot = slot
strip.action_frame_start = 1.0
strip.action_frame_end = 11.0
strip.frame_start = frame_start
strip.frame_end = frame_end
strip.scale = scale
strip.repeat = repeat
strip.blend_type = "REPLACE"
strip.extrapolation = "NOTHING"
strip.use_reverse = reverse
strip.influence = 1.0
strip.blend_in = 0.0
strip.blend_out = 0.0
def main(output_path: str) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 70
obj = mesh_object("M10_NLA_TimeMapping")
animation_data = obj.animation_data_create()
track = animation_data.nla_tracks.new()
track.name = "M10 Time Mapping"
scaled_action, scaled_slot = location_action("M10_NLA_Scaled_X", 0)
scaled_strip = track.strips.new("M10 Scaled Clip", 20, scaled_action)
configure_strip(
scaled_strip,
scaled_slot,
frame_start=20.0,
frame_end=40.0,
scale=2.0,
repeat=1.0,
reverse=False,
)
reverse_repeat_action, reverse_repeat_slot = location_action("M10_NLA_ReverseRepeat_Y", 1)
reverse_repeat_strip = track.strips.new("M10 Reverse Repeat Clip", 45, reverse_repeat_action)
configure_strip(
reverse_repeat_strip,
reverse_repeat_slot,
frame_start=45.0,
frame_end=65.0,
scale=1.0,
repeat=2.0,
reverse=True,
)
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-nla-evaluation-fixture.py -- output.blend")
main(sys.argv[sys.argv.index("--") + 1])

View File

@@ -0,0 +1,77 @@
import hashlib
import json
import pathlib
import sys
import bpy
def strip_summary(strip):
return {
"id": strip.name,
"action": strip.action.name if strip.action is not None else None,
"frameStart": strip.frame_start,
"frameEnd": strip.frame_end,
"actionFrameStart": strip.action_frame_start,
"actionFrameEnd": strip.action_frame_end,
"scale": strip.scale,
"repeat": strip.repeat,
"blendIn": strip.blend_in,
"blendOut": strip.blend_out,
"influence": strip.influence,
"blendMode": strip.blend_type,
"extrapolation": strip.extrapolation,
"muted": strip.mute,
"reverse": strip.use_reverse,
}
def main(blend_path: str, output_path: str) -> None:
blend = pathlib.Path(blend_path).resolve()
bpy.ops.wm.open_mainfile(filepath=str(blend), load_ui=False)
scene = bpy.context.scene
object_name = "M10_NLA_TimeMapping"
obj = bpy.data.objects.get(object_name)
if obj is None or obj.animation_data is None:
raise RuntimeError("M10 NLA fixture object or AnimData is missing")
frames = [1, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70]
samples = []
for frame in frames:
scene.frame_set(frame)
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated = obj.evaluated_get(depsgraph)
samples.append({
"frame": frame,
"worldMatrix": [value for row in evaluated.matrix_world for value in row],
})
tracks = []
for track in obj.animation_data.nla_tracks:
tracks.append({
"name": track.name,
"muted": track.mute,
"solo": track.is_solo,
"strips": [strip_summary(strip) for strip in track.strips],
})
result = {
"schemaVersion": 1,
"fixture": f"tests/files/web/{blend.name}",
"fixtureSha256": hashlib.sha256(blend.read_bytes()).hexdigest(),
"blenderVersion": bpy.app.version_string,
"object": object_name,
"mesh": obj.data.name,
"tracks": tracks,
"frames": samples,
"tolerance": {"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-nla-evaluation-golden.py -- input.blend output.json")
arguments = sys.argv[sys.argv.index("--") + 1:]
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 OUTPUT.mp4" >&2
exit 2
fi
output=$1
ffmpeg_bin=${FFMPEG_BIN:-ffmpeg}
mkdir -p "$(dirname "$output")"
"$ffmpeg_bin" -v error \
-f lavfi -i "color=c=red:s=16x16:r=2:d=1" \
-an -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p \
-movflags +faststart -map_metadata -1 -fflags +bitexact -flags:v +bitexact \
-metadata creation_time=1970-01-01T00:00:00Z -y "$output"
printf 'sequencer-codec-fixture path=%s bytes=%s\n' "$output" "$(wc -c < "$output")"

View File

@@ -0,0 +1,110 @@
import json
import pathlib
import sys
import bpy
def group_weight(vertex, group_index):
return next((item.weight for item in vertex.groups if item.group == group_index), 0.0)
def set_weight(obj, vertex_index, group_index, value):
group = obj.vertex_groups[group_index]
if value == 0.0:
try:
group.remove([vertex_index])
except RuntimeError:
pass
else:
group.add([vertex_index], value, "REPLACE")
def normalize_vertex(obj, vertex_index):
vertex = obj.data.vertices[vertex_index]
total = sum(item.weight for item in vertex.groups)
if total > 0.0:
for item in list(vertex.groups):
set_weight(obj, vertex_index, item.group, item.weight / total)
def limit_vertex(obj, vertex_index, limit):
vertex = obj.data.vertices[vertex_index]
while len(vertex.groups) > limit:
remove = min(vertex.groups, key=lambda item: (item.weight, -item.group))
obj.vertex_groups[remove.group].remove([vertex_index])
def mirror_map(obj, axis, tolerance):
result = {}
for source in obj.data.vertices:
reflected = source.co.copy()
reflected[axis] = -reflected[axis]
candidate = min(obj.data.vertices, key=lambda item: ((item.co - reflected).length, item.index))
if (candidate.co - reflected).length > tolerance:
raise RuntimeError("mesh is not mirror symmetric")
result[source.index] = candidate.index
if any(result[result[index]] != index for index in result):
raise RuntimeError("mesh mirror map is not reciprocal")
return result
def snapshot(obj):
groups = [group.name for group in obj.vertex_groups]
return {
"groups": groups,
"vertices": [
{"index": vertex.index, "weights": {
groups[item.group]: round(item.weight, 9) for item in vertex.groups
}}
for vertex in obj.data.vertices
],
}
def main(blend_path, output_path):
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 or obj.type != "MESH":
raise RuntimeError("weight paint fixture object is missing")
group = obj.vertex_groups.get("WebPaintGroup") or obj.vertex_groups.new(name="WebPaintGroup")
set_weight(obj, 0, group.index, 0.75)
set_weight(obj, 1, group.index, 0.25)
steps = [{"name": "initial", **snapshot(obj)}]
set_weight(obj, 2, group.index, 0.5)
normalize_vertex(obj, 2)
steps.append({"name": "normalize", **snapshot(obj)})
set_weight(obj, 3, group.index, 0.4)
limit_vertex(obj, 3, 2)
normalize_vertex(obj, 3)
steps.append({"name": "limit-normalize", **snapshot(obj)})
mapping = mirror_map(obj, 0, 1e-4)
for source in (0, mapping[0]):
set_weight(obj, source, group.index, 0.9)
steps.append({"name": "mirror", **snapshot(obj)})
result = {
"schemaVersion": 1,
"fixture": pathlib.Path(blend_path).name,
"blenderVersion": bpy.app.version_string,
"object": obj.name,
"mesh": obj.data.name,
"operations": [
{"name": "normalize", "vertex": 2},
{"name": "limit-normalize", "vertex": 3, "limit": 2},
{"name": "mirror", "vertices": [0, 1], "axis": 0, "tolerance": 1e-4},
],
"steps": steps,
"tolerance": 1e-6,
}
pathlib.Path(output_path).write_text(json.dumps(result, indent=2, sort_keys=True) + "\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-weight-paint-golden.py -- input.blend output.json")
main(*sys.argv[sys.argv.index("--") + 1:sys.argv.index("--") + 3])

View File

@@ -0,0 +1,45 @@
import json
import pathlib
import sys
import bpy
def main() -> None:
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 3:
raise SystemExit("usage: blender --background --python render-server-job.py -- SOURCE OUTPUT SETTINGS_JSON")
source_arg, output_arg, settings_arg = sys.argv[sys.argv.index("--") + 1:]
source = pathlib.Path(source_arg).resolve()
output = pathlib.Path(output_arg).resolve()
settings_path = pathlib.Path(settings_arg).resolve()
if not source.is_file() or source.stat().st_size < 1:
raise RuntimeError("server render source is missing")
bpy.ops.wm.open_mainfile(filepath=str(source), load_ui=False)
scene = bpy.context.scene
if scene is None:
raise RuntimeError("server render scene is missing")
settings = json.loads(settings_path.read_text(encoding="utf-8"))
engine = settings["renderEngine"]
scene.render.engine = "BLENDER_EEVEE" if engine == "BLENDER_EEVEE_NEXT" else engine
frame = int(settings["frameStart"])
if frame != int(settings["frameEnd"]):
raise RuntimeError("server render schema 1 requires a still frame")
scene.render.resolution_x = int(settings["resolutionX"])
scene.render.resolution_y = int(settings["resolutionY"])
scene.render.resolution_percentage = int(settings["resolutionPercentage"])
if scene.render.engine == "BLENDER_CYCLES":
scene.cycles.samples = int(settings["samples"])
elif scene.render.engine == "BLENDER_EEVEE":
scene.eevee.taa_render_samples = int(settings["samples"])
scene.render.image_settings.file_format = "PNG" if settings["outputMime"] == "image/png" else "OPEN_EXR"
scene.render.film_transparent = bool(settings["transparent"])
scene.frame_set(frame)
scene.render.filepath = str(output)
scene.render.use_file_extension = True
bpy.ops.render.render(write_still=True)
if not output.is_file() or output.stat().st_size < 1:
raise RuntimeError("server render produced no output")
print(f"server-render-ok frame={frame} output={output} bytes={output.stat().st_size}")
main()