157 lines
9.8 KiB
TypeScript
157 lines
9.8 KiB
TypeScript
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<string> => 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<void> => {
|
|
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<any> => {
|
|
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<string[]> => 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);
|
|
});
|