Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -1,21 +1,31 @@
|
||||
import { ASSET_LIBRARY_BUDGET, assetStorageCapabilities, gateIORequest, gateLibraryMutation, libraryLoadOrder, parseAssetLibraryManifest, parseIORequest, verifyAssetSource } from "../../../protocol/asset-library-io";
|
||||
import { ASSET_LIBRARY_BUDGET, assetStorageCapabilities, gateIORequest, gateLibraryMutation, libraryLoadOrder, parseAssetLibraryManifest, parseIORequest, planIOArchiveRanges, verifyAssetPreview, verifyAssetSource } from "../../../protocol/asset-library-io";
|
||||
|
||||
const shaA = "a".repeat(64); const shaB = "b".repeat(64);
|
||||
const asset = { id: "asset:1", name: "Cube", kind: "OBJECT", catalogId: "catalog:models", tags: ["model"], author: "Web", license: "CC0-1.0", sourceSha256: shaA, sourcePath: "assets/cube.blend" };
|
||||
const base = { schemaVersion: 1, revision: 2, catalogs: [{ id: "catalog:root", name: "Root", parentId: null }, { id: "catalog:models", name: "Models", parentId: "catalog:root" }], assets: [asset], libraries: [{ id: "library:a", name: "A", sourcePath: "libraries/a.blend", sourceSha256: shaA, dependencyIds: ["library:b"], readOnly: true }, { id: "library:b", name: "B", sourcePath: "libraries/b.blend", sourceSha256: shaB, dependencyIds: [], readOnly: true }] };
|
||||
const glbRequest = { format: "GLB", operation: "EXPORT", externalUris: [], archiveEntries: [] };
|
||||
|
||||
self.onmessage = () => {
|
||||
self.onmessage = async () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const manifest = parseAssetLibraryManifest(base); verifyAssetSource(manifest.assets[0], shaA); result.valid = [manifest.assets[0].license, libraryLoadOrder(manifest)]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
|
||||
try { verifyAssetSource(parseAssetLibraryManifest(base).assets[0], shaB); } catch (error) { result.hash = error instanceof Error ? error.message : String(error); }
|
||||
try { parseAssetLibraryManifest({ ...base, assets: [{ ...asset, license: "" }] }); } catch (error) { result.license = error instanceof Error ? error.message : String(error); }
|
||||
try { parseAssetLibraryManifest({ ...base, libraries: [{ ...base.libraries[0], dependencyIds: ["library:a"] }, base.libraries[1]] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
|
||||
try { parseIORequest({ ...glbRequest, archiveEntries: [{ path: "safe.bin", compressedBytes: 1, uncompressedBytes: ASSET_LIBRARY_BUDGET.maxCompressionRatio + 1 }] }); } catch (error) { result.archive = error instanceof Error ? error.message : String(error); }
|
||||
try { parseIORequest({ ...glbRequest, archiveEntries: [{ path: "mesh", compressedBytes: 1, uncompressedBytes: 1 }, { path: "mesh/data.bin", compressedBytes: 1, uncompressedBytes: 1 }] }); } catch (error) { result.archivePath = error instanceof Error ? error.message : String(error); }
|
||||
try { parseIORequest({ ...glbRequest, byteLength: 1, archiveEntries: [{ path: "a.bin", compressedBytes: 2, uncompressedBytes: 2 }] }); } catch (error) { result.archiveLength = error instanceof Error ? error.message : String(error); }
|
||||
result.archivePlan = planIOArchiveRanges({ ...glbRequest, byteLength: 10, archiveEntries: [{ path: "z.bin", compressedBytes: 3, uncompressedBytes: 4 }, { path: "a.bin", compressedBytes: 2, uncompressedBytes: 2 }] });
|
||||
try { parseIORequest({ ...glbRequest, externalUris: ["https://example.com/file.bin"] }); } catch (error) { result.uri = error instanceof Error ? error.message : String(error); }
|
||||
result.glb = gateIORequest(glbRequest).status;
|
||||
result.obj = gateIORequest({ ...glbRequest, format: "OBJ", operation: "IMPORT" }).issues[0]?.code;
|
||||
result.library = gateLibraryMutation("APPEND").status;
|
||||
result.storage = assetStorageCapabilities();
|
||||
try {
|
||||
const png = new ArrayBuffer(24); const bytes = new Uint8Array(png); bytes.set([137, 80, 78, 71, 13, 10, 26, 10]); const view = new DataView(png); view.setUint32(16, 2, false); view.setUint32(20, 3, false);
|
||||
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", png)), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
await verifyAssetPreview({ assetId: "preview:1", sha256: digest, mimeType: "image/png", width: 2, height: 3, byteLength: 24 }, png);
|
||||
result.preview = digest;
|
||||
try { await verifyAssetPreview({ assetId: "preview:1", sha256: digest, mimeType: "image/png", width: 3, height: 2, byteLength: 24 }, png); } catch (error) { result.previewSize = error instanceof Error ? error.message : String(error); }
|
||||
} catch (error) { result.preview = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { executeCompositorGraph, gateCompositorGraph, parseCompositorGraph } from "../../../protocol/compositor";
|
||||
import { CompositorFrameCache, executeCompositorGraph, executeCompositorGraphCached, gateCompositorGraph, parseCompositorGraph } from "../../../protocol/compositor";
|
||||
|
||||
const node = (id: string, type: string, properties: Record<string, unknown> = {}) => ({ id, type, name: id, properties });
|
||||
const valid = {
|
||||
@@ -26,7 +26,7 @@ const valid = {
|
||||
],
|
||||
};
|
||||
|
||||
self.onmessage = () => {
|
||||
self.onmessage = async () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = parseCompositorGraph(valid);
|
||||
@@ -54,5 +54,13 @@ self.onmessage = () => {
|
||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
try { executeCompositorGraph(valid, new Map(), { width: 2, height: 2, cancelled: () => true }); }
|
||||
catch (error) { result.cancelled = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const cache = new CompositorFrameCache(512);
|
||||
const first = await executeCompositorGraphCached(valid, new Map(), cache, { frame: 1, width: 2, height: 2 });
|
||||
first.composite.data[0] = 99;
|
||||
const second = await executeCompositorGraphCached(valid, new Map(), cache, { frame: 1, width: 2, height: 2 });
|
||||
const third = await executeCompositorGraphCached(valid, new Map(), cache, { frame: 2, width: 2, height: 2 });
|
||||
result.cache = [first.cacheHit, second.cacheHit, third.cacheHit, first.cacheKey === second.cacheKey, first.cacheKey !== third.cacheKey, second.composite.data[0], cache.size, cache.byteLength];
|
||||
} catch (error) { result.cache = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyEditorWorkflowEdit, EDITOR_WORKFLOW_BUDGET, gateEditorOperation, parseEditorWorkflow } from "../../../protocol/editor-workflow";
|
||||
import { applyEditorWorkflowEdit, EDITOR_WORKFLOW_BUDGET, gateEditorOperation, keyChordFromKeyboardEvent, parseEditorWorkflow, resolveKeymapCommand } from "../../../protocol/editor-workflow";
|
||||
|
||||
const regions = [{ id: "header", kind: "HEADER", visible: true }, { id: "main", kind: "MAIN", visible: true }];
|
||||
const area = (id: string, editor: string, x: number, y: number, width: number, height: number) => ({ id, editor, regions, rect: { x, y, width, height }, maximized: false });
|
||||
@@ -6,10 +6,21 @@ const base = { schemaVersion: 1, workspaces: [{ id: "Layout", name: "Layout", ac
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const parsed = parseEditorWorkflow(base); const switched = applyEditorWorkflowEdit(parsed, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" }); const selected = applyEditorWorkflowEdit(switched, { type: "SET_SELECTION", revision: 1, selectedIds: ["object:2"], activeObjectId: "object:2" }); result.edit = [selected.context.revision, selected.context.activeEditor, selected.context.selection]; } catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
|
||||
try { const parsed = parseEditorWorkflow(base); const switched = applyEditorWorkflowEdit(parsed, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" }); const selected = applyEditorWorkflowEdit(switched, { type: "SET_SELECTION", revision: 1, selectedIds: ["object:2"], activeObjectId: "object:2" }); result.edit = [selected.context.revision, selected.context.activeEditor, selected.context.selection]; result.keymap = resolveKeymapCommand(parsed, keyChordFromKeyboardEvent({ key: "x", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })); } catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
|
||||
try { applyEditorWorkflowEdit(base, { type: "SET_ACTIVE_AREA", revision: 8, areaId: "viewport" }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
try { parseEditorWorkflow({ ...base, workspaces: [{ ...base.workspaces[0], areas: [area("a", "VIEW_3D", 0, 0, 0.6, 1), area("b", "OUTLINER", 0.5, 0, 0.5, 1)] }] }); } catch (error) { result.overlap = error instanceof Error ? error.message : String(error); }
|
||||
try { parseEditorWorkflow({ ...base, context: { ...base.context, selection: new Array(EDITOR_WORKFLOW_BUDGET.maxSelection + 1).fill("x") } }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
try { parseEditorWorkflow({ ...base, keymaps: [...base.keymaps, { id: "key:delete-duplicate", key: "x", modifiers: [], command: "object.delete.other", enabled: true }] }); } catch (error) { result.keymapConflict = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const scoped = parseEditorWorkflow({ ...base, keymaps: [
|
||||
{ id: "key:view", key: "Q", modifiers: [], command: "view.command", enabled: true, editors: ["VIEW_3D"] },
|
||||
{ id: "key:timeline", key: "Q", modifiers: [], command: "timeline.command", enabled: true, editors: ["TIMELINE"] },
|
||||
] });
|
||||
const chord = { key: "q", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false };
|
||||
const viewCommand = resolveKeymapCommand(scoped, keyChordFromKeyboardEvent(chord));
|
||||
const timeline = applyEditorWorkflowEdit(scoped, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" });
|
||||
result.scopedKeymap = [viewCommand, resolveKeymapCommand(timeline, keyChordFromKeyboardEvent(chord))];
|
||||
} catch (error) { result.scopedKeymap = error instanceof Error ? error.message : String(error); }
|
||||
result.view = gateEditorOperation("READ_ONLY_VIEW").status; result.writer = gateEditorOperation("WRITER").issues[0]?.code; result.gizmo = gateEditorOperation("GIZMO").issues[0]?.code;
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
71
web/app/src/workers/grease-pencil-viewport-test.worker.ts
Normal file
71
web/app/src/workers/grease-pencil-viewport-test.worker.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Line,
|
||||
PerspectiveCamera,
|
||||
Points,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import { applyGreasePencilPointPreview, applyGreasePencilPointSelection, createGreasePencilObject, greasePencilPointRef } from "../three-adapter/grease-pencil";
|
||||
import type { GreasePencilDataIR } from "../../../protocol/grease-pencil";
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
const data: GreasePencilDataIR = {
|
||||
id: "grease-pencil:Viewport",
|
||||
name: "Viewport",
|
||||
geometryStatus: "available",
|
||||
layerCount: 1,
|
||||
frameCount: 1,
|
||||
strokeCount: 1,
|
||||
pointCount: 3,
|
||||
layers: [{ id: "grease-pencil-layer:Viewport", name: "Layer", visible: true, locked: false, opacity: 1, frames: [{ frame: 1, drawing: {
|
||||
id: "grease-pencil-drawing:Viewport",
|
||||
strokeCount: 1,
|
||||
pointCount: 3,
|
||||
strokes: [{ id: "grease-pencil-stroke:Viewport", cyclic: false, pointCount: 3, points: [
|
||||
{ position: [-1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ position: [0, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ position: [1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
] }],
|
||||
} }] }],
|
||||
};
|
||||
const object = createGreasePencilObject(data, 1);
|
||||
if (!object) throw new Error("Grease Pencil viewport object was not created");
|
||||
object.updateMatrixWorld(true);
|
||||
let points: Points | undefined;
|
||||
let line: Line | undefined;
|
||||
object.traverse((child) => {
|
||||
if (!points && child instanceof Points && typeof child.userData.greasePencilPointDataId === "string") points = child;
|
||||
if (!line && child instanceof Line && typeof child.userData.greasePencilPreviewDataId === "string") line = child;
|
||||
});
|
||||
if (!points || !line) throw new Error("Grease Pencil viewport proxies were not created");
|
||||
points.visible = true;
|
||||
const camera = new PerspectiveCamera(45, 1, 0.01, 100);
|
||||
camera.position.set(0, 0, 5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
const ndc = new Vector3(0, 0, 0).project(camera);
|
||||
const raycaster = new Raycaster();
|
||||
raycaster.params.Points.threshold = 0.14;
|
||||
raycaster.setFromCamera(new Vector2(ndc.x, ndc.y), camera);
|
||||
const hit = raycaster.intersectObject(points, true)[0];
|
||||
if (!hit || hit.index === undefined) throw new Error("Grease Pencil point raycast did not hit");
|
||||
const ref = greasePencilPointRef(hit.object, hit.index);
|
||||
result.hit = ref;
|
||||
if (!ref) throw new Error("Grease Pencil point reference was not stable");
|
||||
applyGreasePencilPointSelection(object, [ref]);
|
||||
const colors = points.geometry.getAttribute("color");
|
||||
result.selectedColor = colors ? [colors.getX(hit.index), colors.getY(hit.index), colors.getZ(hit.index)] : null;
|
||||
applyGreasePencilPointPreview(object, data.id, data.layers[0].id, 1, [{ ...ref, position: [0.5, 1, 2] }]);
|
||||
const previewLine = line.geometry.getAttribute("position");
|
||||
const previewPoint = points.geometry.getAttribute("position");
|
||||
result.preview = [[previewLine.getX(1), previewLine.getY(1), previewLine.getZ(1)], [previewPoint.getX(1), previewPoint.getY(1), previewPoint.getZ(1)]];
|
||||
applyGreasePencilPointPreview(object, data.id, data.layers[0].id, 1, null);
|
||||
result.restored = [[previewLine.getX(1), previewLine.getY(1), previewLine.getZ(1)], [previewPoint.getX(1), previewPoint.getY(1), previewPoint.getZ(1)]];
|
||||
let proxyCount = 0;
|
||||
object.traverse((child) => { if (typeof child.userData.greasePencilPointDataId === "string") proxyCount++; });
|
||||
result.proxyCount = proxyCount;
|
||||
self.postMessage(result);
|
||||
};
|
||||
@@ -1,11 +1,34 @@
|
||||
import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction";
|
||||
import { applyCurveGizmoDelta, curveGizmoAxisDelta, deriveCurveHandleGizmoFrame } from "../../../protocol/nonmesh-interaction";
|
||||
import { applyCurveHandlePreview, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import { LineSegments, Points } from "../vendor/three/three.module.js";
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
const base = { schemaVersion: 1, dataId: "curve:1", baseRevision: 3, phase: "COMMIT", axis: 0, delta: [0.25, 0, 0], handles: [{ pointIndex: 2, side: "LEFT", position: [1, 2, 3] }] };
|
||||
try { const applied = applyCurveGizmoDelta({ ...base, phase: "PREVIEW" }, 3); result.preview = [applied.revision, applied.handles[0].position]; } catch (error) { result.preview = error instanceof Error ? error.message : String(error); }
|
||||
try { const applied = applyCurveGizmoDelta(base, 3); result.commit = [applied.revision, applied.handles[0].position]; } catch (error) { result.commit = error instanceof Error ? error.message : String(error); }
|
||||
try { applyCurveGizmoDelta(base, 4); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); }
|
||||
try { applyCurveGizmoDelta({ ...base, handles: [{ ...base.handles[0] }, { ...base.handles[0] }] }, 3); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); }
|
||||
try { applyCurveGizmoDelta({ ...base, delta: [0.25, 0.25, 0] }, 3); } catch (error) { result.axis = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const frame = deriveCurveHandleGizmoFrame([0, 0, 0, 1, 0, 0], [{ pointIndex: 0, side: "LEFT", position: [0, 1, 0] }]);
|
||||
const delta = curveGizmoAxisDelta(frame, 0, 0.25);
|
||||
const local = applyCurveGizmoDelta({ ...base, axisVector: frame.axes[0], delta, handles: [{ pointIndex: 0, side: "LEFT", position: [0, 1, 0] }] }, 3);
|
||||
result.localFrame = [frame.origin, frame.axes, local.handles[0].position];
|
||||
} catch (error) { result.localFrame = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const object = createNonMeshObject({ id: "curve:1", name: "Curve", type: "CURVE", geometryStatus: "available", pointCount: 2, splineCount: 1, controlPoints: [0, 0, 0, 1, 0, 0], splineOffsets: [0, 2], handlePointIndices: [0, 1], handlePoints: [-0.5, 0, 0, 0.5, 0, 0, 0.5, 0, 0, 1.5, 0, 0] });
|
||||
if (!object) throw new Error("Curve preview object was not created");
|
||||
let points: Points | undefined; let lines: LineSegments | undefined;
|
||||
object.traverse((child) => { if (child instanceof Points && child.userData.nonMeshHandleBasePositions) points = child; if (child instanceof LineSegments && child.userData.nonMeshHandleBasePositions) lines = child; });
|
||||
if (!points || !lines) throw new Error("Curve handle proxies were not created");
|
||||
const preview = [{ pointIndex: 0, side: "LEFT" as const, position: [-0.25, 0, 0] as [number, number, number] }];
|
||||
applyCurveHandlePreview(object, "curve:1", preview);
|
||||
const first = [points.geometry.getAttribute("position").getX(0), lines.geometry.getAttribute("position").getX(1)];
|
||||
applyCurveHandlePreview(object, "curve:1", preview);
|
||||
const repeated = points.geometry.getAttribute("position").getX(0);
|
||||
applyCurveHandlePreview(object, "curve:1", null);
|
||||
result.rendererPreview = [first, repeated, points.geometry.getAttribute("position").getX(0)];
|
||||
} catch (error) { result.rendererPreview = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PAINT_BUDGET, applyUdimTilePatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch } from "../../../protocol/paint";
|
||||
import { PAINT_BUDGET, applyUdimTilePatch, buildPaintBrushSpatialIndex, composePaintColorPatch, composePaintWeightPatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch, queryPaintBrushSpatialIndex } from "../../../protocol/paint";
|
||||
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
@@ -25,6 +25,27 @@ self.onmessage = async () => {
|
||||
{ index: 4, position: [3, 0, 0], normal: [0, 0, 1] },
|
||||
], [0, 0, 0], 2, 0.8, { frontFaceOnly: true, viewDirection: [0, 0, -1] });
|
||||
} catch (error) { result.brush = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const index = buildPaintBrushSpatialIndex(Array.from({ length: 1_000 }, (_, vertex) => ({ index: vertex, position: [vertex % 100, Math.floor(vertex / 100), 0], normal: [0, 0, 1] })), 1);
|
||||
const query = queryPaintBrushSpatialIndex(index, [50, 5, 0], 1.1, 1, { frontFaceOnly: true, viewDirection: [0, 0, -1], visibleVertexIndices: [449, 450, 451, 549, 550, 551, 649, 650, 651], requireVisibility: true });
|
||||
result.spatialBrush = [query.candidateCount, query.visitedCellCount, query.weights.map((entry) => entry.index)];
|
||||
result.selectedMasked = queryPaintBrushSpatialIndex(index, [50, 5, 0], 1.1, 1, { selectedVertexIndices: [450, 550, 650], requireSelection: true, maskWeights: [{ index: 450, weight: 0.25 }, { index: 550, weight: 0.5 }, { index: 650, weight: 0 }] }).weights;
|
||||
try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { requireVisibility: true }); } catch (error) { result.spatialVisibility = error instanceof Error ? error.message : String(error); }
|
||||
try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { requireSelection: true }); } catch (error) { result.spatialSelection = error instanceof Error ? error.message : String(error); }
|
||||
try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { selectedVertexIndices: [450, 450] }); } catch (error) { result.selectionDuplicate = error instanceof Error ? error.message : String(error); }
|
||||
try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { maskWeights: [{ index: 450, weight: 1.1 }] }); } catch (error) { result.maskInvalid = error instanceof Error ? error.message : String(error); }
|
||||
try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { selectedVertexIndices: [1001] }); } catch (error) { result.selectionUnknown = error instanceof Error ? error.message : String(error); }
|
||||
try { queryPaintBrushSpatialIndex({ schemaVersion: 1, cellSize: 1, vertices: [], cells: new Map() }, [0, 0, 0], 1, 1); } catch (error) { result.spatialForgery = error instanceof Error ? error.message : String(error); }
|
||||
(index.cells as Map<string, readonly number[]>).clear();
|
||||
result.spatialMutation = queryPaintBrushSpatialIndex(index, [50, 5, 0], 1.1, 1, { visibleVertexIndices: [450, 550], requireVisibility: true }).candidateCount;
|
||||
} catch (error) { result.spatialBrush = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const brush = [{ index: 2, weight: 0.25 }, { index: 0, weight: 1 }];
|
||||
result.weightPatch = composePaintWeightPatch("object:Paint", "Group", 4, 4, [0.2, 0.4, 0.6], brush, 1);
|
||||
result.colorPatch = composePaintColorPatch(4, 4, [0, 0, 0, 1, 0.5, 0.5, 0.5, 1, 1, 0, 0, 1], brush, [0, 1, 0, 0.5]);
|
||||
try { composePaintWeightPatch("object:Paint", "Group", 3, 4, [0], [{ index: 0, weight: 1 }], 1); } catch (error) { result.patchRevision = error instanceof Error ? error.message : String(error); }
|
||||
try { composePaintColorPatch(4, 4, [0, 0, 0, 1], [{ index: 4, weight: 1 }], [1, 1, 1, 1]); } catch (error) { result.patchIdentity = error instanceof Error ? error.message : String(error); }
|
||||
} catch (error) { result.weightPatch = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const baseTile = new Uint8Array(16);
|
||||
const hash = async (bytes: Uint8Array) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
physicsCapabilityInventory,
|
||||
selectPhysicsCacheFrame,
|
||||
} from "../../../protocol/physics-simulation";
|
||||
import { applyBrowserTransformCachePreview, BrowserTransformCachePlaybackSession } from "../../../protocol/physics-cache-playback";
|
||||
|
||||
const hash = "a".repeat(64);
|
||||
const base = {
|
||||
@@ -30,7 +31,19 @@ const base = {
|
||||
},
|
||||
};
|
||||
|
||||
self.onmessage = () => {
|
||||
function browserFrame(frame: number, translation: [number, number, number]): ArrayBuffer {
|
||||
const bytes = new ArrayBuffer(16 + 72);
|
||||
const view = new DataView(bytes);
|
||||
view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true);
|
||||
const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
|
||||
[...translation, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const scene = { schemaVersion: 1 as const, revision: 3, sceneId: "scene:Physics", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:Cloth", frame: { current: 1, start: 1, end: 10 }, nodes: [{ id: "object:Cloth", name: "Cloth", type: "MESH" as const, parentId: null, dataId: "mesh:Cloth", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] };
|
||||
|
||||
self.onmessage = async () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] });
|
||||
@@ -63,16 +76,40 @@ self.onmessage = () => {
|
||||
result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status;
|
||||
result.familyCount = PHYSICS_FAMILIES.length;
|
||||
try {
|
||||
const bytes = new ArrayBuffer(16 + 72);
|
||||
const bytes = browserFrame(7, [1, 2, 3]);
|
||||
const view = new DataView(bytes);
|
||||
view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, 7, true); view.setUint32(12, 1, true);
|
||||
const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
|
||||
[1, 2, 3, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
|
||||
const decoded = decodeBrowserTransformCacheFrame(bytes);
|
||||
result.browserPlayback = [decoded.frame, decoded.objects[0].objectId, decoded.objects[0].translation];
|
||||
const preview = applyBrowserTransformCachePreview(scene, bytes.slice(0), 7);
|
||||
result.browserPreview = [preview.frame.current, preview.nodes[0].transform.translation, preview.nodes[0].worldMatrix.slice(12, 15)];
|
||||
try { applyBrowserTransformCachePreview(preview, bytes.slice(0), 8); } catch (error) { result.browserFrameMismatch = error instanceof Error ? error.message : String(error); }
|
||||
view.setFloat32(48 + 3 * 4, 2, true);
|
||||
try { decodeBrowserTransformCacheFrame(bytes); } catch (error) { result.browserRotation = error instanceof Error ? error.message : String(error); }
|
||||
}
|
||||
catch (error) { result.browserPlayback = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const frames = new Map([[7, browserFrame(7, [1, 0, 0])], [8, browserFrame(8, [2, 0, 0])]]);
|
||||
const published: number[] = [];
|
||||
const session = new BrowserTransformCachePlaybackSession(scene, { frameStart: 7, frameEnd: 8, readFrame: async (frame) => frames.get(frame)!.slice(0) }, (preview) => published.push(preview.frame.current));
|
||||
const playback = await session.play();
|
||||
result.browserSession = [playback.status, playback.appliedFrames, playback.lastFrame, published];
|
||||
|
||||
let releaseSlow: ((data: ArrayBuffer) => void) | undefined;
|
||||
const latePublished: number[] = [];
|
||||
const late = new BrowserTransformCachePlaybackSession(scene, { frameStart: 7, frameEnd: 8, readFrame: (frame) => frame === 7 ? new Promise<ArrayBuffer>((resolve) => { releaseSlow = resolve; }) : Promise.resolve(frames.get(frame)!.slice(0)) }, (preview) => latePublished.push(preview.frame.current));
|
||||
const superseded = late.seek(7);
|
||||
const current = late.seek(8);
|
||||
releaseSlow?.(frames.get(7)!.slice(0));
|
||||
result.browserSupersede = [await superseded === null, (await current)?.frame.current, latePublished];
|
||||
|
||||
let releaseCancelled: ((data: ArrayBuffer) => void) | undefined;
|
||||
const cancelledPublished: number[] = [];
|
||||
const cancelled = new BrowserTransformCachePlaybackSession(scene, { frameStart: 7, frameEnd: 7, readFrame: () => new Promise<ArrayBuffer>((resolve) => { releaseCancelled = resolve; }) }, (preview) => cancelledPublished.push(preview.frame.current));
|
||||
const cancelledRead = cancelled.seek(7);
|
||||
cancelled.cancel();
|
||||
releaseCancelled?.(frames.get(7)!.slice(0));
|
||||
result.browserCancel = [await cancelledRead === null, cancelledPublished];
|
||||
}
|
||||
catch (error) { result.browserSession = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
|
||||
|
||||
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies });
|
||||
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], excludedSlices: [], acceptance: [], dependencies });
|
||||
const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] };
|
||||
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] };
|
||||
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
@@ -13,5 +13,9 @@ self.onmessage = () => {
|
||||
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), excludedSlices: ["A"], blockedSlices: ["A", "B"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, fields: ["performance.geometry10M"] }] } }); } catch (error) { result.disabledEvidence = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, artifactSha256: [] }] } }); } catch (error) { result.emptyArtifact = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, generatedAt: "2026-02-31T00:00:00.000Z" }); } catch (error) { result.generatedAt = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
34
web/app/src/workers/scene-delta-render-test.worker.ts
Normal file
34
web/app/src/workers/scene-delta-render-test.worker.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { applySceneDelta, diffSceneSnapshots, parseSceneDelta, sceneDeltaRequiresRendererRebuild } from "../../../protocol/scene-delta";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
|
||||
self.onmessage = () => {
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const before: SceneSnapshotIR = {
|
||||
schemaVersion: 1,
|
||||
revision: 3,
|
||||
sceneId: "scene:Render",
|
||||
source: { kind: "mock" },
|
||||
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
|
||||
nodes: [{ id: "object:Camera", name: "Camera", type: "CAMERA", parentId: null, dataId: "camera:Main", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }],
|
||||
meshes: [], materials: [], cameras: [], lights: [], images: [], animations: [], collections: [],
|
||||
worlds: [{ id: "world:Render", name: "World", color: [0.1, 0.2, 0.3], exposure: 0 }],
|
||||
scenes: [{ id: "scene:Render", name: "Render", worldId: "world:Render", colorManagement: { displayDevice: "sRGB", viewTransform: "AgX", look: "None", exposure: 0, gamma: 1 } }],
|
||||
activeObjectId: "object:Camera",
|
||||
frame: { current: 1, start: 1, end: 10 },
|
||||
};
|
||||
const after: SceneSnapshotIR = {
|
||||
...before,
|
||||
revision: 4,
|
||||
worlds: [{ ...before.worlds[0], color: [0.8, 0.4, 0.2], exposure: 2 }],
|
||||
scenes: [{ ...before.scenes[0], colorManagement: { ...before.scenes[0].colorManagement!, viewTransform: "Standard", exposure: 1 } }],
|
||||
};
|
||||
const delta = parseSceneDelta(diffSceneSnapshots(before, after));
|
||||
const applied = applySceneDelta(before, delta);
|
||||
const result: Record<string, unknown> = {
|
||||
collections: [delta.worlds?.updated.length, delta.scenes?.updated.length],
|
||||
applied: [applied.worlds[0].color, applied.worlds[0].exposure, applied.scenes[0].colorManagement?.viewTransform, applied.scenes[0].colorManagement?.exposure],
|
||||
rebuild: sceneDeltaRequiresRendererRebuild(delta),
|
||||
};
|
||||
try { parseSceneDelta({ ...delta, worlds: { updated: "not-an-array" } }); } catch (error) { result.invalid = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
@@ -1,13 +1,23 @@
|
||||
import { gateScriptExecution, gateServerScriptJob, parseScriptingManifest, platformCapabilities, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform";
|
||||
import { appendScriptExecutionAudit, createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob, parseScriptExecutionAuditLog, parseScriptingManifest, platformCapabilities, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform";
|
||||
|
||||
const sha = "a".repeat(64); const signature = "b".repeat(128);
|
||||
const script = { id: "script:clean", name: "Clean", entryPath: "scripts/clean.py", sourceSha256: sha, publisher: "Team", signature, keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false };
|
||||
const base = { schemaVersion: 1, scripts: [script] };
|
||||
|
||||
self.onmessage = () => {
|
||||
self.onmessage = async () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { result.valid = parseScriptingManifest(base).scripts[0].entryPath; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
|
||||
result.exec = gateScriptExecution(base, "script:clean", new Set(["key:trusted"])).issues[0]?.code;
|
||||
const audit = await createScriptExecutionAudit(base, "script:clean", new Set(["key:trusted"]), { requestId: "e2e-script-audit", requestedAt: "2026-08-12T12:00:00.000Z" });
|
||||
result.audit = [audit.decision, audit.reason, audit.approvedKey, audit.permissions, audit.budget.cpuMs, audit.manifestSha256, audit.requestSha256];
|
||||
const secondAudit = await createScriptExecutionAudit(base, "script:clean", new Set(), { requestId: "e2e-script-audit-2", requestedAt: "2026-08-12T12:00:01.000Z" });
|
||||
const firstLog = await appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, audit);
|
||||
const auditLog = await appendScriptExecutionAudit(firstLog, secondAudit);
|
||||
const checkedLog = await parseScriptExecutionAuditLog(auditLog);
|
||||
result.auditLog = [checkedLog.entries.length, checkedLog.entries[0].previousEntrySha256, checkedLog.entries[1].previousEntrySha256, checkedLog.entries[1].entrySha256];
|
||||
try { await appendScriptExecutionAudit(auditLog, secondAudit); } catch (error) { result.auditReplay = error instanceof Error ? error.message : String(error); }
|
||||
try { await parseScriptExecutionAuditLog({ ...auditLog, entries: auditLog.entries.map((entry, index) => index === 0 ? { ...entry, audit: { ...entry.audit, scriptId: "script:tampered" } } : entry) }); } catch (error) { result.auditTamper = error instanceof Error ? error.message : String(error); }
|
||||
try { await createScriptExecutionAudit(base, "script:clean", new Set(["key:trusted"]), { requestId: "bad-date", requestedAt: "2026-02-31T12:00:00.000Z" }); } catch (error) { result.auditDate = error instanceof Error ? error.message : String(error); }
|
||||
result.server = gateServerScriptJob({ scriptId: "script:clean", sourceSha256: sha, inputBlendSha256: "c".repeat(64), status: "QUEUED" }, base, "c".repeat(64)).issues[0]?.code;
|
||||
try { parseScriptingManifest({ ...base, scripts: [{ ...script, entryPath: "../escape.py" }] }); } catch (error) { result.path = error instanceof Error ? error.message : String(error); }
|
||||
try { parseScriptingManifest({ ...base, scripts: [{ ...script, autorun: true }] }); } catch (error) { result.policy = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
applySequencerEdit,
|
||||
gateSequencerCodec,
|
||||
parseSequencerTimeline,
|
||||
resolveSequencerFrame,
|
||||
resolveSequencerTransitionFrame,
|
||||
sequencerRuntimeCapabilities,
|
||||
sequencerSourceFrame,
|
||||
} from "../../../protocol/sequencer";
|
||||
@@ -23,6 +25,7 @@ self.onmessage = () => {
|
||||
const moved = applySequencerEdit(timeline, { type: "MOVE", revision: 3, stripId: movie.id, frameDelta: 5, channel: 3 });
|
||||
const split = applySequencerEdit(moved, { type: "SPLIT", revision: 4, stripId: movie.id, frame: 20, rightStripId: "strip:MovieRight" });
|
||||
result.edit = [split.revision, split.strips.map((strip) => [strip.id, strip.frameStart, strip.frameEnd, strip.sourceStart, strip.sourceEnd])];
|
||||
result.frame = resolveSequencerFrame(split, 20).map((strip) => [strip.stripId, strip.channel, strip.sourceFrame]);
|
||||
try { applySequencerEdit(timeline, { type: "MOVE", revision: 2, stripId: movie.id, frameDelta: 1 }); }
|
||||
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
}
|
||||
@@ -40,6 +43,15 @@ self.onmessage = () => {
|
||||
}
|
||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code;
|
||||
const transitionTimeline = { ...base, strips: [
|
||||
{ id: "strip:From", name: "From", type: "SCENE", channel: 1, frameStart: 10, frameEnd: 20, sourceStart: 100, sourceEnd: 110, speed: 1, muted: false, locked: false },
|
||||
{ id: "strip:To", name: "To", type: "SCENE", channel: 2, frameStart: 10, frameEnd: 20, sourceStart: 200, sourceEnd: 210, speed: 1, muted: false, locked: false },
|
||||
{ id: "strip:Cross", name: "Cross", type: "EFFECT", effectType: "CROSS", inputStripIds: ["strip:From", "strip:To"], channel: 3, frameStart: 10, frameEnd: 20, sourceStart: 0, sourceEnd: 10, speed: 1, muted: false, locked: false },
|
||||
] };
|
||||
const transition = resolveSequencerTransitionFrame(transitionTimeline, "strip:Cross", 15);
|
||||
result.transition = [transition.effectType, transition.factor, transition.from.stripId, transition.from.sourceFrame, transition.to.stripId, transition.to.sourceFrame];
|
||||
try { resolveSequencerTransitionFrame(transitionTimeline, "strip:Cross", 20); }
|
||||
catch (error) { result.transitionBoundary = error instanceof Error ? error.message : String(error); }
|
||||
result.runtime = sequencerRuntimeCapabilities();
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
applyTrackingMaskEdit,
|
||||
gateTrackingOperation,
|
||||
parseTrackingMaskProject,
|
||||
raycastMaskProject,
|
||||
selectMaskPointsInBounds,
|
||||
type MaskPointIR,
|
||||
type TrackingMarkerIR,
|
||||
} from "../../../protocol/tracking-mask";
|
||||
@@ -18,6 +20,10 @@ self.onmessage = () => {
|
||||
const markerEdit = applyTrackingMaskEdit(parsed, { type: "SET_MARKER", revision: 4, clipId: "clip:1", trackId: "track:1", marker: { ...marker, frame: 10, position: [0.6, 0.4], keyframe: false } });
|
||||
const pointEdit = applyTrackingMaskEdit(markerEdit, { type: "SET_MASK_POINT", revision: 5, maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", point: { ...point, co: [0.4, 0.6], feather: 0.25 } });
|
||||
result.edit = [pointEdit.revision, pointEdit.clips[0].tracks[0].markers.map((item) => item.frame), pointEdit.masks[0].layers[0].splines[0].points[0].co];
|
||||
result.raycast = raycastMaskProject(pointEdit, [0.4, 0.6], 0.03);
|
||||
const selection = selectMaskPointsInBounds(pointEdit, [0.1, 0.2], [0.5, 0.7]);
|
||||
const toggled = selectMaskPointsInBounds(pointEdit, [0.1, 0.2], [0.5, 0.7], selection, "TOGGLE");
|
||||
result.marquee = [selection.map((item) => item.pointId), toggled.length];
|
||||
try { applyTrackingMaskEdit(parsed, { type: "SET_TRACK_SELECTION", revision: 3, clipId: "clip:1", trackId: "track:1", selected: true }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
} catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
|
||||
try { parseTrackingMaskProject({ ...base, clips: [{ ...base.clips[0], sourcePath: "../shot.mp4" }] }); } catch (error) { result.path = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
173
web/app/src/workers/vdb-fault-test.worker.ts
Normal file
173
web/app/src/workers/vdb-fault-test.worker.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { verifyNanoVDBChunk, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
NanoVDBGpuPageAllocator,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
uploadNanoVDBFloat32GridPaged,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR;
|
||||
const report = await (await fetch("/__vdb_fixture__/report", { cache: "no-store" })).json() as { grids: Array<{ name: string; scalarSamples?: Array<{ coord: [number, number, number]; value: number; active: boolean }> }> };
|
||||
let attempts = 0;
|
||||
let retryResponses = 0;
|
||||
const ifRanges: string[] = [];
|
||||
const interruptedFetcher: typeof fetch = async (input, init) => {
|
||||
attempts++;
|
||||
const headers = new Headers(init?.headers);
|
||||
ifRanges.push(headers.get("If-Range") ?? "");
|
||||
if (retryResponses++ === 0) return new Response("temporary interruption", { status: 503 });
|
||||
return fetch(input, init);
|
||||
};
|
||||
const rangeSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: interruptedFetcher,
|
||||
retries: 2,
|
||||
retryDelayMs: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const first = await rangeSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal);
|
||||
await verifyNanoVDBChunk(manifest.chunks[0], first);
|
||||
const second = await rangeSource({ chunkIndex: 1, start: manifest.chunks[1].byteOffset, endExclusive: manifest.chunks[1].byteOffset + manifest.chunks[1].byteLength, sha256: manifest.chunks[1].sha256 }, new AbortController().signal);
|
||||
await verifyNanoVDBChunk(manifest.chunks[1], second);
|
||||
|
||||
const resumeRanges: string[] = [];
|
||||
const resumeIfRanges: string[] = [];
|
||||
let interruptBody = true;
|
||||
const interruptedBodyFetcher: typeof fetch = async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
resumeRanges.push(headers.get("Range") ?? "");
|
||||
resumeIfRanges.push(headers.get("If-Range") ?? "");
|
||||
const response = await fetch(input, init);
|
||||
if (!interruptBody) return response;
|
||||
interruptBody = false;
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const partial = bytes.slice(0, Math.min(4096, bytes.byteLength - 1));
|
||||
let emitted = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (!emitted) {
|
||||
emitted = true;
|
||||
controller.enqueue(partial);
|
||||
return;
|
||||
}
|
||||
controller.error(new TypeError("injected response-body interruption"));
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: response.status, headers: response.headers });
|
||||
};
|
||||
const resumable = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: interruptedBodyFetcher,
|
||||
retries: 2,
|
||||
retryDelayMs: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const resumed = await resumable({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal);
|
||||
await verifyNanoVDBChunk(manifest.chunks[0], resumed);
|
||||
|
||||
let shortResponse = "";
|
||||
try {
|
||||
const shortFetcher: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
return new Response(bytes.slice(0, Math.max(1, Math.floor(bytes.byteLength / 2))), { status: response.status, headers: response.headers });
|
||||
};
|
||||
const shortSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { fetcher: shortFetcher, retries: 2, retryDelayMs: 0, requireStableEtag: true });
|
||||
await shortSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal);
|
||||
}
|
||||
catch (error) { shortResponse = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
let changedEtag = "";
|
||||
try {
|
||||
let etagResponses = 0;
|
||||
const changedEtagFetcher: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
if (etagResponses++ === 0) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("ETag", '"changed-vdb-etag"');
|
||||
return new Response(await response.arrayBuffer(), { status: response.status, headers });
|
||||
};
|
||||
const changedEtagSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { fetcher: changedEtagFetcher, retries: 0, requireStableEtag: true });
|
||||
await changedEtagSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal);
|
||||
await changedEtagSource({ chunkIndex: 1, start: manifest.chunks[1].byteOffset, endExclusive: manifest.chunks[1].byteOffset + manifest.chunks[1].byteLength, sha256: manifest.chunks[1].sha256 }, new AbortController().signal);
|
||||
}
|
||||
catch (error) { changedEtag = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
let outOfOrderResponse = "";
|
||||
try {
|
||||
const outOfOrderFetcher: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const headers = new Headers(response.headers);
|
||||
const requested = new Headers(init?.headers).get("Range")?.match(/^bytes=(\d+)-(\d+)$/);
|
||||
if (requested) headers.set("Content-Range", `bytes ${Number(requested[1]) + 32}-${requested[2]}/${manifest.bundleByteLength}`);
|
||||
return new Response(bytes, { status: response.status, headers });
|
||||
};
|
||||
const outOfOrderSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { fetcher: outOfOrderFetcher, retries: 0, requireStableEtag: true });
|
||||
await outOfOrderSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal);
|
||||
}
|
||||
catch (error) { outOfOrderResponse = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
const asset = await loadNanoVDBViewportAsset("volume:FaultGate", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid)!;
|
||||
const native = report.grids.find((grid) => grid.name === density.name)?.scalarSamples ?? [];
|
||||
const payload = asset.grids.find((grid) => grid.name === density.name)!.data;
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(payload.byteLength + 256 * 1024);
|
||||
|
||||
const allocator = new NanoVDBGpuPageAllocator(device, 64 * 1024, 128 * 1024);
|
||||
const page = new Uint8Array(64 * 1024).buffer;
|
||||
allocator.upload("page-a", page);
|
||||
allocator.upload("page-b", page);
|
||||
allocator.touch("page-a");
|
||||
allocator.upload("page-c", page);
|
||||
const lru = allocator.stats();
|
||||
allocator.dispose();
|
||||
|
||||
let oom = "";
|
||||
try {
|
||||
const constrained = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 256 * 1024);
|
||||
if (constrained.residentPageCount < constrained.pageCount) oom = "NANOVDB_GPU_BUDGET_EXCEEDED: resident paging active";
|
||||
constrained.dispose();
|
||||
}
|
||||
catch (error) { oom = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
const residentBudget = Math.ceil(payload.byteLength / (256 * 1024)) * 256 * 1024;
|
||||
const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, residentBudget);
|
||||
const before = await sampleNanoVDBFloat32WebGPU(device, uploaded, native.map((sample) => sample.coord));
|
||||
const paging = { pageCount: uploaded.pageCount, residentPageCount: uploaded.residentPageCount, byteLength: uploaded.byteLength };
|
||||
uploaded.dispose();
|
||||
const firstGeneration = session.generation;
|
||||
device.destroy();
|
||||
const loss = await session.waitForLoss();
|
||||
const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024);
|
||||
const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget);
|
||||
const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord));
|
||||
recovered.dispose();
|
||||
const recoveredGeneration = session.generation;
|
||||
session.dispose();
|
||||
return {
|
||||
network: {
|
||||
attempts,
|
||||
ifRanges,
|
||||
firstBytes: first.byteLength,
|
||||
secondBytes: second.byteLength,
|
||||
resumeRanges,
|
||||
resumeIfRanges,
|
||||
resumedBytes: resumed.byteLength,
|
||||
shortResponse,
|
||||
changedEtag,
|
||||
outOfOrderResponse,
|
||||
},
|
||||
lru,
|
||||
oom,
|
||||
paging,
|
||||
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration },
|
||||
samplesStable: JSON.stringify(before) === JSON.stringify(after),
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
213
web/app/src/workers/vdb-opfs-test.worker.ts
Normal file
213
web/app/src/workers/vdb-opfs-test.worker.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import { IncrementalSha256 } from "../volume/incremental-sha256";
|
||||
import {
|
||||
commitNanoVDBToOPFS,
|
||||
createVDBProjectBinding,
|
||||
openNanoVDBFromOPFS,
|
||||
pruneNanoVDBOPFS,
|
||||
recoverNanoVDBOPFS,
|
||||
} from "../volume/nanovdb-opfs";
|
||||
|
||||
const scope = self as unknown as { onmessage: ((event: MessageEvent<{ action: "commit" | "reopen" | "interrupt" | "recoverInterrupted" | "prepareQuota" | "quota" | "verifyQuota"; state?: TestState }>) => void) | null; postMessage: (value: unknown) => void };
|
||||
const projectId = "vdb-fixtures";
|
||||
const sourceBlendSha256 = "b".repeat(64);
|
||||
const cancelConverter = { target: "SERVER" as const, blenderVersion: "5.2.0", openVDBVersion: "13.0.0", nanoVDBVersion: "32.9.0", executableSha256: "c".repeat(64) };
|
||||
|
||||
interface TestState { bundleSha256: string; conversionRequestSha256: string; sourceSha256: string }
|
||||
|
||||
function bytes(seed: number): Uint8Array {
|
||||
return Uint8Array.from({ length: 128 * 1024 }, (_value, index) => (index * 17 + seed) & 0xff);
|
||||
}
|
||||
|
||||
function buffer(value: Uint8Array): ArrayBuffer {
|
||||
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function hash(value: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function bundleDirectory(bundleSha256: string): Promise<FileSystemDirectoryHandle> {
|
||||
let directory = await navigator.storage.getDirectory();
|
||||
for (const name of ["projects", projectId, "cache", "vdb", bundleSha256]) directory = await directory.getDirectoryHandle(name);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function overwriteFile(directory: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise<void> {
|
||||
const writer = await (await directory.getFileHandle(name)).createWritable();
|
||||
await writer.write(value);
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
async function manifestFor(data: Uint8Array, requestSeed: string): Promise<NanoVDBBundleManifestIR> {
|
||||
const first = data.subarray(0, 64 * 1024);
|
||||
const second = data.subarray(64 * 1024);
|
||||
return validateNanoVDBBundleManifest({
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
sourcePath: "//volumes/smoke.vdb",
|
||||
sourceSha256: "a".repeat(64),
|
||||
conversionRequestSha256: requestSeed.repeat(64),
|
||||
bundlePath: "//volumes/smoke.nvdb",
|
||||
bundleByteLength: data.byteLength,
|
||||
bundleSha256: await hash(data),
|
||||
converter: cancelConverter,
|
||||
grids: [{
|
||||
name: "density", valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 8,
|
||||
segmentByteOffset: 0, segmentByteLength: data.byteLength, byteOffset: 0, byteLength: data.byteLength,
|
||||
indexBounds: { min: [0, 0, 0], max: [1, 1, 1] }, worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, voxelSize: [0.5, 0.5, 0.5],
|
||||
indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
|
||||
}],
|
||||
chunks: [
|
||||
{ index: 0, byteOffset: 0, byteLength: first.byteLength, sha256: await hash(first) },
|
||||
{ index: 1, byteOffset: first.byteLength, byteLength: second.byteLength, sha256: await hash(second) },
|
||||
],
|
||||
material: { densityGrid: "density", densityScale: 1, emissionScale: 0, temperatureScale: 1, anisotropy: 0, interpolation: "LINEAR" },
|
||||
gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: 64 * 1024, maxResidentBytes: 64 * 1024 * 1024, shaderSemanticVersion: "volume-wgsl-v1" },
|
||||
});
|
||||
}
|
||||
|
||||
async function commit(): Promise<unknown> {
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const rangeSource = async (range: { start: number; endExclusive: number }): Promise<ArrayBuffer> => {
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${range.start}-${range.endExclusive - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: fixture range returned ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
const binding = await createVDBProjectBinding(manifest, sourceBlendSha256);
|
||||
const committed = await commitNanoVDBToOPFS(manifest, rangeSource, new AbortController().signal, binding);
|
||||
const deduplicated = await commitNanoVDBToOPFS(manifest, rangeSource, new AbortController().signal, binding);
|
||||
|
||||
const cancelledData = bytes(71);
|
||||
const cancelledManifest = await manifestFor(cancelledData, "e");
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
try {
|
||||
await commitNanoVDBToOPFS(cancelledManifest, async (range) => {
|
||||
if (range.chunkIndex === 1) controller.abort();
|
||||
return buffer(cancelledData.slice(range.start, range.endExclusive));
|
||||
}, controller.signal);
|
||||
}
|
||||
catch (error) { cancelled = error instanceof DOMException && error.name === "AbortError"; }
|
||||
const recovered = await recoverNanoVDBOPFS(projectId);
|
||||
return {
|
||||
committed,
|
||||
deduplicated: deduplicated.deduplicated,
|
||||
cancelled,
|
||||
recovered,
|
||||
realBundleBytes: manifest.bundleByteLength,
|
||||
realChunkCount: manifest.chunks.length,
|
||||
state: { bundleSha256: manifest.bundleSha256, conversionRequestSha256: manifest.conversionRequestSha256, sourceSha256: manifest.sourceSha256 },
|
||||
};
|
||||
}
|
||||
|
||||
async function reopen(state: TestState): Promise<unknown> {
|
||||
const fixture = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const reopenContext = {
|
||||
projectId, sourceBlendSha256, sourcePath: fixture.sourcePath, sourceSha256: state.sourceSha256, converter: fixture.converter, shaderSemanticVersion: "volume-wgsl-v1",
|
||||
} as const;
|
||||
const opened = await openNanoVDBFromOPFS(projectId, state.bundleSha256, state.conversionRequestSha256, reopenContext);
|
||||
const hasher = new IncrementalSha256();
|
||||
for (let index = 0; index < opened.manifest.chunks.length; index++) {
|
||||
const chunk = opened.manifest.chunks[index];
|
||||
const data = await opened.source({ chunkIndex: index, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 }, new AbortController().signal);
|
||||
hasher.update(data);
|
||||
}
|
||||
const stale = await openNanoVDBFromOPFS(projectId, state.bundleSha256, state.conversionRequestSha256, {
|
||||
projectId, sourceBlendSha256, sourcePath: fixture.sourcePath, sourceSha256: "f".repeat(64), converter: fixture.converter, shaderSemanticVersion: "volume-wgsl-v1",
|
||||
});
|
||||
const bundle = await bundleDirectory(state.bundleSha256);
|
||||
const firstChunkHandle = await bundle.getFileHandle("00000.chunk");
|
||||
const firstChunk = new Uint8Array(await (await firstChunkHandle.getFile()).arrayBuffer());
|
||||
const tampered = firstChunk.slice();
|
||||
tampered[0] ^= 0xff;
|
||||
await overwriteFile(bundle, "00000.chunk", tampered.buffer);
|
||||
let tamperedChunk = "";
|
||||
try {
|
||||
const chunk = opened.manifest.chunks[0];
|
||||
await opened.source({ chunkIndex: 0, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 }, new AbortController().signal);
|
||||
}
|
||||
catch (error) { tamperedChunk = error instanceof Error ? error.message : String(error); }
|
||||
await overwriteFile(bundle, "00000.chunk", firstChunk.buffer);
|
||||
|
||||
const manifestHandle = await bundle.getFileHandle("manifest.json");
|
||||
const manifestText = await (await manifestHandle.getFile()).text();
|
||||
const rolledBackManifest = { ...opened.manifest, material: { ...opened.manifest.material, densityScale: opened.manifest.material.densityScale + 0.25 } };
|
||||
await overwriteFile(bundle, "manifest.json", JSON.stringify(rolledBackManifest));
|
||||
let manifestRollback = "";
|
||||
try { await openNanoVDBFromOPFS(projectId, state.bundleSha256, state.conversionRequestSha256, reopenContext); }
|
||||
catch (error) { manifestRollback = error instanceof Error ? error.message : String(error); }
|
||||
await overwriteFile(bundle, "manifest.json", manifestText);
|
||||
const pruned = await pruneNanoVDBOPFS(projectId, 0);
|
||||
return { bindingStatus: opened.bindingStatus, staleStatus: stale.bindingStatus, bundleHash: hasher.hex(), expectedHash: state.bundleSha256, tamperedChunk, manifestRollback, pruned };
|
||||
}
|
||||
|
||||
async function interrupt(): Promise<never> {
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
await commitNanoVDBToOPFS(manifest, async (range) => {
|
||||
if (range.chunkIndex === 1) {
|
||||
scope.postMessage({ staged: true, bundleSha256: manifest.bundleSha256 });
|
||||
await new Promise<never>(() => undefined);
|
||||
}
|
||||
const response = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${range.start}-${range.endExclusive - 1}` } });
|
||||
if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: fixture range returned ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
}, new AbortController().signal);
|
||||
throw new Error("interrupted commit unexpectedly completed");
|
||||
}
|
||||
|
||||
async function recoverInterrupted(): Promise<unknown> {
|
||||
const recovered = await recoverNanoVDBOPFS(projectId);
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
async function prepareQuota(): Promise<unknown> {
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const data = bytes(81);
|
||||
const manifest = await manifestFor(data, "1");
|
||||
await commitNanoVDBToOPFS(manifest, async (range) => buffer(data.slice(range.start, range.endExclusive)), new AbortController().signal);
|
||||
return { state: { bundleSha256: manifest.bundleSha256, conversionRequestSha256: manifest.conversionRequestSha256, sourceSha256: manifest.sourceSha256 } };
|
||||
}
|
||||
|
||||
async function quota(state: TestState): Promise<unknown> {
|
||||
let error = "";
|
||||
try {
|
||||
const data = bytes(82);
|
||||
const manifest = await manifestFor(data, "2");
|
||||
await commitNanoVDBToOPFS(manifest, async (range) => buffer(data.slice(range.start, range.endExclusive)), new AbortController().signal);
|
||||
}
|
||||
catch (caught) { error = caught instanceof Error ? `${caught.name}: ${caught.message}` : String(caught); }
|
||||
let recoveryWhileQuotaLimited = "";
|
||||
let recoveredWhileQuotaLimited: unknown;
|
||||
try { recoveredWhileQuotaLimited = await recoverNanoVDBOPFS(projectId); }
|
||||
catch (caught) { recoveryWhileQuotaLimited = caught instanceof Error ? `${caught.name}: ${caught.message}` : String(caught); }
|
||||
return { quotaError: error, recoveryWhileQuotaLimited, recoveredWhileQuotaLimited, previousBundleSha256: state.bundleSha256 };
|
||||
}
|
||||
|
||||
async function verifyQuota(state: TestState): Promise<unknown> {
|
||||
const recovered = await recoverNanoVDBOPFS(projectId);
|
||||
const opened = await openNanoVDBFromOPFS(projectId, state.bundleSha256);
|
||||
const first = opened.manifest.chunks[0];
|
||||
await opened.source({ chunkIndex: 0, start: first.byteOffset, endExclusive: first.byteOffset + first.byteLength, sha256: first.sha256 }, new AbortController().signal);
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
return { recovered, previousBundleReadable: true };
|
||||
}
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
const action = event.data.action;
|
||||
void (action === "commit" ? commit() :
|
||||
action === "reopen" ? reopen(event.data.state!) :
|
||||
action === "interrupt" ? interrupt() :
|
||||
action === "recoverInterrupted" ? recoverInterrupted() :
|
||||
action === "prepareQuota" ? prepareQuota() :
|
||||
action === "quota" ? quota(event.data.state!) : verifyQuota(event.data.state!))
|
||||
.then((result) => scope.postMessage(result))
|
||||
.catch((error) => scope.postMessage({ error: error instanceof Error ? error.stack ?? error.message : String(error) }));
|
||||
};
|
||||
@@ -1,27 +1,206 @@
|
||||
import { decodeVDBResource, type VDBResourceManifest } from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
VDB_PIPELINE_SCHEMA,
|
||||
gateNanoVDBPipeline,
|
||||
hashVDBConversionRequest,
|
||||
planNanoVDBRanges,
|
||||
prepareVDBConversionInput,
|
||||
validateNanoVDBBundleManifest,
|
||||
validateVDBConversionRequest,
|
||||
verifyNanoVDBBundle,
|
||||
verifyNanoVDBChunk,
|
||||
type NanoVDBBundleManifestIR,
|
||||
type NanoVDBGridIR,
|
||||
type VDBConverterIdentityIR,
|
||||
type VDBResourceManifest,
|
||||
} from "../../../protocol/volume-vdb";
|
||||
import { createHttpNanoVDBRangeSource, streamNanoVDBChunks } from "../volume/nanovdb-stream";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
const identityTransform: NanoVDBGridIR["indexToWorld"] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
function bytes(length: number, seed: number): Uint8Array {
|
||||
return Uint8Array.from({ length }, (_value, index) => (index * 37 + seed) & 0xff);
|
||||
}
|
||||
|
||||
function arrayBuffer(value: Uint8Array): ArrayBuffer {
|
||||
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function digest(value: Uint8Array): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", arrayBuffer(value));
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function grid(name: string, semantic: NanoVDBGridIR["semantic"], byteOffset: number): NanoVDBGridIR {
|
||||
return {
|
||||
name,
|
||||
valueType: "FLOAT32",
|
||||
gridClass: "FOG_VOLUME",
|
||||
semantic,
|
||||
activeVoxelCount: 8,
|
||||
segmentByteOffset: byteOffset,
|
||||
segmentByteLength: 32,
|
||||
byteOffset,
|
||||
byteLength: 32,
|
||||
indexBounds: { min: [0, 0, 0], max: [1, 1, 1] },
|
||||
worldBounds: { min: [0, 0, 0], max: [1, 1, 1] },
|
||||
voxelSize: [0.5, 0.5, 0.5],
|
||||
indexToWorld: [...identityTransform],
|
||||
};
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const data = Uint8Array.from([0x76, 0x64, 0x62, 0x01]);
|
||||
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
const manifest: VDBResourceManifest = { projectId: "vdb-test", sourcePath: "//cache/smoke.vdb", byteLength: data.byteLength, sha256: digest, grids: [{ name: "density", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 }] };
|
||||
const decoded = await decodeVDBResource({ ...manifest, data: data.buffer }, async (request, signal) => {
|
||||
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
|
||||
return { metadata: request, decodedByteLength: request.data.byteLength };
|
||||
}, new AbortController().signal);
|
||||
// These deterministic bytes exercise the transport contract only. They are not
|
||||
// presented as an OpenVDB or NanoVDB decoder fixture.
|
||||
const sourceBytes = bytes(64, 11);
|
||||
const source: VDBResourceManifest = {
|
||||
projectId: "vdb-protocol-test",
|
||||
sourcePath: "//volumes/smoke.vdb",
|
||||
byteLength: sourceBytes.byteLength,
|
||||
sha256: await digest(sourceBytes),
|
||||
grids: [
|
||||
{ name: "density", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 },
|
||||
{ name: "temperature", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 },
|
||||
],
|
||||
};
|
||||
const prepared = await prepareVDBConversionInput({ ...source, data: arrayBuffer(sourceBytes) }, new AbortController().signal);
|
||||
const converter: VDBConverterIdentityIR = {
|
||||
target: "SERVER",
|
||||
blenderVersion: "5.2.0",
|
||||
openVDBVersion: "contract-fixture",
|
||||
nanoVDBVersion: "contract-fixture",
|
||||
executableSha256: "1".repeat(64),
|
||||
};
|
||||
const conversion = validateVDBConversionRequest({
|
||||
schemaVersion: VDB_PIPELINE_SCHEMA,
|
||||
jobId: "vdb-protocol-job",
|
||||
source,
|
||||
outputPath: "//volumes/smoke.nvdb",
|
||||
selectedGrids: ["density", "temperature"],
|
||||
quantization: "LOSSLESS",
|
||||
chunkByteLength: 64 * 1024,
|
||||
converter,
|
||||
});
|
||||
const conversionRequestSha256 = await hashVDBConversionRequest(conversion);
|
||||
const relocatedConversionSha256 = await hashVDBConversionRequest({
|
||||
...conversion,
|
||||
jobId: "vdb-relocated-job",
|
||||
outputPath: "//cache/relocated.nvdb",
|
||||
});
|
||||
|
||||
const bundleBytes = bytes(64, 23);
|
||||
const first = bundleBytes.slice(0, 32);
|
||||
const second = bundleBytes.slice(32);
|
||||
const manifest: NanoVDBBundleManifestIR = {
|
||||
schemaVersion: VDB_PIPELINE_SCHEMA,
|
||||
projectId: source.projectId,
|
||||
sourcePath: source.sourcePath,
|
||||
sourceSha256: source.sha256,
|
||||
conversionRequestSha256,
|
||||
bundlePath: "//volumes/smoke.nvdb",
|
||||
bundleByteLength: bundleBytes.byteLength,
|
||||
bundleSha256: await digest(bundleBytes),
|
||||
converter,
|
||||
grids: [grid("density", "DENSITY", 0), grid("temperature", "TEMPERATURE", 32)],
|
||||
chunks: [
|
||||
{ index: 0, byteOffset: 0, byteLength: 32, sha256: await digest(first) },
|
||||
{ index: 1, byteOffset: 32, byteLength: 32, sha256: await digest(second) },
|
||||
],
|
||||
material: {
|
||||
densityGrid: "density",
|
||||
temperatureGrid: "temperature",
|
||||
densityScale: 1,
|
||||
emissionScale: 0,
|
||||
temperatureScale: 1,
|
||||
anisotropy: 0,
|
||||
interpolation: "LINEAR",
|
||||
},
|
||||
gpu: {
|
||||
representation: "NANOVDB_STORAGE_BUFFER",
|
||||
byteAlignment: 32,
|
||||
pageByteLength: 64 * 1024,
|
||||
maxResidentBytes: 64 * 1024,
|
||||
shaderSemanticVersion: "volume-wgsl-v1",
|
||||
},
|
||||
};
|
||||
const validated = validateNanoVDBBundleManifest(manifest);
|
||||
const ranges = planNanoVDBRanges(validated);
|
||||
await verifyNanoVDBChunk(validated.chunks[0], arrayBuffer(first));
|
||||
await verifyNanoVDBChunk(validated.chunks[1], arrayBuffer(second));
|
||||
await verifyNanoVDBBundle(validated, arrayBuffer(bundleBytes));
|
||||
const consumed: number[] = [];
|
||||
const progress: number[] = [];
|
||||
const stream = await streamNanoVDBChunks(
|
||||
validated,
|
||||
async (range) => arrayBuffer(bundleBytes.slice(range.start, range.endExclusive)),
|
||||
(range) => { consumed.push(range.chunkIndex); },
|
||||
new AbortController().signal,
|
||||
(state) => { progress.push(state.completedBytes); },
|
||||
);
|
||||
const exactFetch: typeof fetch = async (_input, init) => {
|
||||
const rangeHeader = new Headers(init?.headers).get("Range");
|
||||
const match = rangeHeader?.match(/^bytes=(\d+)-(\d+)$/);
|
||||
if (!match) return new Response(null, { status: 416 });
|
||||
const start = Number(match[1]);
|
||||
const endInclusive = Number(match[2]);
|
||||
return new Response(arrayBuffer(bundleBytes.slice(start, endInclusive + 1)), {
|
||||
status: 206,
|
||||
headers: { "Content-Range": `bytes ${start}-${endInclusive}/${bundleBytes.byteLength}` },
|
||||
});
|
||||
};
|
||||
const httpRange = await createHttpNanoVDBRangeSource("/assets/smoke.nvdb", bundleBytes.byteLength, exactFetch)(ranges[0], new AbortController().signal);
|
||||
let invalidHttpRange = "";
|
||||
try {
|
||||
const fullResponse: typeof fetch = async () => new Response(arrayBuffer(bundleBytes), { status: 200 });
|
||||
await createHttpNanoVDBRangeSource("/assets/smoke.nvdb", bundleBytes.byteLength, fullResponse)(ranges[0], new AbortController().signal);
|
||||
}
|
||||
catch (error) { invalidHttpRange = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
let outsideProject = "";
|
||||
try {
|
||||
await decodeVDBResource({ ...manifest, sourcePath: "../../outside.vdb", data: data.buffer }, undefined, new AbortController().signal);
|
||||
await prepareVDBConversionInput({ ...source, sourcePath: "../../outside.vdb", data: arrayBuffer(sourceBytes) }, new AbortController().signal);
|
||||
}
|
||||
catch (error) { outsideProject = error instanceof Error ? error.message : String(error); }
|
||||
let tamperedChunk = "";
|
||||
try {
|
||||
const tampered = first.slice();
|
||||
tampered[0] ^= 0xff;
|
||||
await verifyNanoVDBChunk(validated.chunks[0], arrayBuffer(tampered));
|
||||
}
|
||||
catch (error) { tamperedChunk = error instanceof Error ? error.message : String(error); }
|
||||
let incompleteStream = "";
|
||||
try {
|
||||
validateNanoVDBBundleManifest({ ...manifest, chunks: manifest.chunks.map((chunk, index) => index === 1 ? { ...chunk, byteOffset: 64 } : chunk) });
|
||||
}
|
||||
catch (error) { incompleteStream = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
const controller = new AbortController();
|
||||
const cancelled = decodeVDBResource({ ...manifest, data: data.buffer }, async (_request, signal) => new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve({ metadata: manifest, decodedByteLength: data.byteLength }), 20);
|
||||
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("cancelled", "AbortError")); }, { once: true });
|
||||
}), controller.signal).then(() => false).catch((error) => error instanceof DOMException && error.name === "AbortError");
|
||||
setTimeout(() => controller.abort(), 1);
|
||||
scope.postMessage({ decodedByteLength: decoded.decodedByteLength, outsideProject, cancelled: await cancelled });
|
||||
controller.abort();
|
||||
const cancelled = prepareVDBConversionInput({ ...source, data: arrayBuffer(sourceBytes) }, controller.signal)
|
||||
.then(() => false)
|
||||
.catch((error) => error instanceof DOMException && error.name === "AbortError");
|
||||
const rawBrowserGate = gateNanoVDBPipeline("RAW_VDB_BROWSER_DECODE");
|
||||
const streamGate = gateNanoVDBPipeline("NANOVDB_STREAM", { manifestValidated: true, rangeReaderAvailable: true });
|
||||
const renderGate = gateNanoVDBPipeline("WEBGPU_VOLUME_RENDER", { manifestValidated: true, rangeReaderAvailable: true, webgpuAvailable: true });
|
||||
scope.postMessage({
|
||||
preparedByteLength: prepared.data.byteLength,
|
||||
conversionTarget: conversion.converter.target,
|
||||
conversionRequestSha256: manifest.conversionRequestSha256,
|
||||
relocationKeepsContentKey: conversionRequestSha256 === relocatedConversionSha256,
|
||||
ranges,
|
||||
consumed,
|
||||
progress,
|
||||
stream,
|
||||
httpRangeByteLength: httpRange.byteLength,
|
||||
invalidHttpRange,
|
||||
outsideProject,
|
||||
tamperedChunk,
|
||||
incompleteStream,
|
||||
cancelled: await cancelled,
|
||||
rawBrowserGate,
|
||||
streamGate,
|
||||
renderGate,
|
||||
});
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
|
||||
64
web/app/src/workers/vdb-webgpu-test.worker.ts
Normal file
64
web/app/src/workers/vdb-webgpu-test.worker.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
|
||||
import { mapPrincipledVolumeToNanoVDB } from "../volume/volume-material-mapping";
|
||||
import { probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, sampleNanoVDBFloat32WebGPU, uploadNanoVDBFloat32Grid } from "../render/nanovdb-volume-renderer";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const report = await (await fetch("/__vdb_fixture__/report", { cache: "no-store" })).json() as { grids: Array<{ name: string; scalarSamples?: Array<{ coord: [number, number, number]; value: number; active: boolean }> }> };
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
const nativeDensity = report.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density || !nativeDensity?.scalarSamples || !manifest.gpu.float32TreeLayout) throw new Error("VDB WebGPU fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, cache: "no-store" });
|
||||
if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`);
|
||||
const payload = await response.arrayBuffer();
|
||||
const cpu = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
|
||||
const cpuSamples = nativeDensity.scalarSamples.map((sample) => ({ coord: sample.coord, ...cpu.nearest(sample.coord), expectedValue: sample.value, expectedActive: sample.active }));
|
||||
const probe = await probeNanoVDBWebGPU(payload.byteLength);
|
||||
if (!probe.capability.available || !probe.device) throw new Error(probe.capability.reason ?? "WebGPU adapter is unavailable");
|
||||
const device = probe.device;
|
||||
device.pushErrorScope("validation");
|
||||
const uploaded = uploadNanoVDBFloat32Grid(device, payload);
|
||||
const gpuSamples = await sampleNanoVDBFloat32WebGPU(device, uploaded, nativeDensity.scalarSamples.map((sample) => sample.coord));
|
||||
const materialMapping = mapPrincipledVolumeToNanoVDB(manifest, {
|
||||
densityGrid: manifest.material.densityGrid,
|
||||
densityScale: 1,
|
||||
colorGrid: manifest.material.colorGrid,
|
||||
color: [0.7, 0.8, 0.95],
|
||||
temperatureGrid: manifest.material.temperatureGrid,
|
||||
temperatureScale: 1,
|
||||
blackbodyEnabled: true,
|
||||
emissionColor: [1, 0.35, 0.1],
|
||||
emissionScale: 0.08,
|
||||
velocityGrid: manifest.material.velocityGrid,
|
||||
anisotropy: 0.2,
|
||||
interpolation: "LINEAR",
|
||||
});
|
||||
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, materialMapping.material, 96, 96);
|
||||
const validationError = await device.popErrorScope();
|
||||
if (validationError) throw new Error(`WebGPU validation failed: ${validationError.message}`);
|
||||
let visiblePixels = 0;
|
||||
let alphaSum = 0;
|
||||
for (let index = 3; index < pixels.length; index += 4) {
|
||||
alphaSum += pixels[index];
|
||||
if (pixels[index] > 0) visiblePixels++;
|
||||
}
|
||||
const imageHash = await crypto.subtle.digest("SHA-256", new Uint8Array(pixels).buffer);
|
||||
uploaded.dispose();
|
||||
device.destroy();
|
||||
return {
|
||||
capability: probe.capability,
|
||||
payloadBytes: payload.byteLength,
|
||||
nativeSamples: nativeDensity.scalarSamples,
|
||||
cpuSamples,
|
||||
gpuSamples,
|
||||
visiblePixels,
|
||||
alphaSum,
|
||||
imageSha256: Array.from(new Uint8Array(imageHash), (byte) => byte.toString(16).padStart(2, "0")).join(""),
|
||||
materialMapping,
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
InstancedMesh,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
MeshPhysicalMaterial,
|
||||
PerspectiveCamera,
|
||||
Quaternion,
|
||||
@@ -27,6 +28,10 @@ import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../three-adapter/offscreen-viewport-protocol";
|
||||
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
|
||||
import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
|
||||
import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
import { createNanoVDBViewportObject } from "../three-adapter/volume";
|
||||
import {
|
||||
configurePBRLight,
|
||||
configurePBRRenderer,
|
||||
@@ -35,8 +40,15 @@ import {
|
||||
setPBRMaterialSelected,
|
||||
} from "../three-adapter/pbr";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
|
||||
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import {
|
||||
applyGreasePencilPointSelection,
|
||||
applyGreasePencilPointPreview,
|
||||
applyGreasePencilTransform,
|
||||
createGreasePencilObject,
|
||||
greasePencilPointRef,
|
||||
type GreasePencilPointRef,
|
||||
} from "../three-adapter/grease-pencil";
|
||||
|
||||
const workerScope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
|
||||
@@ -47,6 +59,7 @@ let scene: Scene | null = null;
|
||||
let camera: PerspectiveCamera | null = null;
|
||||
let root: Group | null = null;
|
||||
let importedLights: Group | null = null;
|
||||
let contextLost = false;
|
||||
let width = 1;
|
||||
let height = 1;
|
||||
let yaw = -Math.PI / 4;
|
||||
@@ -57,17 +70,27 @@ let selectionMode: MeshElementMode = "FACE";
|
||||
let currentSnapshot: SceneSnapshotIR | null = null;
|
||||
const textureStore = new GPUTextureStore();
|
||||
const raycaster = new Raycaster();
|
||||
raycaster.params.Points.threshold = 0.14;
|
||||
const objectById = new Map<string, Object3D>();
|
||||
let curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
|
||||
let volumeAssets: NanoVDBViewportAssetIR[] = [];
|
||||
const volumeRenderCache = new Map<string, NanoVDBViewportRenderResultIR>();
|
||||
let volumeRenderGeneration = 0;
|
||||
const volumeRenderSession = new NanoVDBViewportRenderSession(() => {
|
||||
volumeRenderCache.clear();
|
||||
void refreshVolumes();
|
||||
});
|
||||
|
||||
function post(message: OffscreenViewportResponse): void {
|
||||
workerScope.postMessage(message);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
if (!renderer || !scene || !camera) return;
|
||||
if (contextLost || !renderer || !scene || !camera) return;
|
||||
camera.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch));
|
||||
camera.lookAt(0, 0, 0);
|
||||
renderer.render(scene, camera);
|
||||
publishCurveGizmoFrame();
|
||||
const gl = renderer.getContext();
|
||||
const sampleWidth = Math.min(16, gl.drawingBufferWidth);
|
||||
const sampleHeight = Math.min(16, gl.drawingBufferHeight);
|
||||
@@ -88,6 +111,28 @@ function render(): void {
|
||||
post({ type: "frame", visiblePixels });
|
||||
}
|
||||
|
||||
function publishCurveGizmoFrame(): void {
|
||||
const active = curveGizmoFrame;
|
||||
let frame: CurveGizmoScreenFrameIR | null = null;
|
||||
const node = active ? currentSnapshot?.nodes.find((candidate) => candidate.dataId === active.dataId && candidate.id === currentSnapshot?.activeObjectId) : undefined;
|
||||
const object = node ? objectById.get(node.id) : undefined;
|
||||
if (active && object && camera) {
|
||||
object.updateWorldMatrix(true, false);
|
||||
camera.updateMatrixWorld(true);
|
||||
const project = (value: readonly number[]): Vector3 => new Vector3(value[0], value[2], -value[1]).applyMatrix4(object.matrixWorld).project(camera!);
|
||||
const origin = project(active.frame.origin);
|
||||
const axes = active.frame.axes.map((axis) => {
|
||||
const endpoint = project([active.frame.origin[0] + axis[0], active.frame.origin[1] + axis[1], active.frame.origin[2] + axis[2]]);
|
||||
const x = endpoint.x - origin.x;
|
||||
const y = origin.y - endpoint.y;
|
||||
const magnitude = Math.hypot(x, y);
|
||||
return magnitude > 1e-8 ? [x / magnitude, y / magnitude] as [number, number] : [0, 0] as [number, number];
|
||||
}) as CurveGizmoScreenFrameIR["axes"];
|
||||
frame = { origin: [(origin.x + 1) / 2, (1 - origin.y) / 2], axes };
|
||||
}
|
||||
post({ type: "curveGizmoScreenFrame", frame });
|
||||
}
|
||||
|
||||
function resize(nextWidth: number, nextHeight: number, pixelRatio: number): void {
|
||||
width = Math.max(1, nextWidth);
|
||||
height = Math.max(1, nextHeight);
|
||||
@@ -172,12 +217,65 @@ function clearRoot(): void {
|
||||
const mesh = object as Mesh;
|
||||
mesh.geometry?.dispose();
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
for (const material of materials) material?.dispose();
|
||||
for (const material of materials) {
|
||||
if (mesh.userData.nanoVDBVolume && material instanceof MeshBasicMaterial) material.map?.dispose();
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
objectById.clear();
|
||||
}
|
||||
|
||||
async function refreshVolumes(): Promise<void> {
|
||||
const generation = ++volumeRenderGeneration;
|
||||
if (!root || !currentSnapshot) return;
|
||||
for (const child of [...root.children]) {
|
||||
if (!child.userData.nanoVDBVolume) continue;
|
||||
root.remove(child);
|
||||
child.traverse((object) => {
|
||||
const mesh = object as Mesh;
|
||||
mesh.geometry?.dispose();
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
for (const material of materials) {
|
||||
if (material instanceof MeshBasicMaterial) material.map?.dispose();
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
const snapshot = currentSnapshot;
|
||||
const nodes = snapshot.nodes.filter((node) => node.visible && node.type === "VOLUME" && node.dataId);
|
||||
if (nodes.length === 0) {
|
||||
post({ type: "volumeStatus", status: "none", count: 0 });
|
||||
return;
|
||||
}
|
||||
post({ type: "volumeStatus", status: "loading", count: 0 });
|
||||
try {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
const asset = volumeAssets.find((candidate) => candidate.dataId === node.dataId);
|
||||
if (!asset) continue;
|
||||
const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`;
|
||||
let result = volumeRenderCache.get(cacheKey);
|
||||
if (!result) {
|
||||
result = await renderNanoVDBViewportAsset(asset, 128, 128, volumeRenderSession);
|
||||
volumeRenderCache.set(cacheKey, result);
|
||||
}
|
||||
if (generation !== volumeRenderGeneration || currentSnapshot !== snapshot || !root) return;
|
||||
const object = createNanoVDBViewportObject(result, node);
|
||||
root.add(object);
|
||||
objectById.set(node.id, object);
|
||||
count++;
|
||||
}
|
||||
if (generation !== volumeRenderGeneration) return;
|
||||
post({ type: "volumeStatus", status: count === nodes.length ? "ready" : "blocked", count, ...(count === nodes.length ? {} : { errorCode: "NON_MESH_RESOURCE_MISSING" }) });
|
||||
render();
|
||||
}
|
||||
catch (error) {
|
||||
if (generation !== volumeRenderGeneration) return;
|
||||
post({ type: "volumeStatus", status: "blocked", count: 0, errorCode: error instanceof Error ? error.message.split(":", 1)[0] : "VOLUME_SHADER_UNAVAILABLE" });
|
||||
}
|
||||
}
|
||||
|
||||
function applyTextureAssets(assets: readonly GPUTextureAsset[]): void {
|
||||
void textureStore.upload(assets).then((status) => {
|
||||
if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot);
|
||||
@@ -258,6 +356,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
if (node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA" || !node.visible || !node.dataId) continue;
|
||||
const data = dataById.get(node.dataId);
|
||||
if (!data) continue;
|
||||
if (data.type === "VOLUME") continue;
|
||||
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
|
||||
if (!object) {
|
||||
nonMeshBlockedCount++;
|
||||
@@ -310,10 +409,11 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
objectById.set(node.id, light);
|
||||
}
|
||||
}
|
||||
void refreshVolumes();
|
||||
render();
|
||||
}
|
||||
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>): void {
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[]): void {
|
||||
const selected = new Set(ids);
|
||||
const visited = new Set<Object3D>();
|
||||
for (const [id, object] of objectById) {
|
||||
@@ -340,13 +440,24 @@ function setSelection(ids: string[], elements: Array<{ dataId: string; kind: Non
|
||||
selection.set(element.dataId, kinds);
|
||||
}
|
||||
if (root) applyNonMeshElementSelection(root, selection);
|
||||
if (root) applyGreasePencilPointSelection(root, greasePencilPoints);
|
||||
render();
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, additive: boolean): void {
|
||||
if (!root || !camera) return;
|
||||
raycaster.setFromCamera(new Vector2(x, y), camera);
|
||||
const hit = raycaster.intersectObjects(root.children, true)[0];
|
||||
const hits = raycaster.intersectObjects(root.children, true);
|
||||
const greasePencilHit = editMode
|
||||
? hits.find((intersection) => intersection.index !== undefined && greasePencilPointRef(intersection.object, intersection.index) !== null)
|
||||
: undefined;
|
||||
if (greasePencilHit?.index !== undefined) {
|
||||
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
|
||||
if (point) post({ type: "greasePencilPointSelected", point, additive });
|
||||
return;
|
||||
}
|
||||
const preferredNonMeshHit = editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
|
||||
const hit = preferredNonMeshHit ?? hits[0];
|
||||
if (!hit) return;
|
||||
const nonMeshDataId = hit.object.userData.nonMeshDataId;
|
||||
if (typeof nonMeshDataId === "string" && hit.index !== undefined) {
|
||||
@@ -403,6 +514,18 @@ workerScope.onmessage = (event): void => {
|
||||
const message = event.data;
|
||||
if (message.type === "init") {
|
||||
renderer = new WebGLRenderer({ canvas: message.canvas, antialias: true, preserveDrawingBuffer: true });
|
||||
message.canvas.addEventListener("webglcontextlost", (event) => {
|
||||
event.preventDefault();
|
||||
contextLost = true;
|
||||
post({ type: "volumeStatus", status: "loading", count: 0, errorCode: "WEBGL_CONTEXT_LOST" });
|
||||
});
|
||||
message.canvas.addEventListener("webglcontextrestored", () => {
|
||||
contextLost = false;
|
||||
configurePBRRenderer(renderer!);
|
||||
volumeRenderCache.clear();
|
||||
void refreshVolumes();
|
||||
render();
|
||||
});
|
||||
configurePBRRenderer(renderer);
|
||||
scene = new Scene();
|
||||
camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
||||
@@ -421,11 +544,31 @@ workerScope.onmessage = (event): void => {
|
||||
}
|
||||
else if (message.type === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers);
|
||||
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
|
||||
else if (message.type === "volumeAssets") {
|
||||
volumeAssets = message.assets;
|
||||
void refreshVolumes();
|
||||
}
|
||||
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints);
|
||||
else if (message.type === "interaction") {
|
||||
editMode = message.editMode;
|
||||
selectionMode = message.selectionMode;
|
||||
root?.traverse((object) => {
|
||||
if (typeof object.userData.greasePencilPointDataId === "string") object.visible = editMode;
|
||||
});
|
||||
render();
|
||||
}
|
||||
else if (message.type === "curveHandlePreview") {
|
||||
if (root) applyCurveHandlePreview(root, message.dataId, message.handles);
|
||||
render();
|
||||
}
|
||||
else if (message.type === "greasePencilPointPreview") {
|
||||
if (root) applyGreasePencilPointPreview(root, message.dataId, message.layerId, message.frame, message.points);
|
||||
render();
|
||||
}
|
||||
else if (message.type === "curveGizmoFrame") {
|
||||
curveGizmoFrame = message.dataId && message.frame ? { dataId: message.dataId, frame: message.frame } : null;
|
||||
render();
|
||||
}
|
||||
else if (message.type === "orbit") {
|
||||
yaw -= message.deltaX * 0.008;
|
||||
@@ -436,6 +579,9 @@ workerScope.onmessage = (event): void => {
|
||||
else if (message.type === "pick") pick(message.x, message.y, message.additive);
|
||||
else if (message.type === "dispose") {
|
||||
clearRoot();
|
||||
volumeRenderGeneration++;
|
||||
volumeRenderCache.clear();
|
||||
volumeRenderSession.dispose();
|
||||
clearLights();
|
||||
renderer?.dispose();
|
||||
textureStore.dispose();
|
||||
@@ -444,6 +590,7 @@ workerScope.onmessage = (event): void => {
|
||||
camera = null;
|
||||
root = null;
|
||||
importedLights = null;
|
||||
contextLost = false;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -90,6 +90,7 @@ let module: WasmModule | null = null;
|
||||
let wasmFactory: WasmFactory | null = null;
|
||||
let wasmBinary: ArrayBuffer | null = null;
|
||||
let handle = 0;
|
||||
let initializationPromise: Promise<WebEngineStatus> | null = null;
|
||||
let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
|
||||
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
|
||||
let sourceBlendBuffer: ArrayBuffer | null = null;
|
||||
@@ -300,6 +301,17 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (!payload.links || [payload.links.regular, payload.links.bold, payload.links.italic, payload.links.boldItalic].some((id) => typeof id !== "string" || !available.has(id))) throw report("NON_MESH_RESOURCE_MISSING", "Font style links must reference VFonts already present in the current Main");
|
||||
return;
|
||||
}
|
||||
case "setVolumeProperties": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "VOLUME") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Volume data block is unavailable: ${payload.dataId}`);
|
||||
if (typeof payload.sourcePath !== "string" || !payload.sourcePath.startsWith("//") || !payload.sourcePath.endsWith(".vdb") || payload.sourcePath.includes("\\") || payload.sourcePath.slice(2).split("/").includes("..") || new TextEncoder().encode(payload.sourcePath).byteLength >= 1024) throw report("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "Volume source must be a bounded project-relative // path ending in .vdb");
|
||||
if (!Number.isFinite(payload.displayDensity) || payload.displayDensity < 0 || payload.displayDensity > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", "Volume display density is outside the bounded range");
|
||||
if (payload.interpolation !== "NEAREST" && payload.interpolation !== "LINEAR") throw report("NON_MESH_PROPERTY_INVALID", "Volume interpolation is unsupported");
|
||||
if (!Number.isFinite(payload.stepSize) || payload.stepSize < 0 || payload.stepSize > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", "Volume render step is outside the bounded range");
|
||||
if (payload.velocityGrid !== undefined && (typeof payload.velocityGrid !== "string" || new TextEncoder().encode(payload.velocityGrid).byteLength >= 64)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity grid name exceeds the Blender field limit");
|
||||
if (payload.velocityScale !== undefined && (!Number.isFinite(payload.velocityScale) || payload.velocityScale < -1_000_000 || payload.velocityScale > 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity scale is outside the bounded range");
|
||||
return;
|
||||
}
|
||||
case "setMetaballElements": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`);
|
||||
@@ -459,20 +471,32 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport {
|
||||
}
|
||||
|
||||
async function initialize(): Promise<WebEngineStatus> {
|
||||
if (!module) {
|
||||
const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory };
|
||||
const factory = imported.default;
|
||||
const binary = await fetch("/vendor/blender/web_engine.wasm?v=2").then((response) => {
|
||||
if (!response.ok) throw new Error(`web_engine.wasm request failed: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
if (module) return status();
|
||||
if (!initializationPromise) {
|
||||
initializationPromise = (async () => {
|
||||
const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory };
|
||||
const factory = imported.default;
|
||||
const binary = await fetch("/vendor/blender/web_engine.wasm?v=2").then((response) => {
|
||||
if (!response.ok) throw new Error(`web_engine.wasm request failed: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
});
|
||||
const initializedModule = await factory({ wasmBinary: binary });
|
||||
const initializedHandle = initializedModule._web_engine_create();
|
||||
if (initializedHandle <= 0) throw new Error("WebEngine handle creation failed");
|
||||
wasmFactory = factory;
|
||||
wasmBinary = binary;
|
||||
module = initializedModule;
|
||||
handle = initializedHandle;
|
||||
return status();
|
||||
})().catch((error: unknown) => {
|
||||
module = null;
|
||||
handle = 0;
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
initializationPromise = null;
|
||||
});
|
||||
wasmFactory = factory;
|
||||
wasmBinary = binary;
|
||||
module = await factory({ wasmBinary: binary });
|
||||
handle = module._web_engine_create();
|
||||
if (handle <= 0) throw new Error("WebEngine handle creation failed");
|
||||
}
|
||||
return status();
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
function copyIntoWasm(buffer: ArrayBuffer): { pointer: number; length: number } {
|
||||
|
||||
Reference in New Issue
Block a user