import { expect, test } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"); test("M8-18 saves Volume Main and its NanoVDB binding, then reopens both production viewports", async ({ page }) => { test.setTimeout(240_000); await page.goto("/"); const blend = fs.readFileSync(nonMeshBlend); const result = await page.evaluate(async ({ blendBytes }) => { const [engineModule, storageModule, volumeModule, opfsModule, viewportModule, offscreenModule] = await Promise.all([ import("/src/engine-client/WebEngineClient.ts"), import("/src/storage/StorageClient.ts"), import("/src/volume/nanovdb-viewport.ts"), import("/src/volume/nanovdb-opfs.ts"), import("/src/three-adapter/viewport.ts"), import("/src/three-adapter/offscreen-viewport.ts"), ]); const { WebEngineClient } = engineModule; const { StorageClient } = storageModule; const { loadAndCommitNanoVDBViewportAsset, reopenNanoVDBViewportAssetFromOPFS, } = volumeModule; const { listVDBProjectBindings, pruneNanoVDBOPFS } = opfsModule; const { ViewportRenderer } = viewportModule; const { OffscreenViewportRenderer } = offscreenModule; const projectId = "m8-volume-roundtrip"; const mainSourcePath = "//volumes/generated-smoke.vdb"; const bindingSourcePath = "volumes/generated-smoke.vdb"; const data = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer; const digest = async (value: ArrayBuffer): Promise => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", value)), (byte) => byte.toString(16).padStart(2, "0")).join(""); const waitFor = async (condition: () => boolean, timeoutMs = 120_000): Promise => { const deadline = performance.now() + timeoutMs; while (!condition()) { if (performance.now() > deadline) throw new Error("M8-18 viewport volume timeout"); await new Promise((resolve) => setTimeout(resolve, 25)); } }; const renderViewport = async ( backend: "main" | "offscreen", snapshot: any, geometryBuffers: any[], nonMeshGeometryBuffers: any[], asset: any, ): Promise => { const canvas = document.createElement("canvas"); canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000"; document.body.append(canvas); const renderer: any = backend === "main" ? new ViewportRenderer(canvas) : new OffscreenViewportRenderer(canvas); renderer.setSnapshot(snapshot, geometryBuffers, nonMeshGeometryBuffers); renderer.setVolumeAssets([asset]); await waitFor(() => ["ready", "blocked"].includes(canvas.dataset.volumeStatus ?? "")); if (canvas.dataset.volumeStatus !== "ready") throw new Error(`${backend} volume ${canvas.dataset.volumeErrorCode ?? "blocked"}`); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const visible = backend === "offscreen" ? Number(canvas.dataset.rendererPixels ?? 0) : (() => { const gl = renderer.renderer.getContext(); const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels); return Array.from({ length: 64 * 64 }, (_, index) => pixels[index * 4 + 3] > 0 && pixels[index * 4] + pixels[index * 4 + 1] + pixels[index * 4 + 2] > 40).filter(Boolean).length; })(); let volumeObjects = 0; if (backend === "main") renderer.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) volumeObjects += 1; }); const status = { backend, volumeStatus: canvas.dataset.volumeStatus, volumeCount: Number(canvas.dataset.volumeCount), volumeObjects, visible }; renderer.dispose(); canvas.remove(); return status; }; await pruneNanoVDBOPFS(projectId, 0); const firstEngine = new WebEngineClient({ timeoutMs: 60_000 }); await firstEngine.init(); const opened = await firstEngine.openBlend(data.slice(0)); const volume = opened.snapshot.nonMeshData.find((candidate: any) => candidate.type === "VOLUME"); if (!volume) throw new Error("M8-18 nonmesh fixture has no Volume Main data"); const properties = { displayDensity: 1.75, interpolation: "NEAREST" as const, stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 }; const changed = await firstEngine.applyCommand({ type: "setVolumeProperties", dataId: volume.id, sourcePath: mainSourcePath, ...properties }); const saved = await firstEngine.saveBlend(); const savedHash = await digest(saved); const firstVolume = changed.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id); if (!firstVolume || firstVolume.sourcePath !== mainSourcePath) throw new Error("M8-18 Volume Main source path did not commit"); const storage = new StorageClient(); const savedProject = await storage.saveProject(projectId, 7, saved.slice(0)); storage.terminate(); firstEngine.terminate(); const committedStorage = new StorageClient(); const persisted = await committedStorage.readProject(projectId); committedStorage.terminate(); const committedAsset = await loadAndCommitNanoVDBViewportAsset( volume.id, bindingSourcePath, "/volumes/generated-smoke.nanovdb.json", "/volumes/generated-smoke.nvdb", { projectId, sourceBlendSha256: savedHash }, new AbortController().signal, ); const bindings = await listVDBProjectBindings(projectId); const binding = bindings.find((candidate: any) => candidate.bundleSha256 === committedAsset.manifest.bundleSha256); if (!binding) throw new Error("M8-18 VDB binding was not discoverable after commit"); const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 }); await reopenedEngine.init(); const reopened = await reopenedEngine.openBlend(persisted.buffer.slice(0)); const reopenedVolume = reopened.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id); if (!reopenedVolume) throw new Error("M8-18 reopened Main has no Volume data"); const reopenedAsset = await reopenNanoVDBViewportAssetFromOPFS( volume.id, bindingSourcePath, { projectId, sourceBlendSha256: savedHash }, new AbortController().signal, ); const assetHashes = async (asset: any): Promise => Promise.all(asset.grids.map((grid: any) => digest(grid.data))); const committedHashes = await assetHashes(committedAsset); const reopenedHashes = await assetHashes(reopenedAsset); const mainViewport = await renderViewport("main", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset); const offscreenViewport = await renderViewport("offscreen", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset); const reopenedBytesHash = await digest(persisted.buffer); reopenedEngine.terminate(); await pruneNanoVDBOPFS(projectId, 0); return { project: { revision: savedProject.revision, bytes: savedProject.bytes, persisted: savedProject.persisted, backend: savedProject.backend, hash: savedProject.sha256 }, savedHash, reopenedStorage: { revision: persisted.revision, bytes: persisted.bytes, sha256: persisted.sha256, backend: persisted.backend, recovered: persisted.recovered, bytesHash: reopenedBytesHash }, main: { id: firstVolume.id, sourcePath: firstVolume.sourcePath, properties: firstVolume.volumeProperties }, reopenedMain: { id: reopenedVolume.id, sourcePath: reopenedVolume.sourcePath, properties: reopenedVolume.volumeProperties, revision: reopened.snapshot.revision }, binding: { projectId: binding.projectId, sourcePath: binding.sourcePath, sourceBlendSha256: binding.sourceBlendSha256, bundleSha256: binding.bundleSha256, manifestSha256: binding.manifestSha256 }, asset: { dataId: reopenedAsset.dataId, bundleSha256: reopenedAsset.manifest.bundleSha256, committedHashes, reopenedHashes, gridCount: reopenedAsset.grids.length }, viewports: { main: mainViewport, offscreen: offscreenViewport }, }; }, { blendBytes: new Uint8Array(blend) }); console.log("nanovdb-volume-roundtrip", JSON.stringify({ project: result.project, savedHash: result.savedHash, reopenedStorage: result.reopenedStorage, binding: result.binding, asset: result.asset, viewports: result.viewports, })); expect(result.project).toMatchObject({ revision: 7, persisted: true, backend: "opfs", hash: result.savedHash }); expect(result.project.bytes).toBeGreaterThan(0); expect(result.reopenedStorage).toMatchObject({ revision: 7, sha256: result.savedHash, backend: "opfs", recovered: false, bytesHash: result.savedHash }); expect(result.main).toMatchObject({ id: result.reopenedMain.id, sourcePath: "//volumes/generated-smoke.vdb", properties: { displayDensity: 1.75, interpolation: "NEAREST", stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 } }); expect(result.reopenedMain).toMatchObject(result.main); expect(result.binding).toMatchObject({ projectId: "m8-volume-roundtrip", sourcePath: "volumes/generated-smoke.vdb", sourceBlendSha256: result.savedHash }); expect(result.asset).toMatchObject({ dataId: result.main.id, committedHashes: result.asset.reopenedHashes }); expect(result.asset.gridCount).toBeGreaterThan(0); expect(result.viewports.main).toMatchObject({ backend: "main", volumeStatus: "ready", volumeCount: 1, volumeObjects: 1 }); expect(result.viewports.main.visible).toBeGreaterThan(50); expect(result.viewports.offscreen).toMatchObject({ backend: "offscreen", volumeStatus: "ready", volumeCount: 1 }); expect(result.viewports.offscreen.visible).toBeGreaterThan(10); });