import { normalizeProjectAssetPath } from "./asset-path"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; import type { VolumeGridMetadataIR } from "./scene-ir"; export const VDB_PIPELINE_SCHEMA = 1; export const VDB_MAX_RESOURCE_BYTES = 512 * 1024 * 1024; export const VDB_MAX_ACTIVE_VOXELS = 64_000_000; export const VDB_MAX_GRIDS = 64; export const NANOVDB_MAX_BUNDLE_BYTES = 1024 * 1024 * 1024; export const NANOVDB_MAX_CHUNKS = 8192; export const NANOVDB_MAX_CHUNK_BYTES = 16 * 1024 * 1024; export const NANOVDB_MAX_GPU_RESIDENT_BYTES = 512 * 1024 * 1024; const ID_PATTERN = /^[a-zA-Z0-9._-]+$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const SUPPORTED_GRID_TYPES = new Set(["FLOAT32", "FLOAT16", "VEC3F32", "VEC4F32"]); export type VDBExecutionTarget = "DESKTOP" | "SERVER"; export type NanoVDBGridValueType = "FLOAT32" | "FLOAT16" | "VEC3F32" | "VEC4F32"; export type NanoVDBGridClass = "FOG_VOLUME" | "LEVEL_SET" | "STAGGERED" | "UNKNOWN"; export type NanoVDBGridSemantic = "DENSITY" | "TEMPERATURE" | "COLOR" | "EMISSION" | "VELOCITY" | "CUSTOM"; export type NanoVDBPipelineStage = | "RAW_VDB_BROWSER_DECODE" | "DESKTOP_CONVERSION" | "SERVER_CONVERSION" | "NANOVDB_STREAM" | "WEBGPU_VOLUME_RENDER"; export class VDBPipelineError extends Error { constructor(public readonly code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "VDBPipelineError"; } } export interface VDBResourceManifest { projectId: string; sourcePath: string; byteLength: number; sha256: string; grids: VolumeGridMetadataIR[]; } export interface VDBConversionInput extends VDBResourceManifest { data: ArrayBuffer; } export interface PreparedVDBConversionInput { metadata: VDBResourceManifest; data: ArrayBuffer; } export interface VDBConverterIdentityIR { target: VDBExecutionTarget; blenderVersion: string; openVDBVersion: string; nanoVDBVersion: string; executableSha256: string; } export interface VDBConversionRequestIR { schemaVersion: typeof VDB_PIPELINE_SCHEMA; jobId: string; source: VDBResourceManifest; sourceBlendSha256?: string; outputPath: string; selectedGrids: string[]; quantization: "LOSSLESS" | "FP16" | "FP8"; chunkByteLength: number; converter: VDBConverterIdentityIR; } export interface NanoVDBChunkIR { index: number; byteOffset: number; byteLength: number; sha256: string; } export interface NanoVDBGridIR { name: string; valueType: NanoVDBGridValueType; gridClass: NanoVDBGridClass; semantic: NanoVDBGridSemantic; activeVoxelCount: number; segmentByteOffset: number; segmentByteLength: number; byteOffset: number; byteLength: number; indexBounds: { min: [number, number, number]; max: [number, number, number] }; worldBounds: { min: [number, number, number]; max: [number, number, number] }; voxelSize: [number, number, number]; indexToWorld: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; } export interface NanoVDBMaterialIR { densityGrid: string; temperatureGrid?: string; colorGrid?: string; emissionGrid?: string; velocityGrid?: string; densityScale: number; emissionScale: number; temperatureScale: number; anisotropy: number; interpolation: "NEAREST" | "LINEAR"; color?: [number, number, number]; emissionColor?: [number, number, number]; } export interface NanoVDBGpuLayoutIR { representation: "NANOVDB_STORAGE_BUFFER"; byteAlignment: 32; pageByteLength: number; maxResidentBytes: number; shaderSemanticVersion: "volume-wgsl-v1"; float32TreeLayout?: NanoVDBFloat32TreeLayoutIR; vec3fTreeLayout?: NanoVDBFloat32TreeLayoutIR; } export interface NanoVDBFloat32TreeLayoutIR { gridDataBytes: number; treeDataBytes: number; treeRootOffsetOffset: number; rootDataBytes: number; rootTableSizeOffset: number; rootTileBytes: number; rootTileKeyOffset: number; rootTileChildOffset: number; rootTileStateOffset: number; rootTileValueOffset: number; upperNodeBytes: number; upperValueMaskOffset: number; upperChildMaskOffset: number; upperTableOffset: number; lowerNodeBytes: number; lowerValueMaskOffset: number; lowerChildMaskOffset: number; lowerTableOffset: number; leafNodeBytes: number; leafValueMaskOffset: number; leafValuesOffset: number; } export interface NanoVDBBundleManifestIR { schemaVersion: typeof VDB_PIPELINE_SCHEMA; projectId: string; sourcePath: string; sourceSha256: string; conversionRequestSha256: string; bundlePath: string; bundleByteLength: number; bundleSha256: string; converter: VDBConverterIdentityIR; grids: NanoVDBGridIR[]; chunks: NanoVDBChunkIR[]; material: NanoVDBMaterialIR; gpu: NanoVDBGpuLayoutIR; } export interface VDBProjectBindingIR { schemaVersion: typeof VDB_PIPELINE_SCHEMA; projectId: string; sourceBlendSha256: string; sourcePath: string; sourceSha256: string; conversionRequestSha256: string; bundleSha256: string; bundleByteLength: number; manifestSha256: string; converter: VDBConverterIdentityIR; shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"]; material: NanoVDBMaterialIR; committedAt: string; } export interface VDBProjectReopenContextIR { projectId: string; sourceBlendSha256: string; sourcePath: string; sourceSha256: string; converter: VDBConverterIdentityIR; shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"]; } export interface VDBProjectBindingStatusIR { status: "READY" | "BLOCKED"; code?: "VDB_BINDING_MISSING" | "VDB_SOURCE_CHANGED" | "VDB_CONVERTER_CHANGED" | "NANOVDB_HASH_MISMATCH" | "VOLUME_SHADER_UNAVAILABLE"; message?: string; } export interface NanoVDBRangeIR { chunkIndex: number; start: number; endExclusive: number; sha256: string; } export interface NanoVDBPipelineContext { desktopConverterConfigured?: boolean; serverConverterConfigured?: boolean; manifestValidated?: boolean; rangeReaderAvailable?: boolean; webgpuAvailable?: boolean; volumeRendererAvailable?: boolean; } function fail(code: ErrorCode, message: string): never { throw new VDBPipelineError(code, message); } function safeInteger(value: number, name: string, min: number, max: number): number { if (!Number.isSafeInteger(value) || value < min || value > max) fail("NANOVDB_MANIFEST_INVALID", `${name} is outside the bounded integer range`); return value; } function finite(value: number, name: string): number { if (!Number.isFinite(value)) fail("NANOVDB_MANIFEST_INVALID", `${name} must be finite`); return value; } function projectPath(sourcePath: string, extension: string, label: string): string { let normalized: string; try { normalized = normalizeProjectAssetPath(sourcePath); } catch { fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", `${label} path is outside the project asset root`); } if (!normalized.toLowerCase().endsWith(extension)) fail("NON_MESH_BINARY_INVALID", `${label} must use the ${extension} extension`); return normalized; } function validateIdentity(value: VDBConverterIdentityIR): VDBConverterIdentityIR { if (value.target !== "DESKTOP" && value.target !== "SERVER") fail("VDB_CONVERSION_INVALID", "Converter target is invalid"); for (const [name, version] of Object.entries({ blenderVersion: value.blenderVersion, openVDBVersion: value.openVDBVersion, nanoVDBVersion: value.nanoVDBVersion })) { if (typeof version !== "string" || version.length === 0 || version.length > 128) fail("VDB_CONVERSION_INVALID", `${name} is invalid`); } if (!SHA256_PATTERN.test(value.executableSha256)) fail("VDB_CONVERSION_INVALID", "Converter executable SHA-256 is invalid"); return { ...value }; } function validateBounds( bounds: { min: [number, number, number]; max: [number, number, number] }, name: string, integer: boolean, ): void { if (!bounds || bounds.min.length !== 3 || bounds.max.length !== 3) fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`); bounds.min.forEach((value, index) => { if (!Number.isFinite(value) || value > bounds.max[index] || (integer && (!Number.isSafeInteger(value) || !Number.isSafeInteger(bounds.max[index])))) { fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`); } }); } function validateColor(value: [number, number, number] | undefined, name: string): void { if (value === undefined) return; if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1000000)) { fail("NANOVDB_MANIFEST_INVALID", `${name} must contain three finite non-negative channels`); } } function hex(bytes: Uint8Array): string { return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); } async function sha256(data: ArrayBuffer): Promise { if (!globalThis.crypto?.subtle) fail("NON_MESH_BINARY_INVALID", "SHA-256 is unavailable"); return hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data))); } export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest { if (!manifest.projectId || !ID_PATTERN.test(manifest.projectId)) fail("NON_MESH_BINARY_INVALID", "VDB projectId is invalid"); const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB resource"); if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 || manifest.byteLength > VDB_MAX_RESOURCE_BYTES) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB resource size is outside the bounded range"); if (!SHA256_PATTERN.test(manifest.sha256)) fail("NON_MESH_BINARY_INVALID", "VDB SHA-256 is invalid"); if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB grid count is outside the bounded range"); const names = new Set(); let activeVoxels = 0; for (const grid of manifest.grids) { if (!grid.name || names.has(grid.name) || !grid.valueType) fail("NON_MESH_BINARY_INVALID", "VDB grid identity is missing or duplicated"); names.add(grid.name); const count = grid.activeVoxelCount ?? grid.voxelCount; if (!Number.isSafeInteger(count) || count < 0) fail("NON_MESH_BINARY_INVALID", `VDB grid ${grid.name} has an invalid active voxel count`); activeVoxels += count; if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB active voxel budget exceeded"); if (grid.bounds) validateBounds(grid.bounds, `VDB grid ${grid.name}`, false); } return { ...manifest, sourcePath, grids: manifest.grids.map((grid) => ({ ...grid })) }; } export async function prepareVDBConversionInput(request: VDBConversionInput, signal: AbortSignal): Promise { const metadata = validateVDBManifest(request); if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError"); if (!(request.data instanceof ArrayBuffer) || request.data.byteLength !== metadata.byteLength) fail("NON_MESH_BINARY_INVALID", "VDB byte length does not match its manifest"); if (await sha256(request.data) !== metadata.sha256) fail("NANOVDB_HASH_MISMATCH", "VDB bytes do not match the source manifest SHA-256"); if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError"); return { metadata, data: request.data }; } export function validateVDBConversionRequest(request: VDBConversionRequestIR): VDBConversionRequestIR { if (request.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(request.jobId)) fail("VDB_CONVERSION_INVALID", "Conversion request schema or job ID is invalid"); const source = validateVDBManifest(request.source); const outputPath = projectPath(request.outputPath, ".nvdb", "NanoVDB output"); if (request.sourceBlendSha256 !== undefined && !SHA256_PATTERN.test(request.sourceBlendSha256)) fail("VDB_CONVERSION_INVALID", "Source blend SHA-256 is invalid"); if (!Array.isArray(request.selectedGrids) || request.selectedGrids.length === 0 || request.selectedGrids.length > VDB_MAX_GRIDS) fail("VDB_CONVERSION_INVALID", "Selected grid list is invalid"); const available = new Set(source.grids.map((grid) => grid.name)); const selected = new Set(); request.selectedGrids.forEach((name) => { if (!available.has(name) || selected.has(name)) fail("VDB_CONVERSION_INVALID", `Selected grid ${name} is missing or duplicated`); selected.add(name); }); if (!["LOSSLESS", "FP16", "FP8"].includes(request.quantization)) fail("VDB_CONVERSION_INVALID", "NanoVDB quantization is invalid"); if (!Number.isSafeInteger(request.chunkByteLength) || request.chunkByteLength < 64 * 1024 || request.chunkByteLength > NANOVDB_MAX_CHUNK_BYTES || request.chunkByteLength % 32 !== 0) fail("VDB_CONVERSION_INVALID", "Chunk size must be 32-byte aligned and within 64 KiB to 16 MiB"); return { ...request, source, outputPath, selectedGrids: [...request.selectedGrids], converter: validateIdentity(request.converter) }; } export function serializeVDBConversionRequest(value: VDBConversionRequestIR): string { const request = validateVDBConversionRequest(value); return JSON.stringify({ schemaVersion: request.schemaVersion, source: { byteLength: request.source.byteLength, sha256: request.source.sha256, grids: request.source.grids.map((grid) => ({ name: grid.name, valueType: grid.valueType, voxelCount: grid.voxelCount, ...(grid.activeVoxelCount === undefined ? {} : { activeVoxelCount: grid.activeVoxelCount }), ...(grid.bounds === undefined ? {} : { bounds: grid.bounds }), })), }, ...(request.sourceBlendSha256 === undefined ? {} : { sourceBlendSha256: request.sourceBlendSha256 }), selectedGrids: request.selectedGrids, quantization: request.quantization, chunkByteLength: request.chunkByteLength, converter: request.converter, }); } export async function hashVDBConversionRequest(value: VDBConversionRequestIR): Promise { const encoded = new TextEncoder().encode(serializeVDBConversionRequest(value)); return sha256(encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength) as ArrayBuffer); } export function validateNanoVDBBundleManifest(manifest: NanoVDBBundleManifestIR): NanoVDBBundleManifestIR { if (manifest.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(manifest.projectId)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB schema or project ID is invalid"); const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB source"); const bundlePath = projectPath(manifest.bundlePath, ".nvdb", "NanoVDB bundle"); if (!SHA256_PATTERN.test(manifest.sourceSha256) || !SHA256_PATTERN.test(manifest.conversionRequestSha256) || !SHA256_PATTERN.test(manifest.bundleSha256)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB source, conversion request, or bundle SHA-256 is invalid"); safeInteger(manifest.bundleByteLength, "bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES); const converter = validateIdentity(manifest.converter); if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0 || manifest.chunks.length > NANOVDB_MAX_CHUNKS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB chunk count is outside the bounded range"); let nextOffset = 0; const chunks = manifest.chunks.map((chunk, position) => { if (chunk.index !== position || chunk.byteOffset !== nextOffset || chunk.byteOffset % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} is not contiguous or aligned`); safeInteger(chunk.byteLength, `chunks[${position}].byteLength`, 1, NANOVDB_MAX_CHUNK_BYTES); if (position < manifest.chunks.length - 1 && chunk.byteLength % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} length is not aligned`); if (!SHA256_PATTERN.test(chunk.sha256)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB chunk ${position} SHA-256 is invalid`); nextOffset += chunk.byteLength; if (!Number.isSafeInteger(nextOffset) || nextOffset > manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunk ranges exceed the bundle"); return { ...chunk }; }); if (nextOffset !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunks do not cover the complete bundle"); if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid count is outside the bounded range"); const names = new Set(); let activeVoxels = 0; const grids = manifest.grids.map((grid) => { if (!grid.name || names.has(grid.name)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid identity is missing or duplicated"); names.add(grid.name); if (!SUPPORTED_GRID_TYPES.has(grid.valueType)) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB grid ${grid.name} uses unsupported value type ${grid.valueType}`); if (!["FOG_VOLUME", "LEVEL_SET", "STAGGERED", "UNKNOWN"].includes(grid.gridClass) || !["DENSITY", "TEMPERATURE", "COLOR", "EMISSION", "VELOCITY", "CUSTOM"].includes(grid.semantic)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} class or semantic is invalid`); safeInteger(grid.activeVoxelCount, `${grid.name}.activeVoxelCount`, 0, VDB_MAX_ACTIVE_VOXELS); activeVoxels += grid.activeVoxelCount; if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "NanoVDB active voxel budget exceeded"); safeInteger(grid.segmentByteOffset, `${grid.name}.segmentByteOffset`, 0, manifest.bundleByteLength - 1); safeInteger(grid.segmentByteLength, `${grid.name}.segmentByteLength`, 1, manifest.bundleByteLength); safeInteger(grid.byteOffset, `${grid.name}.byteOffset`, 0, manifest.bundleByteLength - 1); safeInteger(grid.byteLength, `${grid.name}.byteLength`, 1, manifest.bundleByteLength); if (grid.segmentByteOffset + grid.segmentByteLength > manifest.bundleByteLength || grid.byteOffset < grid.segmentByteOffset || grid.byteOffset + grid.byteLength > grid.segmentByteOffset + grid.segmentByteLength) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} segment or payload range is invalid`); validateBounds(grid.indexBounds, `NanoVDB grid ${grid.name} index`, true); validateBounds(grid.worldBounds, `NanoVDB grid ${grid.name} world`, false); if (grid.voxelSize.length !== 3 || grid.voxelSize.some((value) => !Number.isFinite(value) || value <= 0)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} voxel size is invalid`); if (grid.indexToWorld.length !== 16 || grid.indexToWorld.some((value) => !Number.isFinite(value))) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} transform is invalid`); return { ...grid, indexBounds: { min: [...grid.indexBounds.min], max: [...grid.indexBounds.max] }, worldBounds: { min: [...grid.worldBounds.min], max: [...grid.worldBounds.max] }, voxelSize: [...grid.voxelSize], indexToWorld: [...grid.indexToWorld] } as NanoVDBGridIR; }); const orderedRanges = [...grids].sort((left, right) => left.segmentByteOffset - right.segmentByteOffset); for (let index = 1; index < orderedRanges.length; index += 1) { if (orderedRanges[index - 1].segmentByteOffset + orderedRanges[index - 1].segmentByteLength > orderedRanges[index].segmentByteOffset) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid segments overlap"); } const material = { ...manifest.material }; const references: Array<[keyof NanoVDBMaterialIR, NanoVDBGridSemantic]> = [ ["densityGrid", "DENSITY"], ["temperatureGrid", "TEMPERATURE"], ["colorGrid", "COLOR"], ["emissionGrid", "EMISSION"], ["velocityGrid", "VELOCITY"], ]; for (const [field, semantic] of references) { const gridName = material[field]; if (typeof gridName !== "string") continue; const grid = grids.find((candidate) => candidate.name === gridName); if (!grid || grid.semantic !== semantic) fail("NANOVDB_MANIFEST_INVALID", `Material ${field} does not reference a ${semantic} grid`); } finite(material.densityScale, "material.densityScale"); finite(material.emissionScale, "material.emissionScale"); finite(material.temperatureScale, "material.temperatureScale"); validateColor(material.color, "material.color"); validateColor(material.emissionColor, "material.emissionColor"); if (material.densityScale < 0 || material.emissionScale < 0 || material.temperatureScale < 0 || !Number.isFinite(material.anisotropy) || material.anisotropy < -0.99 || material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB material parameters are invalid"); const gpu = { ...manifest.gpu }; if (gpu.representation !== "NANOVDB_STORAGE_BUFFER" || gpu.byteAlignment !== 32 || gpu.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU representation is unsupported"); if (!Number.isSafeInteger(gpu.pageByteLength) || gpu.pageByteLength < 64 * 1024 || gpu.pageByteLength > NANOVDB_MAX_CHUNK_BYTES || gpu.pageByteLength % 32 !== 0) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU page size is invalid"); if (!Number.isSafeInteger(gpu.maxResidentBytes) || gpu.maxResidentBytes < gpu.pageByteLength || gpu.maxResidentBytes > NANOVDB_MAX_GPU_RESIDENT_BYTES) fail("NANOVDB_GPU_BUDGET_EXCEEDED", "NanoVDB GPU resident budget is invalid"); if (gpu.float32TreeLayout !== undefined) { const layout = gpu.float32TreeLayout; const expected: NanoVDBFloat32TreeLayoutIR = { gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24, rootDataBytes: 64, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20, upperNodeBytes: 270400, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256, lowerNodeBytes: 33856, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088, leafNodeBytes: 2144, leafValueMaskOffset: 16, leafValuesOffset: 96, }; for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Float32 layout ${name} is unsupported`); } if (gpu.vec3fTreeLayout !== undefined) { const layout = gpu.vec3fTreeLayout; const expected: NanoVDBFloat32TreeLayoutIR = { gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24, rootDataBytes: 96, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20, upperNodeBytes: 532544, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256, lowerNodeBytes: 66624, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088, leafNodeBytes: 6272, leafValueMaskOffset: 16, leafValuesOffset: 128, }; for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Vec3f layout ${name} is unsupported`); } return { ...manifest, sourcePath, bundlePath, converter, chunks, grids, material, gpu }; } export function validateVDBProjectBinding(value: VDBProjectBindingIR): VDBProjectBindingIR { if (value.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(value.projectId)) fail("NANOVDB_MANIFEST_INVALID", "VDB project binding schema or project id is invalid"); const sourcePath = projectPath(value.sourcePath, ".vdb", "VDB binding source"); for (const [name, digest] of Object.entries({ sourceBlendSha256: value.sourceBlendSha256, sourceSha256: value.sourceSha256, conversionRequestSha256: value.conversionRequestSha256, bundleSha256: value.bundleSha256, manifestSha256: value.manifestSha256, })) if (!SHA256_PATTERN.test(digest)) fail("NANOVDB_MANIFEST_INVALID", `VDB binding ${name} is invalid`); safeInteger(value.bundleByteLength, "binding.bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES); if (value.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "VDB binding shader semantic version is unsupported"); if (typeof value.committedAt !== "string" || !Number.isFinite(Date.parse(value.committedAt))) fail("NANOVDB_MANIFEST_INVALID", "VDB binding commit timestamp is invalid"); const converter = validateIdentity(value.converter); const synthetic: NanoVDBBundleManifestIR = { schemaVersion: VDB_PIPELINE_SCHEMA, projectId: value.projectId, sourcePath, sourceSha256: value.sourceSha256, conversionRequestSha256: value.conversionRequestSha256, bundlePath: "//cache/binding.nvdb", bundleByteLength: value.bundleByteLength, bundleSha256: value.bundleSha256, converter, grids: [{ name: value.material.densityGrid, valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0, segmentByteOffset: 0, segmentByteLength: 1, byteOffset: 0, byteLength: 1, indexBounds: { min: [0, 0, 0], max: [0, 0, 0] }, worldBounds: { min: [0, 0, 0], max: [0, 0, 0] }, voxelSize: [1, 1, 1], indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }], chunks: [{ index: 0, byteOffset: 0, byteLength: value.bundleByteLength, sha256: value.bundleSha256 }], material: { ...value.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, velocityGrid: undefined }, gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: Math.min(NANOVDB_MAX_CHUNK_BYTES, Math.max(64 * 1024, Math.ceil(Math.min(value.bundleByteLength, NANOVDB_MAX_CHUNK_BYTES) / 32) * 32)), maxResidentBytes: NANOVDB_MAX_GPU_RESIDENT_BYTES, shaderSemanticVersion: value.shaderSemanticVersion }, }; // Reuse bounded scalar material checks without requiring all referenced grids in this binding record. finite(synthetic.material.densityScale, "binding.material.densityScale"); finite(synthetic.material.emissionScale, "binding.material.emissionScale"); finite(synthetic.material.temperatureScale, "binding.material.temperatureScale"); validateColor(synthetic.material.color, "binding.material.color"); validateColor(synthetic.material.emissionColor, "binding.material.emissionColor"); if (synthetic.material.densityScale < 0 || synthetic.material.emissionScale < 0 || synthetic.material.temperatureScale < 0 || !Number.isFinite(synthetic.material.anisotropy) || synthetic.material.anisotropy < -0.99 || synthetic.material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(synthetic.material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "VDB binding material is invalid"); return { ...value, sourcePath, converter, material: { ...value.material } }; } export function evaluateVDBProjectBinding(value: VDBProjectBindingIR | undefined, context: VDBProjectReopenContextIR): VDBProjectBindingStatusIR { if (!value) return { status: "BLOCKED", code: "VDB_BINDING_MISSING", message: "The project has no committed NanoVDB binding" }; const binding = validateVDBProjectBinding(value); if (binding.projectId !== context.projectId || binding.sourcePath !== projectPath(context.sourcePath, ".vdb", "VDB reopen source") || binding.sourceBlendSha256 !== context.sourceBlendSha256 || binding.sourceSha256 !== context.sourceSha256) { return { status: "BLOCKED", code: "VDB_SOURCE_CHANGED", message: "The blend or VDB source changed after conversion" }; } const converter = validateIdentity(context.converter); if (serializeIdentity(binding.converter) !== serializeIdentity(converter)) return { status: "BLOCKED", code: "VDB_CONVERTER_CHANGED", message: "The VDB converter identity changed" }; if (binding.shaderSemanticVersion !== context.shaderSemanticVersion) return { status: "BLOCKED", code: "VOLUME_SHADER_UNAVAILABLE", message: "The volume shader semantic version changed" }; return { status: "READY" }; } function serializeIdentity(value: VDBConverterIdentityIR): string { return `${value.target}\n${value.blenderVersion}\n${value.openVDBVersion}\n${value.nanoVDBVersion}\n${value.executableSha256}`; } export function planNanoVDBRanges(value: NanoVDBBundleManifestIR): NanoVDBRangeIR[] { const manifest = validateNanoVDBBundleManifest(value); return manifest.chunks.map((chunk) => ({ chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 })); } export async function verifyNanoVDBChunk(chunk: NanoVDBChunkIR, data: ArrayBuffer): Promise { if (!(data instanceof ArrayBuffer) || data.byteLength !== chunk.byteLength) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${chunk.index} byte length is incomplete`); if (await sha256(data) !== chunk.sha256) fail("NANOVDB_HASH_MISMATCH", `NanoVDB chunk ${chunk.index} SHA-256 mismatch`); } export async function verifyNanoVDBBundle(manifestValue: NanoVDBBundleManifestIR, data: ArrayBuffer): Promise { const manifest = validateNanoVDBBundleManifest(manifestValue); if (!(data instanceof ArrayBuffer) || data.byteLength !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB bundle byte length is incomplete"); if (await sha256(data) !== manifest.bundleSha256) fail("NANOVDB_HASH_MISMATCH", "NanoVDB bundle SHA-256 mismatch"); } export function gateNanoVDBPipeline(stage: NanoVDBPipelineStage, context: NanoVDBPipelineContext = {}): CapabilityGateResult { if (stage === "RAW_VDB_BROWSER_DECODE") { return blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERSION_REQUIRED", "Raw OpenVDB must be converted by the desktop or server OpenVDB toolchain; browser decoding is intentionally unavailable")]); } if (stage === "DESKTOP_CONVERSION") { return context.desktopConverterConfigured ? readyGate("N-015", stage) : blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The desktop OpenVDB to NanoVDB converter is not configured")]); } if (stage === "SERVER_CONVERSION") { return context.serverConverterConfigured ? readyGate("N-015", stage) : blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The server OpenVDB to NanoVDB job endpoint is not configured")]); } if (stage === "NANOVDB_STREAM") { return context.manifestValidated && context.rangeReaderAvailable ? readyGate("N-015", stage) : blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "A validated NanoVDB manifest and bounded range reader are required")]); } if (!context.webgpuAvailable) return blockedGate("N-015", stage, [capabilityIssue("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable in this browser or device")]); if (!context.manifestValidated || !context.rangeReaderAvailable) return blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "Volume rendering requires a validated and readable NanoVDB stream")]); return context.volumeRendererAvailable ? readyGate("N-015", stage) : blockedGate("N-015", stage, [capabilityIssue("VOLUME_SHADER_UNAVAILABLE", "The NanoVDB WGSL traversal and volume material renderer have not been installed")]); }