Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -39,19 +39,27 @@ const scene = snapshot(engine, handle).scenes.find((candidate) => candidate.name
|
||||
assert.equal(scene?.compositorStatus, "AVAILABLE");
|
||||
const graph = scene.compositorGraph;
|
||||
assert.equal(graph.schemaVersion, 1);
|
||||
assert.equal(graph.nodes.length, 4);
|
||||
assert.equal(graph.links.length, 2);
|
||||
assert.equal(graph.nodes.length, 6);
|
||||
assert.equal(graph.links.length, 4);
|
||||
const color = graph.nodes.find((node) => node.name === "WebConstantColor");
|
||||
assert.equal(color.type, "CONSTANT_COLOR");
|
||||
assert.deepEqual(color.properties.color, [0.125, 0.25, 0.5, 0.75]);
|
||||
const exposure = graph.nodes.find((node) => node.name === "WebExposure");
|
||||
assert.equal(exposure.type, "EXPOSURE");
|
||||
assert.equal(exposure.properties.exposure, 1);
|
||||
const invert = graph.nodes.find((node) => node.name === "WebInvert");
|
||||
assert.equal(invert.type, "INVERT");
|
||||
assert.deepEqual(invert.properties, {});
|
||||
assert.ok(graph.links.some((link) => link.toNodeId === invert.id && link.toSocket === "Image"));
|
||||
assert.equal(graph.nodes.find((node) => node.name === "WebViewer").type, "VIEWER");
|
||||
const composite = graph.nodes.find((node) => node.name === "WebComposite");
|
||||
assert.equal(composite.type, "COMPOSITE");
|
||||
assert.equal(graph.outputNodeId, composite.id);
|
||||
assert.ok(graph.links.some((link) => link.toNodeId === composite.id && link.toSocket === "Image"));
|
||||
const unsupported = graph.nodes.find((node) => node.name === "PreservedUnsupportedGlare");
|
||||
assert.equal(unsupported.type, "UNSUPPORTED");
|
||||
assert.equal(unsupported.blenderType, "CompositorNodeGlare");
|
||||
assert.ok(graph.links.every((link) => graph.nodes.some((node) => node.id === link.fromNodeId) &&
|
||||
graph.nodes.some((node) => node.id === link.toNodeId)));
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("compositor-main-reader-ok graph-structure=passed socket-links=passed unsupported-preserved=passed\n");
|
||||
process.stdout.write("compositor-main-reader-ok graph-structure=passed exposure-invert-parameters=passed socket-links=passed unsupported-preserved=passed\n");
|
||||
|
||||
@@ -41,8 +41,8 @@ assert.equal(project.bindings.length, 0);
|
||||
assert.equal(project.masks.length, 1);
|
||||
const mask = project.masks[0];
|
||||
assert.equal(mask.id, "mask:WebMask");
|
||||
assert.equal(mask.layers.length, 1);
|
||||
const layer = mask.layers[0];
|
||||
assert.equal(mask.layers.length, 2);
|
||||
const layer = mask.layers.find((candidate) => candidate.name === "WebMaskLayer");
|
||||
assert.equal(layer.name, "WebMaskLayer");
|
||||
assert.equal(layer.locked, true);
|
||||
close(layer.opacity, 0.625, "layer opacity");
|
||||
@@ -60,5 +60,10 @@ for (const [label, actual, expected] of [
|
||||
}
|
||||
close(spline.points[2].feather, 0.75, "point feather");
|
||||
assert.deepEqual(spline.points.map((point) => point.selected), [true, false, true]);
|
||||
const editable = mask.layers.find((candidate) => candidate.name === "WebEditableLayer");
|
||||
assert.equal(editable.locked, false);
|
||||
assert.equal(editable.visible, true);
|
||||
assert.equal(editable.splines[0].points.length, 2);
|
||||
assert.deepEqual(editable.splines[0].points.map((point) => point.handleType), ["FREE", "FREE"]);
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("mask-main-reader-ok layers-splines=passed handles-feather=passed selection=passed\n");
|
||||
process.stdout.write("mask-main-reader-ok layers-splines=passed handles-feather=passed locked-editable-selection=passed\n");
|
||||
|
||||
@@ -79,11 +79,13 @@ const curve = before.nonMeshData.find((data) => data.type === "CURVE");
|
||||
const surface = before.nonMeshData.find((data) => data.type === "SURFACE");
|
||||
const font = before.nonMeshData.find((data) => data.type === "FONT");
|
||||
const metaball = before.nonMeshData.find((data) => data.type === "METABALL");
|
||||
const volume = before.nonMeshData.find((data) => data.type === "VOLUME");
|
||||
assert.ok(curve?.controlPoints?.length && curve.splineOffsets?.length);
|
||||
assert.ok(surface?.controlPoints?.length && surface.splineOffsets?.length);
|
||||
assert.deepEqual(surface.splineDimensions, [{ u: 4, v: 4, orderU: 4, orderV: 4 }]);
|
||||
assert.equal(surface.pointWeights?.length, 16);
|
||||
assert.ok(font?.text && metaball?.elements?.length);
|
||||
assert.ok(volume?.sourcePath && volume?.volumeProperties);
|
||||
assert.equal(before.vfonts?.length, 2);
|
||||
assert.ok(before.vfonts.every((resource) => resource.packed && resource.id.startsWith("vfont:")));
|
||||
assert.ok(font.fontLinks);
|
||||
@@ -93,6 +95,27 @@ assert.equal(curve.handleTypes?.length, 6);
|
||||
assert.equal(curve.handlePoints?.length, 18);
|
||||
assert.deepEqual(curve.handlePointIndices, [4, 5, 6]);
|
||||
|
||||
const volumeProperties = {
|
||||
displayDensity: 1.75,
|
||||
interpolation: "NEAREST",
|
||||
stepSize: 0.125,
|
||||
velocityGrid: "velocity",
|
||||
velocityScale: 1.5,
|
||||
};
|
||||
const volumeSourcePath = "//assets/volumes/generated-smoke.vdb";
|
||||
reject(engine, handle, { type: "setVolumeProperties", dataId: volume.id, ...volumeProperties, sourcePath: "../outside.vdb" }, "NON_MESH_PROPERTY_INVALID");
|
||||
command(engine, handle, { type: "setVolumeProperties", dataId: volume.id, ...volumeProperties, sourcePath: volumeSourcePath });
|
||||
let changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id);
|
||||
assert.equal(changedVolume.sourcePath, volumeSourcePath);
|
||||
assert.deepEqual(changedVolume.volumeProperties, volumeProperties);
|
||||
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id);
|
||||
assert.equal(changedVolume.sourcePath, volume.sourcePath);
|
||||
assert.deepEqual(changedVolume.volumeProperties, volume.volumeProperties);
|
||||
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id);
|
||||
assert.deepEqual(changedVolume.volumeProperties, volumeProperties);
|
||||
|
||||
command(engine, handle, { type: "renameId", id: curve.id, name: "WebCurveRenamed" });
|
||||
const renamedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.type === "CURVE" && data.name === "WebCurveRenamed");
|
||||
assert.equal(renamedCurve?.id, "curve:WebCurveRenamed");
|
||||
@@ -284,6 +307,9 @@ engine._web_engine_destroy(handle);
|
||||
const reopened = engine._web_engine_create();
|
||||
open(engine, reopened, saved);
|
||||
const roundtripped = snapshot(engine, reopened);
|
||||
const reopenedVolume = roundtripped.nonMeshData.find((data) => data.id === volume.id);
|
||||
assert.equal(reopenedVolume.sourcePath, volumeSourcePath);
|
||||
assert.deepEqual(reopenedVolume.volumeProperties, volumeProperties);
|
||||
close(roundtripped.nonMeshData.find((data) => data.id === curveId).controlPoints[0], curvePoints[0], "reopened curve point");
|
||||
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions);
|
||||
assert.equal(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointCount, 20);
|
||||
@@ -310,4 +336,4 @@ close(createdAfterReopen.handlePoints[1], twoSplineHandlePoints[1], "reopened bu
|
||||
command(engine, reopened, { type: "deleteNonMeshData", dataId: createdAfterReopen.id });
|
||||
assert.equal(snapshot(engine, reopened).nonMeshData.some((data) => data.id === createdAfterReopen.id), false);
|
||||
engine._web_engine_destroy(reopened);
|
||||
console.log("nonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links=passed undo-redo=passed save-reopen=passed");
|
||||
console.log("nonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL,VOLUME multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links-volume-properties=passed undo-redo=passed save-reopen=passed");
|
||||
|
||||
@@ -84,6 +84,11 @@ function vertexWeight(mesh, vertex, groupName) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function assertVertexGroup(mesh, groupName) {
|
||||
assert.ok(mesh.vertexGroups?.some((group) => group.name === groupName), `${groupName} missing from vertexGroups`);
|
||||
assert.ok(mesh.skinWeights?.boneNames.includes(groupName), `${groupName} missing from skinWeights.boneNames`);
|
||||
}
|
||||
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
|
||||
const colorHandle = engine._web_engine_create();
|
||||
@@ -126,6 +131,7 @@ apply(engine, weightHandle, {
|
||||
normalize: false,
|
||||
});
|
||||
let weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId);
|
||||
assertVertexGroup(weightedMesh, "WebPaintGroup");
|
||||
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "vertex 0 paint weight");
|
||||
close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.25, "vertex 1 paint weight");
|
||||
apply(engine, weightHandle, {
|
||||
@@ -159,6 +165,7 @@ engine._web_engine_destroy(weightHandle);
|
||||
const reopenedWeights = engine._web_engine_create();
|
||||
open(engine, reopenedWeights, savedWeights);
|
||||
weightedMesh = snapshot(engine, reopenedWeights).meshes.find((mesh) => mesh.id === meshId);
|
||||
assertVertexGroup(weightedMesh, "WebPaintGroup");
|
||||
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "reopened vertex 0 paint weight");
|
||||
close(vertexWeight(weightedMesh, 2, "WebPaintGroup"), 0.5 / 1.75, "reopened normalized vertex 2 paint weight");
|
||||
engine._web_engine_destroy(reopenedWeights);
|
||||
|
||||
@@ -25,10 +25,19 @@ try {
|
||||
const evaluation = module.evaluateReleaseManifest(parsed);
|
||||
assert.equal(evaluation.status, "BLOCKED");
|
||||
assert.ok(evaluation.missing.includes("performance.geometry10M"));
|
||||
assert.ok(evaluation.missing.includes("faults.deviceLoss"));
|
||||
assert.equal(evaluation.missing.includes("faults.deviceLoss"), false);
|
||||
assert.equal(evaluation.missing.includes("performance.simulationCache"), false);
|
||||
assert.equal(evaluation.missing.includes("faults.networkInterrupt"), false);
|
||||
assert.equal(evaluation.missing.includes("performance.texture4K"), false);
|
||||
assert.equal(evaluation.missing.includes("performance.texture8K"), false);
|
||||
assert.equal(evaluation.missing.includes("browser.chromium"), false);
|
||||
assert.ok(parsed.evidence.records.length >= 7);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.zipBomb")), true);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.simulationCache")), true);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.networkInterrupt")), true);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.deviceLoss")), true);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.texture4K")), true);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.texture8K")), true);
|
||||
process.stdout.write(`release-evidence-check-ok records=${parsed.evidence.records.length} missing=${evaluation.missing.length} status=${evaluation.status}\n`);
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -23,7 +23,7 @@ try {
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
|
||||
const { gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const { createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
@@ -56,7 +56,13 @@ try {
|
||||
assert.equal(approved.issues[0]?.code, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||
assert.equal(server.status, "BLOCKED");
|
||||
assert.equal(server.issues[0]?.code, "SERVER_JOB_UNAVAILABLE");
|
||||
process.stdout.write("scripting-isolation-ok status=BLOCKED approved-key=sandbox-unavailable server=unavailable execution=disabled\n");
|
||||
const audit = await createScriptExecutionAudit(manifest, "clean", new Set(["approved-key"]), { requestId: "isolation-check", requestedAt: "2026-08-12T12:00:00.000Z" });
|
||||
assert.equal(audit.decision, "DENY");
|
||||
assert.equal(audit.reason, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||
assert.equal(audit.approvedKey, true);
|
||||
assert.match(audit.manifestSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(audit.requestSha256, /^[a-f0-9]{64}$/);
|
||||
process.stdout.write("scripting-isolation-ok status=BLOCKED audit=DENY approved-key=sandbox-unavailable server=unavailable execution=disabled\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
|
||||
@@ -45,10 +45,11 @@ assert.equal(sound.locked, true);
|
||||
const image = timeline.strips.find((strip) => strip.name === "WebImage");
|
||||
assert.equal(image.type, "IMAGE");
|
||||
assert.equal(image.sourcePath, "//media/sequencer-frame.png");
|
||||
assert.deepEqual([image.sourceStart, image.sourceEnd], [0, 1]);
|
||||
const cross = timeline.strips.find((strip) => strip.name === "WebCross");
|
||||
assert.equal(cross.type, "EFFECT");
|
||||
assert.equal(cross.effectType, "CROSS");
|
||||
assert.equal(cross.inputStripIds.length, 2);
|
||||
assert.ok(cross.inputStripIds.every((id) => timeline.strips.some((strip) => strip.id === id)));
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("sequencer-main-reader-ok media-paths=passed fps=passed effect-dependencies=passed\n");
|
||||
process.stdout.write("sequencer-main-reader-ok media-paths=passed still-image-range=passed fps=passed effect-dependencies=passed\n");
|
||||
|
||||
@@ -5,8 +5,13 @@ import path from "node:path";
|
||||
const root = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
const cachePath = path.join(root, "build_web_blender6", "CMakeCache.txt");
|
||||
const cache = fs.readFileSync(cachePath, "utf8");
|
||||
assert.match(cache, /^WITH_OPENVDB:BOOL=OFF$/m, "OpenVDB must remain disabled until a WASM decoder is linked");
|
||||
assert.match(cache, /^WITH_NANOVDB:BOOL=ON$/m, "NanoVDB metadata support is expected");
|
||||
assert.match(cache, /^WITH_OPENVDB:BOOL=OFF$/m, "Browser OpenVDB must remain disabled; conversion belongs to desktop/server targets");
|
||||
|
||||
const protocol = fs.readFileSync(path.join(root, "web", "protocol", "volume-vdb.ts"), "utf8");
|
||||
assert.match(protocol, /prepareVDBConversionInput/, "VDB conversion input validation is missing");
|
||||
assert.match(protocol, /validateNanoVDBBundleManifest/, "NanoVDB bundle validation is missing");
|
||||
assert.match(protocol, /gateNanoVDBPipeline/, "NanoVDB stage capability gates are missing");
|
||||
assert.doesNotMatch(protocol, /export async function decodeVDBResource/, "Browser raw OpenVDB decode entry must not be restored");
|
||||
|
||||
const roots = [path.join(root, "resource-library"), path.join(root, "tests"), "/home/mes123456/resource-library"];
|
||||
const vdbFiles = [];
|
||||
@@ -15,9 +20,22 @@ function scan(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) scan(fullPath);
|
||||
else if (/\.vdb(?:\.gz)?$/i.test(entry.name)) vdbFiles.push(fullPath);
|
||||
else if (/\.(?:vdb(?:\.gz)?|nvdb)$/i.test(entry.name)) vdbFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
for (const directory of roots) scan(directory);
|
||||
assert.equal(vdbFiles.length, 0, `unexpected local VDB resource(s): ${vdbFiles.join(", ")}`);
|
||||
process.stdout.write("vdb-availability-ok status=BLOCKED openvdb=disabled nanovdb=metadata-only local-vdb=0 renderer=blocked decoder=blocked\n");
|
||||
const converterCandidates = [
|
||||
path.join(root, "build_vdb_tools", "vdb_to_nanovdb"),
|
||||
path.join(root, "tools", "vdb", "convert-openvdb-to-nanovdb"),
|
||||
path.join(root, "tools", "vdb", "convert-openvdb-to-nanovdb.py"),
|
||||
];
|
||||
const converterConfigured = converterCandidates.some((candidate) => fs.existsSync(candidate));
|
||||
const rendererConfigured = fs.existsSync(path.join(root, "web", "app", "src", "render", "nanovdb-volume-renderer.ts"));
|
||||
const serverConfigured = fs.existsSync(path.join(root, "tools", "vdb", "server", "vdb-job-service.mjs"));
|
||||
const opfsConfigured = fs.existsSync(path.join(root, "web", "app", "src", "volume", "nanovdb-opfs.ts"));
|
||||
const mainWriterConfigured = fs.readFileSync(path.join(root, "blender-5.2.0", "source", "blender", "web_engine", "web_engine_api.cpp"), "utf8").includes("setVolumeProperties");
|
||||
const coreStatus = converterConfigured && serverConfigured && opfsConfigured && rendererConfigured && mainWriterConfigured && vdbFiles.some((file) => /\.vdb(?:\.gz)?$/i.test(file)) ? "READY" : "BLOCKED";
|
||||
const releaseStatus = "BLOCKED";
|
||||
assert.equal(coreStatus, "READY", "VDB core pipeline files or real resources are missing");
|
||||
assert.equal(releaseStatus, "BLOCKED", "VDB release must remain blocked until viewport integration, advanced grids, and full goldens are present");
|
||||
process.stdout.write(`vdb-availability-ok core=${coreStatus} release=${releaseStatus} browser-openvdb=disabled desktop=ready server=ready opfs=ready webgpu-core=ready main-roundtrip=ready viewport=blocked advanced-material=blocked resources=${vdbFiles.length}\n`);
|
||||
|
||||
108
tools/web/check-vdb-native-pipeline.mjs
Normal file
108
tools/web/check-vdb-native-pipeline.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
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 resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb");
|
||||
const converter = path.join(root, "build_vdb_tools", "vdb_to_nanovdb");
|
||||
const generator = path.join(root, "build_vdb_tools", "vdb_fixture_generator");
|
||||
const source = path.join(resourceRoot, "generated", "generated-smoke.vdb");
|
||||
const storedBundle = path.join(resourceRoot, "nanovdb", "generated-smoke.nvdb");
|
||||
const officialSource = path.join(resourceRoot, "official", "sphere.vdb");
|
||||
const officialBundle = path.join(resourceRoot, "nanovdb", "official-sphere.nvdb");
|
||||
const browserManifest = path.join(resourceRoot, "manifests", "generated-smoke.nanovdb.json");
|
||||
const catalog = JSON.parse(fs.readFileSync(path.join(resourceRoot, "manifest.json"), "utf8"));
|
||||
const evidence = JSON.parse(fs.readFileSync(path.join(root, "docs", "status", "vdb-native-evidence.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
assert.match(fs.readFileSync(path.join(root, "build_web_blender6", "CMakeCache.txt"), "utf8"), /^WITH_OPENVDB:BOOL=OFF$/m);
|
||||
assert.match(fs.readFileSync(path.join(root, "build_blender_5.2.0", "CMakeCache.txt"), "utf8"), /^WITH_OPENVDB:BOOL=ON$/m);
|
||||
assert.equal(catalog.schemaVersion, 1);
|
||||
assert.equal(catalog.toolchain.openVDBVersion, "13.0.0");
|
||||
assert.equal(catalog.toolchain.converterSha256, sha256(converter));
|
||||
assert.equal(evidence.schemaVersion, 1);
|
||||
assert.equal(evidence.browserOpenVDB, false);
|
||||
assert.equal(evidence.desktopOpenVDB, true);
|
||||
assert.equal(evidence.serverJobConfigured, true);
|
||||
assert.equal(evidence.opfsStreamingConfigured, true);
|
||||
assert.equal(evidence.webgpuRendererConfigured, true);
|
||||
assert.equal(evidence.mainVolumeRoundtripConfigured, true);
|
||||
assert.equal(evidence.primaryViewportVolumeIntegrated, false);
|
||||
assert.equal(evidence.releaseStatus, "BLOCKED");
|
||||
assert.deepEqual(evidence.toolchain, catalog.toolchain);
|
||||
assert.deepEqual(evidence.resources, catalog.entries);
|
||||
for (const entry of catalog.entries) {
|
||||
const file = path.join(resourceRoot, entry.path);
|
||||
assert.equal(fs.statSync(file).size, entry.byteLength, `${entry.id} byte length changed`);
|
||||
assert.equal(sha256(file), entry.sha256, `${entry.id} SHA-256 changed`);
|
||||
}
|
||||
assert.equal(catalog.entries.find((entry) => entry.id === "official-sphere")?.sha256, "bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab");
|
||||
|
||||
const ldd = spawnSync("ldd", [converter], { encoding: "utf8" });
|
||||
assert.equal(ldd.status, 0);
|
||||
assert.match(ldd.stdout, /libopenvdb\.so\.13/);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "vdb-native-"));
|
||||
try {
|
||||
const output = path.join(temporary, "smoke.nvdb");
|
||||
const report = path.join(temporary, "smoke.json");
|
||||
const conversion = spawnSync(converter, ["--input", source, "--output", output, "--report", report, "--grid", "density", "--grid", "temperature", "--grid", "color", "--grid", "velocity", "--quantization", "LOSSLESS"], { encoding: "utf8" });
|
||||
assert.equal(conversion.status, 0, conversion.stderr);
|
||||
assert.equal(sha256(output), sha256(storedBundle), "OpenVDB to NanoVDB conversion is not deterministic");
|
||||
const parsedReport = JSON.parse(fs.readFileSync(report, "utf8"));
|
||||
assert.equal(parsedReport.openVDBVersion, "13.0.0");
|
||||
assert.equal(parsedReport.nanoVDBVersion, "32.9.0");
|
||||
assert.deepEqual(parsedReport.grids.map((grid) => grid.name), ["color", "density", "temperature", "velocity"]);
|
||||
assert.ok(parsedReport.grids.every((grid) => grid.byteOffset >= grid.segmentByteOffset && grid.byteOffset + grid.byteLength <= grid.segmentByteOffset + grid.segmentByteLength));
|
||||
|
||||
const generatedA = path.join(temporary, "generated-a");
|
||||
const generatedB = path.join(temporary, "generated-b");
|
||||
fs.mkdirSync(generatedA);
|
||||
fs.mkdirSync(generatedB);
|
||||
assert.equal(spawnSync(generator, [generatedA], { encoding: "utf8" }).status, 0);
|
||||
assert.equal(spawnSync(generator, [generatedB], { encoding: "utf8" }).status, 0);
|
||||
const semanticOutputs = [];
|
||||
for (const [index, generated] of [generatedA, generatedB].entries()) {
|
||||
const semanticOutput = path.join(temporary, `semantic-${index}.nvdb`);
|
||||
const semanticReport = path.join(temporary, `semantic-${index}.json`);
|
||||
const result = spawnSync(converter, ["--input", path.join(generated, "generated-smoke.vdb"), "--output", semanticOutput, "--report", semanticReport, "--grid", "density", "--grid", "temperature", "--grid", "color", "--grid", "velocity", "--quantization", "LOSSLESS"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
semanticOutputs.push(semanticOutput);
|
||||
}
|
||||
assert.equal(sha256(semanticOutputs[0]), sha256(semanticOutputs[1]), "Semantically identical generated VDB grids produce different NanoVDB bundles");
|
||||
assert.equal(sha256(semanticOutputs[0]), sha256(storedBundle));
|
||||
|
||||
const officialOutput = path.join(temporary, "official-sphere.nvdb");
|
||||
const officialReport = path.join(temporary, "official-sphere.json");
|
||||
const officialConversion = spawnSync(converter, ["--input", officialSource, "--output", officialOutput, "--report", officialReport, "--quantization", "LOSSLESS"], { encoding: "utf8" });
|
||||
assert.equal(officialConversion.status, 0, officialConversion.stderr);
|
||||
assert.equal(sha256(officialOutput), sha256(officialBundle), "Official sphere conversion is not deterministic");
|
||||
const parsedOfficial = JSON.parse(fs.readFileSync(officialReport, "utf8"));
|
||||
assert.deepEqual(parsedOfficial.grids.map((grid) => [grid.name, grid.gridClass]), [["ls_sphere", "LEVEL_SET"]]);
|
||||
|
||||
const malformed = spawnSync(converter, ["--input", path.join(resourceRoot, "generated", "generated-smoke-truncated.vdb"), "--output", path.join(temporary, "bad.nvdb"), "--report", path.join(temporary, "bad.json")], { encoding: "utf8" });
|
||||
assert.notEqual(malformed.status, 0);
|
||||
assert.match(malformed.stderr, /VDB_CONVERSION_FAILED/);
|
||||
|
||||
for (const name of ["asset-path", "capability-gates", "volume-vdb"]) {
|
||||
const sourceText = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
let code = ts.transpileModule(sourceText, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText;
|
||||
code = code.replaceAll('require("./asset-path")', 'require("./asset-path.cjs")').replaceAll('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), code);
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
const protocol = require(path.join(temporary, "volume-vdb.cjs"));
|
||||
const validated = protocol.validateNanoVDBBundleManifest(JSON.parse(fs.readFileSync(browserManifest, "utf8")));
|
||||
assert.equal(validated.bundleSha256, sha256(storedBundle));
|
||||
assert.deepEqual(validated.grids.map((grid) => grid.semantic), ["COLOR", "DENSITY", "TEMPERATURE", "VELOCITY"]);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`vdb-native-pipeline-ok resources=${catalog.entries.length} converter=openvdb13-nanovdb32 conversion-deterministic=1 semantic-deterministic=1 official-deterministic=1 malformed=blocked browser-openvdb=off\n`);
|
||||
126
tools/web/check-vdb-server-job.mjs
Normal file
126
tools/web/check-vdb-server-job.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { VDBJobService, createVDBJobHttpServer } from "../vdb/server/vdb-job-service.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const converter = path.join(root, "build_vdb_tools/vdb_to_nanovdb");
|
||||
const source = "/home/mes123456/resource-library/blender-web-vdb/generated/generated-smoke.vdb";
|
||||
const secret = "vdb-server-test-signing-key-000000000000000000000000";
|
||||
assert.ok(fs.existsSync(converter), "native converter is missing");
|
||||
assert.ok(fs.existsSync(source), "real OpenVDB fixture is missing");
|
||||
assert.equal(spawnSync("/usr/bin/bwrap", ["--version"], { encoding: "utf8" }).status, 0, "bwrap is unavailable");
|
||||
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "vdb-server-test-"));
|
||||
const service = new VDBJobService({ converter, root: temporary, secret, timeoutMs: 30_000 });
|
||||
const server = createVDBJobHttpServer(service);
|
||||
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}`;
|
||||
const sourceBytes = fs.readFileSync(source);
|
||||
const sourceSha256 = crypto.createHash("sha256").update(sourceBytes).digest("hex");
|
||||
const headers = {
|
||||
"Content-Type": "application/x-openvdb",
|
||||
"X-VDB-Project-Id": "vdb-server-test",
|
||||
"X-VDB-Source-Path": "//volumes/generated-smoke.vdb",
|
||||
"X-VDB-Source-SHA256": sourceSha256,
|
||||
"X-VDB-Grids": "density",
|
||||
"X-VDB-Quantization": "LOSSLESS",
|
||||
"X-VDB-Chunk-Bytes": String(4 * 1024 * 1024),
|
||||
};
|
||||
|
||||
async function submit() {
|
||||
const response = await fetch(`${origin}/v1/vdb/jobs`, { method: "POST", headers, body: sourceBytes, duplex: "half" });
|
||||
if (response.status !== 200 && response.status !== 202) throw new Error(await response.text());
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
async function waitFor(id, states, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(`${origin}/v1/vdb/jobs/${id}`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
if (states.includes(body.state)) return body;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`VDB server job ${id} timed out in test`);
|
||||
}
|
||||
|
||||
try {
|
||||
const health = await (await fetch(`${origin}/healthz`)).json();
|
||||
assert.equal(health.sandbox, true);
|
||||
assert.match(health.converterSha256, /^[a-f0-9]{64}$/);
|
||||
|
||||
const first = await submit();
|
||||
assert.equal(first.status, 202);
|
||||
assert.equal(first.body.deduplicated, false);
|
||||
const completed = await waitFor(first.body.id, ["SUCCEEDED", "FAILED"]);
|
||||
assert.equal(completed.state, "SUCCEEDED", completed.error);
|
||||
assert.equal(completed.sandbox, "bwrap-unshare-all+readonly-root+prlimit");
|
||||
|
||||
const manifestResponse = await fetch(`${origin}${completed.artifacts.manifest}`);
|
||||
const bundleResponse = await fetch(`${origin}${completed.artifacts.bundle}`);
|
||||
assert.equal(manifestResponse.status, 200);
|
||||
assert.equal(bundleResponse.status, 200);
|
||||
const manifestBytes = Buffer.from(await manifestResponse.arrayBuffer());
|
||||
const bundleBytes = Buffer.from(await bundleResponse.arrayBuffer());
|
||||
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||
assert.equal(manifest.converter.target, "SERVER");
|
||||
assert.equal(manifest.sourceSha256, sourceSha256);
|
||||
assert.deepEqual(manifest.grids.map((grid) => grid.name), ["density"]);
|
||||
assert.equal(crypto.createHash("sha256").update(bundleBytes).digest("hex"), manifest.bundleSha256);
|
||||
const expectedSignature = crypto.createHmac("sha256", secret).update(`${completed.id}:${crypto.createHash("sha256").update(manifestBytes).digest("hex")}:${manifest.bundleSha256}`).digest("hex");
|
||||
assert.equal(manifestResponse.headers.get("x-vdb-signature"), expectedSignature);
|
||||
assert.equal(bundleResponse.headers.get("x-vdb-signature"), expectedSignature);
|
||||
|
||||
const desktopBundle = path.join(temporary, "desktop-density.nvdb");
|
||||
const desktopReport = path.join(temporary, "desktop-density.json");
|
||||
const desktopConversion = spawnSync(converter, [
|
||||
"--input", source,
|
||||
"--output", desktopBundle,
|
||||
"--report", desktopReport,
|
||||
"--grid", "density",
|
||||
"--quantization", "LOSSLESS",
|
||||
], { encoding: "utf8" });
|
||||
assert.equal(desktopConversion.status, 0, desktopConversion.stderr);
|
||||
assert.equal(crypto.createHash("sha256").update(fs.readFileSync(desktopBundle)).digest("hex"), manifest.bundleSha256,
|
||||
"desktop and isolated server conversion produced different NanoVDB bytes");
|
||||
|
||||
const repeated = await submit();
|
||||
assert.equal(repeated.status, 200);
|
||||
assert.equal(repeated.body.deduplicated, true);
|
||||
assert.equal(repeated.body.id, completed.id);
|
||||
|
||||
const cancellationHeaders = { ...headers, "X-VDB-Grids": "color,density,temperature,velocity", "X-VDB-Quantization": "FP16" };
|
||||
const cancellationResponse = await fetch(`${origin}/v1/vdb/jobs`, { method: "POST", headers: cancellationHeaders, body: sourceBytes, duplex: "half" });
|
||||
assert.equal(cancellationResponse.status, 202);
|
||||
const cancellation = await cancellationResponse.json();
|
||||
const cancelledResponse = await fetch(`${origin}/v1/vdb/jobs/${cancellation.id}`, { method: "DELETE" });
|
||||
assert.equal(cancelledResponse.status, 200);
|
||||
const cancelled = await waitFor(cancellation.id, ["CANCELLED"]);
|
||||
assert.equal(cancelled.state, "CANCELLED");
|
||||
assert.equal(fs.existsSync(path.join(temporary, "jobs", cancellation.id, "bundle.nvdb")), false);
|
||||
|
||||
const cancelFile = path.join(temporary, "native-cancel");
|
||||
fs.writeFileSync(cancelFile, "cancel\n");
|
||||
const cancelledNative = spawnSync(converter, ["--input", source, "--output", path.join(temporary, "cancelled.nvdb"), "--report", path.join(temporary, "cancelled.json"), "--cancel-file", cancelFile, "--timeout-ms", "30000"], { encoding: "utf8" });
|
||||
assert.notEqual(cancelledNative.status, 0);
|
||||
assert.match(cancelledNative.stderr, /conversion cancelled/);
|
||||
assert.equal(fs.existsSync(path.join(temporary, "cancelled.nvdb")), false);
|
||||
|
||||
process.stdout.write(`vdb-server-job-ok job=${completed.id} bytes=${bundleBytes.length} idempotent=1 signed=1 isolated=1 cancelled=1 atomic=1 desktop-server-hash=equal\n`);
|
||||
}
|
||||
finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fsp.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -35,9 +35,15 @@ function run(id, fields, command, artifacts = []) {
|
||||
}
|
||||
|
||||
run("sbom", ["provenance.license", "provenance.sbom"], "npm --prefix web run release:sbom", ["docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "web/package-lock.json"]);
|
||||
run("vdb-boundary", [], "npm --prefix web run test:vdb-availability && npm --prefix web run test:vdb-native && npm --prefix web run test:vdb", ["web/protocol/volume-vdb.ts", "web/app/src/volume/nanovdb-stream.ts", "tools/vdb/vdb_to_nanovdb.cc", "docs/status/vdb-native-evidence.json", "docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md"]);
|
||||
run("chromium", ["browser.chromium", "runtime.offline", "runtime.workerRestart", "runtime.opfsRecovery"], "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", ["web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("geometry-1m", ["performance.geometry1M"], "npm --prefix web run test:release-performance", ["web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("simulation-cache", ["performance.simulationCache"], "npm --prefix web run test:simulation-cache-performance", ["web/protocol/physics-cache-playback.ts", "web/protocol/simulation-cache.ts", "web/app/src/workers/storage.worker.ts"]);
|
||||
run("malformed-blend", ["faults.malformedBlend"], "npm --prefix web run test:malicious-blends", ["tests/files/web/basic_scene.blend"]);
|
||||
run("network-interruption", ["faults.networkInterrupt"], "npm --prefix web run test:network-interruption", ["web/tests/e2e/network-interruption.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("device-loss", ["faults.deviceLoss"], "npm --prefix web run test:device-loss", ["web/app/src/three-adapter/viewport.ts", "web/tests/e2e/device-loss.spec.ts"]);
|
||||
run("texture-4k", ["performance.texture4K"], "npm --prefix web run test:texture-4k-performance", ["web/protocol/render-assets.ts", "web/app/src/three-adapter/texture-assets.ts"]);
|
||||
run("texture-8k", ["performance.texture8K"], "npm --prefix web run test:texture-8k-performance", ["web/protocol/render-assets.ts", "web/app/src/three-adapter/texture-assets.ts"]);
|
||||
run("zip-bomb", ["faults.zipBomb"], "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", ["web/protocol/asset-library-io.ts"]);
|
||||
run("release-package", [], "npm --prefix web run test:release-package", ["docs/web/sbom.spdx.json", "web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("offline-reproducibility", ["provenance.sourceOffer", "provenance.deterministicPackage"], "npm --prefix web run release:offline", ["release/blender-web-offline.tar.gz", "release/blender-web-corresponding-source.tar.gz", "release/SHA256SUMS.txt"]);
|
||||
@@ -47,6 +53,12 @@ const ledger = JSON.parse(ledgerBytes);
|
||||
const manifest = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), generatedAt: new Date().toISOString(), families: ledger.families, evidence };
|
||||
assert.ok(manifest.families.some((family) => family.status === "BLOCKED"));
|
||||
assert.equal(manifest.evidence.performance.geometry10M, false);
|
||||
assert.equal(manifest.evidence.faults.deviceLoss, false);
|
||||
assert.equal(manifest.evidence.performance.longMedia, false);
|
||||
assert.equal(manifest.evidence.faults.oom, false);
|
||||
assert.equal(manifest.evidence.performance.simulationCache, true);
|
||||
assert.equal(manifest.evidence.performance.texture4K, true);
|
||||
assert.equal(manifest.evidence.performance.texture8K, true);
|
||||
assert.equal(manifest.evidence.faults.deviceLoss, true);
|
||||
assert.equal(manifest.evidence.faults.networkInterrupt, true);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`release-evidence-ok records=${evidence.records.length} status=BLOCKED sha256=${sha256(outputPath)}\n`);
|
||||
|
||||
@@ -16,6 +16,12 @@ def main(output_path):
|
||||
color.name = "WebConstantColor"
|
||||
color.outputs["Color"].default_value = (0.125, 0.25, 0.5, 0.75)
|
||||
|
||||
exposure = tree.nodes.new("CompositorNodeExposure")
|
||||
exposure.name = "WebExposure"
|
||||
exposure.inputs["Exposure"].default_value = 1.0
|
||||
invert = tree.nodes.new("CompositorNodeInvert")
|
||||
invert.name = "WebInvert"
|
||||
|
||||
viewer = tree.nodes.new("CompositorNodeViewer")
|
||||
viewer.name = "WebViewer"
|
||||
composite = tree.nodes.new("NodeGroupOutput")
|
||||
@@ -23,8 +29,10 @@ def main(output_path):
|
||||
glare = tree.nodes.new("CompositorNodeGlare")
|
||||
glare.name = "PreservedUnsupportedGlare"
|
||||
|
||||
tree.links.new(color.outputs["Color"], viewer.inputs["Image"])
|
||||
tree.links.new(color.outputs["Color"], composite.inputs["Image"])
|
||||
tree.links.new(color.outputs["Color"], exposure.inputs["Image"])
|
||||
tree.links.new(exposure.outputs["Image"], invert.inputs["Color"])
|
||||
tree.links.new(invert.outputs["Color"], viewer.inputs["Image"])
|
||||
tree.links.new(invert.outputs["Color"], composite.inputs["Image"])
|
||||
|
||||
path = pathlib.Path(output_path).resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -29,6 +29,19 @@ def main(output_path):
|
||||
point.weight = weight
|
||||
point.select = selected
|
||||
|
||||
editable_layer = mask.layers.new(name="WebEditableLayer")
|
||||
editable_spline = editable_layer.splines.new()
|
||||
editable_spline.points.add(1)
|
||||
editable_values = [
|
||||
((0.2, 0.2), (0.1, 0.2), (0.35, 0.4)),
|
||||
((0.8, 0.2), (0.65, 0.4), (0.9, 0.2)),
|
||||
]
|
||||
for point, (co, left, right) in zip(editable_spline.points, editable_values):
|
||||
point.co = co
|
||||
point.handle_left = left
|
||||
point.handle_right = right
|
||||
point.handle_type = "FREE"
|
||||
|
||||
path = pathlib.Path(output_path).resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
|
||||
|
||||
Reference in New Issue
Block a user