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