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