Advance M8-M11 parity workflows
This commit is contained in:
@@ -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
|
||||
|
||||
86
tools/vdb/generate-volume-golden.mjs
Normal file
86
tools/vdb/generate-volume-golden.mjs
Normal 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 });
|
||||
}
|
||||
179
tools/vdb/vdb_volume_golden.cc
Normal file
179
tools/vdb/vdb_volume_golden.cc
Normal 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;
|
||||
}
|
||||
}
|
||||
81
tools/web/check-compositor-node-golden.mjs
Normal file
81
tools/web/check-compositor-node-golden.mjs
Normal 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 });
|
||||
}
|
||||
32
tools/web/check-editing-soak-report.mjs
Normal file
32
tools/web/check-editing-soak-report.mjs
Normal 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`);
|
||||
140
tools/web/check-geometry-node-evaluator-golden.mjs
Normal file
140
tools/web/check-geometry-node-evaluator-golden.mjs
Normal 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);
|
||||
}
|
||||
111
tools/web/check-geometry-node-main-reader.mjs
Normal file
111
tools/web/check-geometry-node-main-reader.mjs
Normal 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");
|
||||
@@ -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");
|
||||
|
||||
78
tools/web/check-lighting-field-parity.mjs
Normal file
78
tools/web/check-lighting-field-parity.mjs
Normal 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`);
|
||||
@@ -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: {
|
||||
|
||||
119
tools/web/check-nla-evaluation-golden.mjs
Normal file
119
tools/web/check-nla-evaluation-golden.mjs
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
50
tools/web/check-render-reference.mjs
Normal file
50
tools/web/check-render-reference.mjs
Normal 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}`);
|
||||
167
tools/web/check-server-render-job.mjs
Normal file
167
tools/web/check-server-render-job.mjs
Normal 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 });
|
||||
}
|
||||
119
tools/web/check-weight-paint-golden.mjs
Normal file
119
tools/web/check-weight-paint-golden.mjs
Normal 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`);
|
||||
51
tools/web/generate-curve-toggle-golden.py
Normal file
51
tools/web/generate-curve-toggle-golden.py
Normal 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()
|
||||
369
tools/web/generate-geometry-node-evaluator-golden.py
Normal file
369
tools/web/generate-geometry-node-evaluator-golden.py
Normal 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()
|
||||
65
tools/web/generate-grease-pencil-reorder-golden.py
Normal file
65
tools/web/generate-grease-pencil-reorder-golden.py
Normal 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()
|
||||
54
tools/web/generate-m11-compositor-allowlist.py
Normal file
54
tools/web/generate-m11-compositor-allowlist.py
Normal 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])
|
||||
99
tools/web/generate-m11-render-reference.py
Normal file
99
tools/web/generate-m11-render-reference.py
Normal 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()
|
||||
94
tools/web/generate-nla-evaluation-fixture.py
Normal file
94
tools/web/generate-nla-evaluation-fixture.py
Normal 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])
|
||||
77
tools/web/generate-nla-evaluation-golden.py
Normal file
77
tools/web/generate-nla-evaluation-golden.py
Normal 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])
|
||||
19
tools/web/generate-sequencer-codec-fixture.sh
Executable file
19
tools/web/generate-sequencer-codec-fixture.sh
Executable 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")"
|
||||
110
tools/web/generate-weight-paint-golden.py
Normal file
110
tools/web/generate-weight-paint-golden.py
Normal 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])
|
||||
45
tools/web/render-server-job.py
Normal file
45
tools/web/render-server-job.py
Normal 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()
|
||||
Reference in New Issue
Block a user