Advance WebGPU volume and bounded workflows

This commit is contained in:
mes123456
2026-08-14 18:08:29 -04:00
parent 3da1dfc804
commit 68d50f810f
119 changed files with 9028 additions and 430 deletions

View File

@@ -13,6 +13,7 @@ import { AutosaveScheduler } from "../storage/autosave";
import { ViewportRenderer } from "../three-adapter/viewport";
import { acquireOffscreenViewportRenderer, OffscreenViewportRenderer, releaseOffscreenViewportRenderer, supportsOffscreenViewport, type ViewportBackend } from "../three-adapter/offscreen-viewport";
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "../three-adapter/grease-pencil";
import { buildLODCacheKey } from "../three-adapter/lod";
import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
import type { LODCacheRecord } from "../../../protocol/lod";
@@ -20,8 +21,16 @@ import { modifierStackHash } from "../../../protocol/modifier";
import { exportGLB } from "../../../protocol/glb-export";
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction";
import { applyCurveGizmoDelta, curveGizmoAxisDelta, deriveCurveHandleGizmoFrame, type CurveGizmoFrameIR, type CurveGizmoHandleIR, type CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
import { applyGreasePencilPointTranslation } from "../../../protocol/grease-pencil-editor";
import {
loadAndCommitNanoVDBViewportAsset,
loadNanoVDBViewportAsset,
reopenNanoVDBViewportAssetFromOPFS,
type NanoVDBViewportAssetIR,
type NanoVDBViewportProjectContextIR,
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import "./app-shell.css";
@@ -34,6 +43,11 @@ function errorMessage(error: unknown): string {
return String(error);
}
async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
interface AreaProps {
className?: string;
editor: EditorType;
@@ -62,34 +76,143 @@ interface MeshEditSelection {
indices: Set<number>;
nonMeshKind?: NonMeshElementKind;
nonMeshSelections?: Map<NonMeshElementKind, Set<number>>;
greasePencilPoints?: GreasePencilPointRef[];
}
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onTransform }: {
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, volumeProject, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onGreasePencilPointSelect, onTransform }: {
snapshot: SceneSnapshotIR | null;
geometryBuffers: MeshGeometryBuffer[];
nonMeshGeometryBuffers: NonMeshGeometryChunk[];
textureAssets: GPUTextureAsset[];
volumeProject: NanoVDBViewportProjectContextIR | null;
lodLevels: Record<string, WebEngineLODLevelResult[]> | null;
selectedObjectIds: ReadonlySet<string>;
editMode: boolean;
meshSelection: MeshEditSelection;
onSelect: (id: string, additive: boolean) => void;
onElementSelect: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
onTransform: (tool: "translate" | "rotate" | "scale", amount?: number, axis?: 0 | 1 | 2) => void;
onGreasePencilPointSelect: (point: GreasePencilPointRef, additive: boolean) => void;
onTransform: (tool: "translate" | "rotate" | "scale", amount?: number, axis?: 0 | 1 | 2, axisVector?: [number, number, number]) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<ViewportBackend | null>(null);
const onSelectRef = useRef(onSelect);
const onElementSelectRef = useRef(onElementSelect);
const onGreasePencilPointSelectRef = useRef(onGreasePencilPointSelect);
onSelectRef.current = onSelect;
onElementSelectRef.current = onElementSelect;
onGreasePencilPointSelectRef.current = onGreasePencilPointSelect;
const [viewportError, setViewportError] = useState<string | null>(null);
const [activeTool, setActiveTool] = useState<"translate" | "rotate" | "scale">("translate");
const [curveGizmoScreenFrame, setCurveGizmoScreenFrame] = useState<CurveGizmoScreenFrameIR | null>(null);
const [volumeAssets, setVolumeAssets] = useState<NanoVDBViewportAssetIR[]>([]);
const volumeSources = useMemo(() => (snapshot?.nonMeshData ?? []).flatMap((data) => {
if (data.type !== "VOLUME" || !data.sourcePath?.toLowerCase().endsWith(".vdb")) return [];
try { return [{ dataId: data.id, sourcePath: normalizeProjectAssetPath(data.sourcePath) }]; }
catch { return []; }
}), [snapshot?.nonMeshData]);
const curveControlPoints = (dataId: string, inline?: ArrayLike<number>): ArrayLike<number> | null => {
if (inline) return inline;
const chunks = nonMeshGeometryBuffers.filter((chunk) => chunk.dataId === dataId).sort((left, right) => left.pointOffset - right.pointOffset);
if (chunks.length === 0) return null;
const points = new Float32Array(chunks[0].totalPointCount * 3);
for (const chunk of chunks) {
if (chunk.pointOffset < 0 || chunk.pointOffset + chunk.pointCount > chunks[0].totalPointCount || chunk.positions.byteLength !== chunk.pointCount * 3 * 4) return null;
points.set(new Float32Array(chunk.positions), chunk.pointOffset * 3);
}
return points;
};
const curveGizmo = useMemo<{ dataId: string; handles: CurveGizmoHandleIR[]; frame: CurveGizmoFrameIR } | null>(() => {
if (!snapshot || !editMode || activeTool !== "translate") return null;
const activeNode = snapshot.nodes.find((node) => node.id === snapshot.activeObjectId);
const nonMesh = snapshot.nonMeshData?.find((candidate) => candidate.id === activeNode?.dataId);
if (nonMesh?.type !== "CURVE" || meshSelection.meshId !== nonMesh.id || !nonMesh.handlePoints || !meshSelection.nonMeshSelections) return null;
const controlPoints = curveControlPoints(nonMesh.id, nonMesh.controlPoints);
if (!controlPoints) return null;
const pointIndices = nonMesh.handlePointIndices ?? Array.from({ length: nonMesh.handlePoints.length / 6 }, (_, index) => index);
const handles: CurveGizmoHandleIR[] = [];
for (const [kind, selected] of meshSelection.nonMeshSelections) {
if (kind === "CONTROL_POINT") continue;
const sideOffset = kind === "HANDLE_RIGHT" ? 3 : 0;
for (const pointIndex of selected) {
const packedPointIndex = pointIndices.indexOf(pointIndex);
if (packedPointIndex < 0) continue;
handles.push({ pointIndex, side: kind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position: nonMesh.handlePoints.slice(packedPointIndex * 6 + sideOffset, packedPointIndex * 6 + sideOffset + 3) as [number, number, number] });
}
}
if (handles.length === 0) return null;
try {
return { dataId: nonMesh.id, handles, frame: deriveCurveHandleGizmoFrame(controlPoints, handles) };
}
catch {
return null;
}
}, [snapshot, editMode, activeTool, meshSelection.meshId, meshSelection.nonMeshSelections, nonMeshGeometryBuffers]);
const greasePencilGizmo = useMemo(() => {
if (!snapshot || !editMode || activeTool !== "translate" || !meshSelection.greasePencilPoints?.length) return null;
const activeNode = snapshot.nodes.find((node) => node.id === snapshot.activeObjectId);
const data = snapshot.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId);
const first = meshSelection.greasePencilPoints[0];
if (!data || meshSelection.meshId !== data.id || meshSelection.greasePencilPoints.some((point) => point.dataId !== first.dataId || point.layerId !== first.layerId || point.frame !== first.frame)) return null;
const layer = data.layers.find((candidate) => candidate.id === first.layerId);
const frame = layer?.frames.find((candidate) => candidate.frame === first.frame);
if (!layer || !frame) return null;
return { data, layer, frame, selected: meshSelection.greasePencilPoints };
}, [snapshot, editMode, activeTool, meshSelection.meshId, meshSelection.greasePencilPoints]);
const previewCurveHandles = (amount: number, axis: 0 | 1 | 2): string | null => {
if (!snapshot || !curveGizmo) return null;
const axisVector = curveGizmo.frame.axes[axis];
const delta = curveGizmoAxisDelta(curveGizmo.frame, axis, amount);
try {
const preview = applyCurveGizmoDelta({ schemaVersion: 1, dataId: curveGizmo.dataId, baseRevision: snapshot.revision, phase: "PREVIEW", axis, axisVector, delta, handles: curveGizmo.handles }, snapshot.revision);
rendererRef.current?.setCurveHandlePreview(curveGizmo.dataId, preview.handles);
return curveGizmo.dataId;
}
catch {
return null;
}
};
const previewGreasePencilPoints = (amount: number, axis: 0 | 1 | 2): boolean => {
if (!snapshot || !greasePencilGizmo) return false;
const translation: [number, number, number] = [0, 0, 0];
translation[axis] = amount;
const { data, layer, frame, selected } = greasePencilGizmo;
try {
const result = applyGreasePencilPointTranslation({
schemaVersion: 1,
revision: snapshot.revision,
dataId: data.id,
layerId: layer.id,
frame: frame.frame,
onionSkinning: layer.onionSkinning ?? false,
selectedStrokeIndices: [...new Set(selected.map((point) => point.strokeIndex))],
selectedPoints: selected.map(({ strokeIndex, pointIndex }) => ({ strokeIndex, pointIndex })),
}, frame.drawing.strokes, { type: "TRANSLATE_POINTS", revision: snapshot.revision, translation });
const points: GreasePencilPointPreview[] = selected.map((identity) => ({ ...identity, position: result.strokes[identity.strokeIndex].points[identity.pointIndex].position }));
rendererRef.current?.setGreasePencilPointPreview(data.id, layer.id, frame.frame, points);
return true;
}
catch {
return false;
}
};
useEffect(() => {
if (!canvasRef.current) return;
try {
const offscreenRequested = new URLSearchParams(window.location.search).get("offscreen") === "1";
const canvas = canvasRef.current;
const selectObject = (id: string, additive: boolean): void => onSelectRef.current(id, additive);
const selectElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => onElementSelectRef.current(meshId, mode, index, additive, nonMeshKind);
const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean): void => onGreasePencilPointSelectRef.current(point, additive);
const renderer = offscreenRequested && supportsOffscreenViewport(canvas)
? acquireOffscreenViewportRenderer(canvas, onSelect, onElementSelect)
: new ViewportRenderer(canvas, onSelect, onElementSelect);
? acquireOffscreenViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint)
: new ViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint);
rendererRef.current = renderer;
return () => {
rendererRef.current = null;
@@ -121,14 +244,59 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections
? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]])
: undefined;
renderer?.setSelection(selectedObjectIds, elementSelection);
renderer?.setSelection(selectedObjectIds, elementSelection, meshSelection.greasePencilPoints);
renderer?.setInteractionMode(editMode, meshSelection.mode);
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections]);
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections, meshSelection.greasePencilPoints]);
useEffect(() => {
rendererRef.current?.setTextureAssets(textureAssets);
}, [textureAssets]);
useEffect(() => {
const controller = new AbortController();
if (volumeSources.length === 0) {
setVolumeAssets([]);
return () => controller.abort();
}
void Promise.all(volumeSources.map(async ({ dataId, sourcePath }) => {
const sourceUrl = `/${sourcePath.slice(2)}`;
const manifestUrl = sourceUrl.replace(/\.vdb$/i, ".nanovdb.json");
const bundleUrl = sourceUrl.replace(/\.vdb$/i, ".nvdb");
if (!volumeProject) return loadNanoVDBViewportAsset(dataId, manifestUrl, bundleUrl, controller.signal);
try { return await reopenNanoVDBViewportAssetFromOPFS(dataId, sourcePath, volumeProject, controller.signal); }
catch {
return loadAndCommitNanoVDBViewportAsset(dataId, sourcePath, manifestUrl, bundleUrl, volumeProject, controller.signal);
}
})).then(setVolumeAssets).catch((error) => {
if (!controller.signal.aborted) {
setVolumeAssets([]);
const canvas = canvasRef.current;
if (canvas) {
canvas.dataset.volumeStatus = "blocked";
canvas.dataset.volumeErrorCode = error instanceof Error ? error.message.split(":", 1)[0] : "NON_MESH_RESOURCE_MISSING";
}
}
});
return () => controller.abort();
}, [volumeSources, volumeProject]);
useEffect(() => {
rendererRef.current?.setVolumeAssets(volumeAssets);
}, [volumeAssets]);
useEffect(() => {
rendererRef.current?.setCurveGizmoFrame(curveGizmo?.dataId ?? null, curveGizmo?.frame ?? null);
if (!curveGizmo) setCurveGizmoScreenFrame(null);
}, [curveGizmo]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const update = (event: Event): void => setCurveGizmoScreenFrame((event as CustomEvent<CurveGizmoScreenFrameIR | null>).detail);
canvas.addEventListener("curve-gizmo-frame", update);
return () => canvas.removeEventListener("curve-gizmo-frame", update);
}, []);
return (
<div className="viewport-placeholder" role="img" aria-label="Three.js 视口占位区域">
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
@@ -143,22 +311,38 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<button type="button" className={`tool-button${activeTool === "rotate" ? " active" : ""}`} aria-label="旋转工具" title="旋转" onClick={() => setActiveTool("rotate")}></button>
<button type="button" className={`tool-button${activeTool === "scale" ? " active" : ""}`} aria-label="缩放工具" title="缩放" onClick={() => setActiveTool("scale")}></button>
</div>
{snapshot?.activeObjectId ? <div className="transform-gizmo" aria-label="变换 Gizmo">{([0, 1, 2] as const).map((axis) => <button key={axis} type="button" className={`gizmo-axis axis-${"xyz"[axis]}`} aria-label={`${"XYZ"[axis]} 轴变换手柄`} onPointerDown={(event) => {
{snapshot?.activeObjectId ? <div className={`transform-gizmo${curveGizmoScreenFrame ? " handle-local" : ""}`} aria-label="变换 Gizmo" data-gizmo-space={curveGizmoScreenFrame ? "HANDLE_LOCAL" : "OBJECT"} style={curveGizmoScreenFrame ? { left: `${curveGizmoScreenFrame.origin[0] * 100}%`, top: `${curveGizmoScreenFrame.origin[1] * 100}%` } : undefined}>{([0, 1, 2] as const).map((axis) => <button key={axis} type="button" className={`gizmo-axis axis-${"xyz"[axis]}`} aria-label={`${"XYZ"[axis]} 轴变换手柄`} data-local-axis={curveGizmoScreenFrame ? curveGizmo?.frame.axes[axis].map((value) => value.toFixed(6)).join(",") : undefined} data-screen-axis={curveGizmoScreenFrame ? curveGizmoScreenFrame.axes[axis].map((value) => value.toFixed(6)).join(",") : undefined} style={curveGizmoScreenFrame ? { left: `${46 + curveGizmoScreenFrame.axes[axis][0] * 32 - 14}px`, top: `${46 + curveGizmoScreenFrame.axes[axis][1] * 32 - 14}px` } : undefined} onPointerDown={(event) => {
const start = { x: event.clientX, y: event.clientY };
const screenAxis = curveGizmoScreenFrame?.axes[axis];
const axisVector = curveGizmo?.frame.axes[axis];
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const target = event.currentTarget;
let previewDataId: string | null = null;
let greasePencilPreview = false;
const pointerDistance = (pointer: PointerEvent): number => screenAxis ? (pointer.clientX - start.x) * screenAxis[0] + (pointer.clientY - start.y) * screenAxis[1] : (pointer.clientX - start.x) - (pointer.clientY - start.y);
const move = (pointer: PointerEvent): void => {
const distance = pointerDistance(pointer);
if (Math.abs(distance) < 2) return;
if (curveGizmo) previewDataId = previewCurveHandles(distance / 100, axis) ?? previewDataId;
else greasePencilPreview = previewGreasePencilPoints(distance / 100, axis) || greasePencilPreview;
};
const finish = (up: PointerEvent): void => {
target.removeEventListener("pointermove", move);
target.removeEventListener("pointerup", finish);
target.removeEventListener("pointercancel", finish);
const distance = (up.clientX - start.x) - (up.clientY - start.y);
if (Math.abs(distance) >= 2) onTransform(activeTool, distance / 100, axis);
if (previewDataId) rendererRef.current?.setCurveHandlePreview(previewDataId, null);
if (greasePencilPreview && greasePencilGizmo) rendererRef.current?.setGreasePencilPointPreview(greasePencilGizmo.data.id, greasePencilGizmo.layer.id, greasePencilGizmo.frame.frame, null);
if (up.type === "pointercancel") return;
const distance = pointerDistance(up);
if (Math.abs(distance) >= 2) onTransform(activeTool, distance / 100, axis, axisVector);
};
target.addEventListener("pointermove", move);
target.addEventListener("pointerup", finish);
target.addEventListener("pointercancel", finish);
}}>{"XYZ"[axis]}</button>)}</div> : null}
<aside className="viewport-sidebar" aria-label="视口侧栏">
<span>{editMode ? `${meshSelection.mode} ${meshSelection.indices.size}` : "Transform"}</span>
<span>{editMode ? `VERT ${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size}` : "Transform"}</span>
<span>View</span>
<span>Item</span>
</aside>
@@ -190,10 +374,11 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
);
}
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilPointSelection, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
snapshot: SceneSnapshotIR | null;
selectedFaceIndices: number[];
selectedVertexIndices: number[];
greasePencilPointSelection: readonly GreasePencilPointRef[];
onCommand: (command: WebEngineEditCommand) => void;
onImportImage: (file: File) => void;
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
@@ -225,6 +410,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
const [renameValue, setRenameValue] = useState("");
const [paintColor, setPaintColor] = useState("#cc6633");
const [paintWeight, setPaintWeight] = useState(1);
const [paintSelectionMask, setPaintSelectionMask] = useState(0.5);
const [paintGroup, setPaintGroup] = useState("WebPaint");
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
@@ -234,6 +420,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined;
const selectedGreasePencilPoint = greasePencilPointSelection.find((point) => point.dataId === activeGreasePencil?.id);
const activeMaterial = snapshot?.materials.find((material) => material.id === activeMesh?.materialSlotIds?.[0]);
useEffect(() => {
if (!activeMaterial) return;
@@ -252,11 +439,46 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
if (!activeGreasePencil) setGreasePencilLayerId("");
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
}, [activeGreasePencil, greasePencilLayerId]);
const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (snapshot?.frame.current ?? 1));
useEffect(() => {
if (!selectedGreasePencilPoint) return;
setGreasePencilLayerId(selectedGreasePencilPoint.layerId);
setGreasePencilStrokeIndex(selectedGreasePencilPoint.strokeIndex);
setGreasePencilPointIndex(selectedGreasePencilPoint.pointIndex);
}, [selectedGreasePencilPoint?.dataId, selectedGreasePencilPoint?.layerId, selectedGreasePencilPoint?.frame, selectedGreasePencilPoint?.strokeIndex, selectedGreasePencilPoint?.pointIndex]);
const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (selectedGreasePencilPoint?.frame ?? snapshot?.frame.current ?? 1));
const activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex];
const activeGreasePencilPoint = activeGreasePencilStroke?.points?.[greasePencilPointIndex];
const selectedPaintBrushWeights = selectedVertexIndices.map((index) => ({ index, weight: paintSelectionMask }));
const currentPointColors = activeMesh && activeMesh.colors?.length === activeMesh.vertexCount * 4
? activeMesh.colors
: Array.from({ length: (activeMesh?.vertexCount ?? 0) * 4 }, (_, index) => index % 4 === 3 ? 1 : 0);
const currentGroupWeights = Array.from({ length: activeMesh?.vertexCount ?? 0 }, (_, vertex) => {
const skin = activeMesh?.skinWeights;
const group = skin?.boneNames.indexOf(paintGroup) ?? -1;
if (!skin || group < 0) return 0;
for (let slot = 0; slot < 4; slot++) {
const offset = vertex * 4 + slot;
if (skin.indices[offset] === group) return skin.weights[offset];
}
return 0;
});
const blendPaintColor = (): void => {
if (!activeMesh || !snapshot || selectedPaintBrushWeights.length === 0) return;
const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number];
const patch = composePaintColorPatch(snapshot.revision, snapshot.revision, currentPointColors, selectedPaintBrushWeights, [...rgb, 1]);
onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: patch.indices, colors: patch.colors });
};
const blendPaintWeight = (): void => {
if (!activeNode || !snapshot || !paintGroup || selectedPaintBrushWeights.length === 0) return;
const patch = composePaintWeightPatch(activeNode.id, paintGroup, snapshot.revision, snapshot.revision, currentGroupWeights, selectedPaintBrushWeights, paintWeight);
onCommand({ type: "setVertexWeights", objectId: patch.objectId, vertexGroup: patch.vertexGroup, indices: patch.indices, values: patch.values, normalize: patch.normalize });
};
const translateGreasePencilPoint = (): void => {
if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return;
const selectedPoints = greasePencilPointSelection
.filter((point) => point.dataId === activeGreasePencil.id && point.layerId === greasePencilLayerId && point.frame === activeGreasePencilFrame.frame)
.map(({ strokeIndex, pointIndex }) => ({ strokeIndex, pointIndex }));
if (selectedPoints.length === 0) selectedPoints.push({ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex });
const result = applyGreasePencilPointTranslation({
schemaVersion: 1,
revision: snapshot.revision,
@@ -264,8 +486,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
layerId: greasePencilLayerId,
frame: activeGreasePencilFrame.frame,
onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false,
selectedStrokeIndices: [greasePencilStrokeIndex],
selectedPoints: [{ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex }],
selectedStrokeIndices: [...new Set(selectedPoints.map((point) => point.strokeIndex))],
selectedPoints,
}, activeGreasePencilFrame.drawing.strokes, {
type: "TRANSLATE_POINTS",
revision: snapshot.revision,
@@ -321,8 +543,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label> <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}></button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}></button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}></button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
<div className="property-section"><h3>Viewport Display</h3><label> <span className="swatch" /></label><label> <input type="checkbox" defaultChecked /></label></div>
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label> UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}> UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => { setGreasePencilLayerId(event.target.value); setGreasePencilStrokeIndex(0); setGreasePencilPointIndex(0); }}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, baseRevision: snapshot?.revision, strokes: [] })}>Clear Drawing</button></div>{activeGreasePencilFrame ? <><label>Stroke <select aria-label="Grease Pencil stroke" value={Math.min(greasePencilStrokeIndex, Math.max(0, activeGreasePencilFrame.drawing.strokes.length - 1))} onChange={(event) => { setGreasePencilStrokeIndex(Number(event.target.value)); setGreasePencilPointIndex(0); }}>{activeGreasePencilFrame.drawing.strokes.map((stroke, index) => <option key={stroke.id ?? index} value={index}>{index + 1}</option>)}</select></label><label>Point <select aria-label="Grease Pencil point" value={Math.min(greasePencilPointIndex, Math.max(0, (activeGreasePencilStroke?.points?.length ?? 1) - 1))} onChange={(event) => setGreasePencilPointIndex(Number(event.target.value))}>{activeGreasePencilStroke?.points?.map((_, index) => <option key={index} value={index}>{index + 1}</option>)}</select></label><label>X delta <input aria-label="Grease Pencil point X delta" type="number" min="-1000000" max="1000000" step="0.1" value={greasePencilPointDeltaX} onChange={(event) => setGreasePencilPointDeltaX(Number(event.target.value))} /></label><div className="property-actions"><button type="button" disabled={!activeGreasePencilPoint} onClick={translateGreasePencilPoint}>Move Point</button></div>{activeGreasePencilPoint ? <output data-testid="grease-pencil-point-position">{activeGreasePencilPoint.position.join(", ")}</output> : null}</> : null}<output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: true })}>Apply Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output></div> : null}
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => { setGreasePencilLayerId(event.target.value); setGreasePencilStrokeIndex(0); setGreasePencilPointIndex(0); }}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, baseRevision: snapshot?.revision, strokes: [] })}>Clear Drawing</button></div>{activeGreasePencilFrame ? <><label>Stroke <select aria-label="Grease Pencil stroke" value={Math.min(greasePencilStrokeIndex, Math.max(0, activeGreasePencilFrame.drawing.strokes.length - 1))} onChange={(event) => { setGreasePencilStrokeIndex(Number(event.target.value)); setGreasePencilPointIndex(0); }}>{activeGreasePencilFrame.drawing.strokes.map((stroke, index) => <option key={stroke.id ?? index} value={index}>{index + 1}</option>)}</select></label><label>Point <select aria-label="Grease Pencil point" value={Math.min(greasePencilPointIndex, Math.max(0, (activeGreasePencilStroke?.points?.length ?? 1) - 1))} onChange={(event) => setGreasePencilPointIndex(Number(event.target.value))}>{activeGreasePencilStroke?.points?.map((_, index) => <option key={index} value={index}>{index + 1}</option>)}</select></label><label>X delta <input aria-label="Grease Pencil point X delta" type="number" min="-1000000" max="1000000" step="0.1" value={greasePencilPointDeltaX} onChange={(event) => setGreasePencilPointDeltaX(Number(event.target.value))} /></label><div className="property-actions"><button type="button" disabled={!activeGreasePencilPoint} onClick={translateGreasePencilPoint}>Move Point</button></div><output data-testid="grease-pencil-selection-count">{greasePencilPointSelection.length} viewport points selected</output>{activeGreasePencilPoint ? <output data-testid="grease-pencil-point-position">{activeGreasePencilPoint.position.join(", ")}</output> : null}</> : null}<output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><label>Selection mask <input aria-label="Paint selection mask" type="range" min="0" max="1" step="0.01" value={paintSelectionMask} onChange={(event) => setPaintSelectionMask(Number(event.target.value))} /><output>{paintSelectionMask.toFixed(2)}</output></label><div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: true })}>Apply Weight</button><button type="button" disabled={selectedVertexIndices.length === 0 || paintSelectionMask <= 0} onClick={blendPaintColor}>Blend Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup || paintSelectionMask <= 0} onClick={blendPaintWeight}>Blend Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output><output data-testid="paint-color-attribute">{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}</output><output data-testid="paint-vertex-group">{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}</output></div> : null}
{activeMesh ? <div className="property-section">
<h3>Material Slots</h3>
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => <span key={`${id}:${index}`}>{index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"}</span>)}</div>
@@ -365,14 +587,22 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
);
}
function OperatorSearch({ onClose }: { onClose: () => void }) {
interface OperatorCommand {
id: string;
label: string;
keywords: string;
execute: () => void;
}
function OperatorSearch({ commands, onClose }: { commands: readonly OperatorCommand[]; onClose: () => void }) {
const [query, setQuery] = useState("");
const operators = ["Add Cube", "Apply Transform", "Frame Selected", "Save Project"];
const matches = operators.filter((operator) => operator.toLowerCase().includes(query.toLowerCase()));
const normalized = query.trim().toLowerCase();
const matches = commands.filter((command) => `${command.label} ${command.keywords}`.toLowerCase().includes(normalized));
const run = (command: OperatorCommand): void => { command.execute(); onClose(); };
return (
<div className="operator-search" role="dialog" aria-label="Operator Search">
<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") onClose(); }} placeholder="Search operators" aria-label="搜索操作" />
<div className="operator-results">{matches.map((operator) => <button key={operator} type="button" onClick={onClose}>{operator}</button>)}</div>
<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") onClose(); else if (event.key === "Enter" && matches[0]) run(matches[0]); }} placeholder="Search operators" aria-label="搜索操作" />
<div className="operator-results">{matches.map((command) => <button key={command.id} type="button" onClick={() => run(command)}>{command.label}</button>)}</div>
</div>
);
}
@@ -395,13 +625,19 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
const third = Math.round(start + (end - start) * 0.6);
const fourth = Math.round(start + (end - start) * 0.8);
const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId);
const keyframes = [...new Set(animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])].sort((left, right) => left - right);
const activeNode = snapshot?.nodes.find((candidate) => candidate.id === snapshot.activeObjectId);
const greasePencil = snapshot?.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId);
const keyframes = [...new Set(greasePencil
? greasePencil.layers.flatMap((layer) => layer.frames.map((entry) => entry.frame))
: animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])]
.filter((keyframe) => keyframe >= start && keyframe <= end)
.sort((left, right) => left - right);
return (
<div className="timeline-content">
<div className="timeline-controls"><button type="button" aria-label="跳到第一帧" onClick={() => { setPlaying(false); onFrameChange(start); }}>|</button><button type="button" aria-label="上一帧" onClick={() => onFrameChange(Math.max(start, frame - 1))}></button><button type="button" aria-label="播放" onClick={() => setPlaying((current) => !current)}>{playing ? "Ⅱ" : "▶"}</button><button type="button" aria-label="下一帧" onClick={() => onFrameChange(Math.min(end, frame + 1))}>|</button><button type="button" aria-label="跳到最后一帧" onClick={() => { setPlaying(false); onFrameChange(end); }}>|</button><output className="frame-number">{frame}</output>{snapshot?.activeObjectId ? <><button type="button" aria-label="插入位置关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "LOCATION", interpolation: "BEZIER" })}>Loc</button><button type="button" aria-label="插入旋转关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "ROTATION_EULER", interpolation: "BEZIER" })}>Rot</button><button type="button" aria-label="插入缩放关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "SCALE", interpolation: "BEZIER" })}>Scale</button><button type="button" aria-label="删除当前关键帧" onClick={() => onCommand({ type: "deleteObjectKeyframe", objectId: snapshot.activeObjectId!, frame })}>Del Key</button></> : null}</div>
<input className="frame-slider" type="range" min={start} max={end} value={Math.min(end, Math.max(start, frame))} onChange={(event) => onFrameChange(Number(event.target.value))} aria-label="当前帧" />
<div className="timeline-scale"><span>{start}</span><span>{mid}</span><span>{second}</span><span>{third}</span><span>{fourth}</span><span>{end}</span></div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`关键帧 ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : null}</div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{greasePencil?.name ?? animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`${greasePencil ? "Grease Pencil 帧" : "关键帧"} ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{!greasePencil && animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : <span />}</div>
</div>
);
}
@@ -420,6 +656,7 @@ export function App() {
const [geometryBuffers, setGeometryBuffers] = useState<MeshGeometryBuffer[]>([]);
const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState<NonMeshGeometryChunk[]>([]);
const [gpuTextureAssets, setGPUTextureAssets] = useState<GPUTextureAsset[]>([]);
const [volumeProject, setVolumeProject] = useState<NanoVDBViewportProjectContextIR | null>(null);
const [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null);
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
@@ -441,7 +678,7 @@ export function App() {
return next;
});
setSnapshot((current) => current ? { ...current, activeObjectId: id } : current);
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined }));
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: undefined }));
};
const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => {
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
@@ -454,7 +691,7 @@ export function App() {
const next = preserve ? new Set(current.indices) : new Set<number>();
if (next.has(index)) next.delete(index);
else next.add(index);
if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined };
if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined, greasePencilPoints: undefined };
const selections = new Map<NonMeshElementKind, Set<number>>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []);
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
if (kindIndices.has(index)) kindIndices.delete(index);
@@ -463,7 +700,23 @@ export function App() {
else selections.set(nonMeshKind, kindIndices);
const combined = new Set<number>();
for (const values of selections.values()) for (const value of values) combined.add(value);
return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections };
return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections, greasePencilPoints: undefined };
});
};
const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean): void => {
const owner = snapshot?.nodes.find((node) => node.dataId === point.dataId);
if (owner) {
setSelectedObjectIds((current) => additive ? new Set([...current, owner.id]) : new Set([owner.id]));
setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current);
}
setMeshSelection((current) => {
const sameDrawing = current.greasePencilPoints?.every((selected) => selected.dataId === point.dataId && selected.layerId === point.layerId && selected.frame === point.frame) ?? false;
const points = additive && sameDrawing ? [...(current.greasePencilPoints ?? [])] : [];
const index = points.findIndex((selected) => selected.strokeIndex === point.strokeIndex && selected.pointIndex === point.pointIndex);
if (index >= 0) points.splice(index, 1);
else points.push(point);
points.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex);
return { meshId: point.dataId, mode: "VERT", indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: points };
});
};
const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise<void> => {
@@ -579,10 +832,19 @@ export function App() {
};
const setMeshSelectionMode = (mode: MeshElementMode): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined });
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: undefined });
};
const selectAllMeshElements = (): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const greasePencil = snapshot?.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId);
if (greasePencil) {
const layer = greasePencil.layers.find((candidate) => candidate.id === meshSelection.greasePencilPoints?.[0]?.layerId) ?? greasePencil.layers.find((candidate) => candidate.visible && !candidate.locked);
const frame = layer?.frames.filter((candidate) => candidate.frame <= (snapshot?.frame.current ?? 1)).sort((left, right) => right.frame - left.frame)[0];
if (!layer || !frame) return;
const points = frame.drawing.strokes.flatMap((stroke, strokeIndex) => (stroke.points ?? []).map((_point, pointIndex) => ({ dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, strokeIndex, pointIndex })));
setMeshSelection({ meshId: greasePencil.id, mode: "VERT", indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: points });
return;
}
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId);
if (!mesh) return;
const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount;
@@ -609,10 +871,37 @@ export function App() {
setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0): void => {
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0, axisVector?: [number, number, number]): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
if (!snapshot || !activeNode) return;
if (uiState.context.mode === "Edit" && activeNode.dataId) {
const greasePencil = snapshot.greasePencils?.find((candidate) => candidate.id === activeNode.dataId);
if (greasePencil && tool === "translate" && meshSelection.meshId === greasePencil.id && meshSelection.greasePencilPoints?.length) {
const selected = meshSelection.greasePencilPoints;
const first = selected[0];
const layer = greasePencil.layers.find((candidate) => candidate.id === first.layerId);
const frame = layer?.frames.find((candidate) => candidate.frame === first.frame);
if (!layer || !frame || selected.some((point) => point.dataId !== first.dataId || point.layerId !== first.layerId || point.frame !== first.frame)) return;
const delta: [number, number, number] = [0, 0, 0];
delta[axis] = amount;
try {
const result = applyGreasePencilPointTranslation({
schemaVersion: 1,
revision: snapshot.revision,
dataId: greasePencil.id,
layerId: layer.id,
frame: frame.frame,
onionSkinning: layer.onionSkinning ?? false,
selectedStrokeIndices: [...new Set(selected.map((point) => point.strokeIndex))],
selectedPoints: selected.map(({ strokeIndex, pointIndex }) => ({ strokeIndex, pointIndex })),
}, frame.drawing.strokes, { type: "TRANSLATE_POINTS", revision: snapshot.revision, translation: delta });
void applyEditCommand({ type: "setGreasePencilStrokes", dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, baseRevision: snapshot.revision, strokes: result.strokes });
}
catch (error) {
setEngineStatus(`Grease Pencil gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`);
}
return;
}
const nonMesh = snapshot?.nonMeshData?.find((candidate) => candidate.id === activeNode.dataId);
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) {
const handlePoints = nonMesh.handlePoints.slice();
@@ -630,9 +919,9 @@ export function App() {
if (handles.length === 0) return;
let applied;
try {
const delta: [number, number, number] = [0, 0, 0];
delta[axis] = amount;
applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, delta, handles }, snapshot.revision);
const delta: [number, number, number] = axisVector ? [axisVector[0] * amount, axisVector[1] * amount, axisVector[2] * amount] : [0, 0, 0];
if (!axisVector) delta[axis] = amount;
applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, axisVector, delta, handles }, snapshot.revision);
}
catch (error) {
setEngineStatus(`Curve gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`);
@@ -668,6 +957,16 @@ export function App() {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement | null;
if (event.key === "F3") {
event.preventDefault();
dispatchUI({ type: "toggleOperatorSearch", open: true });
return;
}
if (event.key === "Escape" && uiState.operatorSearchOpen) {
event.preventDefault();
dispatchUI({ type: "toggleOperatorSearch", open: false });
return;
}
if (target?.matches("input, textarea, select")) return;
const activeId = snapshot?.activeObjectId;
if (event.key === "Tab") {
@@ -701,7 +1000,7 @@ export function App() {
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [snapshot, uiState.context.mode]);
}, [snapshot, uiState.context.mode, uiState.operatorSearchOpen]);
const generateLOD = async (meshId: string, triangleCount: number): Promise<void> => {
const client = webClientRef.current;
if (!client || !snapshot || triangleCount <= 0) return;
@@ -880,6 +1179,7 @@ export function App() {
const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
projectIdRef.current = projectId;
const result = await client.openBlend(input, setOpenProgress);
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(input) });
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
@@ -911,6 +1211,7 @@ export function App() {
await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.pruneOperations(projectIdRef.current, snapshot.revision);
}
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: await sha256Hex(data) });
setSaved(true);
return data;
};
@@ -937,6 +1238,7 @@ export function App() {
buffer = saved.buffer;
}
let opened = await client.openBlend(buffer);
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(buffer) });
const replay = await storage.listOperations(projectId, baseRevision);
for (const operation of replay.operations) {
const payload = operation.payload as WebEngineEditCommand;
@@ -1027,6 +1329,17 @@ export function App() {
const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0;
const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 0) ?? 0;
const frameRange = snapshot?.frame ?? { current: frame, start: 1, end: 250 };
const operatorCommands: OperatorCommand[] = [
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] }); } }] : []),
...(snapshot?.activeObjectId ? [{ id: "object.apply-transform", label: "Apply Transform", keywords: "object location rotation scale", execute: () => { void applyEditCommand({ type: "applyObjectTransform", objectId: snapshot.activeObjectId! }); } }] : []),
...(snapshot ? [
{ id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } },
{ id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } },
{ id: "file.save", label: "Save Project", keywords: "file blend", execute: () => { void saveBlend(); } },
] : []),
];
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
@@ -1039,14 +1352,14 @@ export function App() {
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={reportGLBExport}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept=".blend,application/octet-stream" data-testid="blend-file-input" onChange={(event) => { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
</header>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onTransform={transformActive} /></Area>
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onTransform={transformActive} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilPointSelection={meshSelection.greasePencilPoints ?? []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Blender Web 0.1.0</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);

View File

@@ -42,7 +42,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; }
.viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; }
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; }
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; pointer-events: none; }
.viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; }
.viewport-message strong { color: #f3f4f6; font-size: 15px; }
.viewport-message span { color: #969ba4; }
@@ -50,6 +50,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; }
.viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; }
.transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; }
.transform-gizmo.handle-local::before { position: absolute; left: 42px; top: 42px; width: 8px; height: 8px; border: 1px solid #fff; border-radius: 50%; background: #24262b; box-shadow: 0 0 0 2px #24262baa; content: ""; }
.gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; }
.gizmo-axis.axis-x { left: 60px; top: 32px; color: #e35b55; }.gizmo-axis.axis-y { left: 32px; top: 4px; color: #65bb70; }.gizmo-axis.axis-z { left: 32px; top: 60px; color: #5d8ee8; }
.viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; }

View File

@@ -0,0 +1,15 @@
export {
CompositorFrameCache,
CompositorValidationError,
compositorFrameCacheKey,
executeCompositorGraph,
executeCompositorGraphCached,
gateCompositorGraph,
parseCompositorGraph,
} from "../../../protocol/compositor";
export type {
CompositorCachedExecutionResult,
CompositorExecutionResult,
CompositorGraphIR,
CompositorImageBuffer,
} from "../../../protocol/compositor";

View File

@@ -0,0 +1 @@
export * from "../../../protocol/render-assets";

View File

@@ -0,0 +1,615 @@
import type { NanoVDBGridIR, NanoVDBMaterialIR } from "../../../protocol/volume-vdb";
export interface NanoVDBWebGPUCapabilityIR {
available: boolean;
reason?: string;
maxStorageBufferBindingSize?: number;
maxBufferSize?: number;
}
export interface NanoVDBWebGPUGrid {
buffer: GPUBuffer;
pageTable: GPUBuffer;
byteLength: number;
pageByteLength: number;
pageCount: number;
residentPageCount: number;
residentPageCapacity: number;
paged: boolean;
residentVirtualPages: readonly number[];
uploadPage(pageIndex: number, data?: ArrayBuffer): void;
evictPage(pageIndex: number): void;
hasResidentPage(pageIndex: number): boolean;
dispose(): void;
}
export interface NanoVDBMaterialGridUploadsIR {
temperature?: NanoVDBWebGPUGrid;
color?: NanoVDBWebGPUGrid;
emission?: NanoVDBWebGPUGrid;
}
export interface NanoVDBGpuPageAllocatorStatsIR {
residentBytes: number;
maxResidentBytes: number;
residentPages: number;
evictions: number;
keys: string[];
}
export interface NanoVDBDeviceLossIR { reason?: string; message: string }
const traversalWGSL = /* wgsl */`
fn in_range(byte_offset: u32, byte_length: u32) -> bool {
return byte_offset <= params.data_bytes && byte_length <= params.data_bytes - byte_offset;
}
fn word(byte_offset: u32) -> u32 {
if ((byte_offset & 3u) != 0u || !in_range(byte_offset, 4u)) { return 0u; }
if (params.paged == 0u) { return grid[byte_offset >> 2u]; }
if (params.page_bytes == 0u) { return 0u; }
let page = byte_offset / params.page_bytes;
if (page >= params.page_count) { return 0u; }
let slot = page_table[page];
if (slot == 0xffffffffu || slot >= params.resident_pages) { return 0u; }
let physical = slot * params.page_bytes + (byte_offset % params.page_bytes);
if (physical > params.resident_pages * params.page_bytes - 4u) { return 0u; }
return grid[physical >> 2u];
}
fn scalar(byte_offset: u32) -> f32 { return bitcast<f32>(word(byte_offset)); }
fn mask_on(byte_offset: u32, index: u32) -> bool {
let address = byte_offset + (index >> 5u) * 4u;
return in_range(address, 4u) && (word(address) & (1u << (index & 31u))) != 0u;
}
fn valid_grid() -> bool {
return params.data_bytes >= 736u && word(0u) == 0x6f6e614eu && word(4u) == 0x31424456u &&
(word(16u) >> 21u) == 32u && word(32u) == params.data_bytes && word(36u) == 0u;
}
fn root_key(coord: vec3<i32>) -> vec2<u32> {
let x = bitcast<u32>(coord.x) >> 12u;
let y = bitcast<u32>(coord.y) >> 12u;
let z = bitcast<u32>(coord.z) >> 12u;
return vec2<u32>(z | ((y & 0x7ffu) << 21u), (y >> 11u) | (x << 10u));
}
fn key_less(a: vec2<u32>, b: vec2<u32>) -> bool { return a.y < b.y || (a.y == b.y && a.x < b.x); }
fn child_address(parent: u32, offset_address: u32, child_bytes: u32) -> u32 {
let low = word(offset_address);
let high = word(offset_address + 4u);
if (low == 0u || high != 0u || low > params.data_bytes || parent > params.data_bytes - low) { return 0xffffffffu; }
let child = parent + low;
if (!in_range(child, child_bytes)) { return 0xffffffffu; }
return child;
}
fn sample_density(coord: vec3<i32>) -> vec2<f32> {
if (!valid_grid()) { return vec2<f32>(0.0, -1.0); }
let tree = 672u;
let root = child_address(tree, tree + 24u, 64u);
if (root == 0xffffffffu) { return vec2<f32>(0.0, -1.0); }
let count = word(root + 24u);
if (count > (params.data_bytes - root - 64u) / 32u) { return vec2<f32>(0.0, -1.0); }
let wanted = root_key(coord);
var low = 0u;
var high = count;
var tile = 0xffffffffu;
for (var iteration = 0u; iteration < 32u && low < high; iteration++) {
let middle = low + (high - low) / 2u;
let address = root + 64u + middle * 32u;
let candidate = vec2<u32>(word(address), word(address + 4u));
if (all(candidate == wanted)) { tile = address; break; }
if (key_less(wanted, candidate)) { low = middle + 1u; } else { high = middle; }
}
if (tile == 0xffffffffu) { return vec2<f32>(scalar(root + 28u), 0.0); }
let root_child_low = word(tile + 8u);
let root_child_high = word(tile + 12u);
if (root_child_low == 0u && root_child_high == 0u) { return vec2<f32>(scalar(tile + 20u), select(0.0, 1.0, word(tile + 16u) != 0u)); }
let upper = child_address(root, tile + 8u, 270400u);
if (upper == 0xffffffffu) { return vec2<f32>(0.0, -1.0); }
let ux = (bitcast<u32>(coord.x) & 4095u) >> 7u;
let uy = (bitcast<u32>(coord.y) & 4095u) >> 7u;
let uz = (bitcast<u32>(coord.z) & 4095u) >> 7u;
let upper_index = (ux << 10u) | (uy << 5u) | uz;
if (!mask_on(upper + 4128u, upper_index)) { return vec2<f32>(scalar(upper + 8256u + upper_index * 8u), select(0.0, 1.0, mask_on(upper + 32u, upper_index))); }
let lower = child_address(upper, upper + 8256u + upper_index * 8u, 33856u);
if (lower == 0xffffffffu) { return vec2<f32>(0.0, -1.0); }
let lx = (bitcast<u32>(coord.x) & 127u) >> 3u;
let ly = (bitcast<u32>(coord.y) & 127u) >> 3u;
let lz = (bitcast<u32>(coord.z) & 127u) >> 3u;
let lower_index = (lx << 8u) | (ly << 4u) | lz;
if (!mask_on(lower + 544u, lower_index)) { return vec2<f32>(scalar(lower + 1088u + lower_index * 8u), select(0.0, 1.0, mask_on(lower + 32u, lower_index))); }
let leaf = child_address(lower, lower + 1088u + lower_index * 8u, 2144u);
if (leaf == 0xffffffffu) { return vec2<f32>(0.0, -1.0); }
let voxel = ((bitcast<u32>(coord.x) & 7u) << 6u) | ((bitcast<u32>(coord.y) & 7u) << 3u) | (bitcast<u32>(coord.z) & 7u);
return vec2<f32>(scalar(leaf + 96u + voxel * 4u), select(0.0, 1.0, mask_on(leaf + 16u, voxel)));
}
fn sample_density_linear(position: vec3<f32>) -> vec2<f32> {
let base = vec3<i32>(floor(position));
let fraction = position - vec3<f32>(base);
var value = 0.0;
var activity = 0.0;
for (var x = 0i; x < 2i; x += 1i) {
for (var y = 0i; y < 2i; y += 1i) {
for (var z = 0i; z < 2i; z += 1i) {
let sample = sample_density(base + vec3<i32>(x, y, z));
if (sample.y < 0.0) { return vec2<f32>(0.0, -1.0); }
let offset = vec3<f32>(f32(x), f32(y), f32(z));
let weight3 = select(vec3<f32>(1.0) - fraction, fraction, offset == vec3<f32>(1.0));
value += sample.x * weight3.x * weight3.y * weight3.z;
activity = max(activity, sample.y);
}
}
}
return vec2<f32>(value, activity);
}
`;
function specializeFloatTraversal(prefix: string, gridName: string, pageTableName: string, parameterPrefix: string): string {
let source = traversalWGSL
.replaceAll("grid[", `${gridName}[`)
.replaceAll("page_table[page]", pageTableName ? `${pageTableName}[page]` : "page")
.replaceAll("params.data_bytes", `params.${parameterPrefix}_data_bytes`)
.replaceAll("params.page_bytes", `params.${parameterPrefix}_page_bytes`)
.replaceAll("params.page_count", `params.${parameterPrefix}_page_count`)
.replaceAll("params.resident_pages", `params.${parameterPrefix}_resident_pages`)
.replaceAll("params.paged", `params.${parameterPrefix}_paged`);
for (const name of ["sample_density_linear", "sample_density", "child_address", "valid_grid", "root_key", "key_less", "in_range", "mask_on", "scalar", "word"]) {
source = source.replace(new RegExp(`\\b${name}\\b`, "g"), `${prefix}_${name}`);
}
return source;
}
const temperatureTraversalWGSL = specializeFloatTraversal("temperature", "temperature_grid", "", "temperature");
const emissionTraversalWGSL = specializeFloatTraversal("emission", "emission_grid", "", "emission");
const vec3TraversalWGSL = /* wgsl */`
fn color_in_range(byte_offset: u32, byte_length: u32) -> bool {
return byte_offset <= params.color_data_bytes && byte_length <= params.color_data_bytes - byte_offset;
}
fn color_word(byte_offset: u32) -> u32 {
if ((byte_offset & 3u) != 0u || !color_in_range(byte_offset, 4u) || params.color_page_bytes == 0u) { return 0u; }
let page = byte_offset / params.color_page_bytes;
if (page >= params.color_page_count) { return 0u; }
let slot = page;
if (slot == 0xffffffffu || slot >= params.color_resident_pages) { return 0u; }
let physical = slot * params.color_page_bytes + (byte_offset % params.color_page_bytes);
if (physical > params.color_resident_pages * params.color_page_bytes - 4u) { return 0u; }
return color_grid[physical >> 2u];
}
fn color_scalar(byte_offset: u32) -> f32 { return bitcast<f32>(color_word(byte_offset)); }
fn color_vec3(byte_offset: u32) -> vec3<f32> { return vec3<f32>(color_scalar(byte_offset), color_scalar(byte_offset + 4u), color_scalar(byte_offset + 8u)); }
fn color_mask_on(byte_offset: u32, index: u32) -> bool { return (color_word(byte_offset + (index >> 5u) * 4u) & (1u << (index & 31u))) != 0u; }
fn color_valid_grid() -> bool {
return params.color_data_bytes >= 768u && color_word(0u) == 0x6f6e614eu && color_word(4u) == 0x31424456u &&
(color_word(16u) >> 21u) == 32u && color_word(32u) == params.color_data_bytes && color_word(36u) == 0u;
}
fn color_root_key(coord: vec3<i32>) -> vec2<u32> {
let x = bitcast<u32>(coord.x) >> 12u; let y = bitcast<u32>(coord.y) >> 12u; let z = bitcast<u32>(coord.z) >> 12u;
return vec2<u32>(z | ((y & 0x7ffu) << 21u), (y >> 11u) | (x << 10u));
}
fn color_key_less(a: vec2<u32>, b: vec2<u32>) -> bool { return a.y < b.y || (a.y == b.y && a.x < b.x); }
fn color_child_address(parent: u32, offset_address: u32, child_bytes: u32) -> u32 {
let low = color_word(offset_address); let high = color_word(offset_address + 4u);
if (low == 0u || high != 0u || low > params.color_data_bytes || parent > params.color_data_bytes - low) { return 0xffffffffu; }
let child = parent + low; if (!color_in_range(child, child_bytes)) { return 0xffffffffu; } return child;
}
fn sample_color(coord: vec3<i32>) -> vec4<f32> {
if (!color_valid_grid()) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let tree = 672u; let root = color_child_address(tree, tree + 24u, 96u);
if (root == 0xffffffffu) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let count = color_word(root + 24u);
if (count > (params.color_data_bytes - root - 96u) / 32u) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let wanted = color_root_key(coord); var low = 0u; var high = count; var tile = 0xffffffffu;
for (var iteration = 0u; iteration < 32u && low < high; iteration++) {
let middle = low + (high - low) / 2u; let address = root + 96u + middle * 32u;
let candidate = vec2<u32>(color_word(address), color_word(address + 4u));
if (all(candidate == wanted)) { tile = address; break; }
if (color_key_less(wanted, candidate)) { low = middle + 1u; } else { high = middle; }
}
if (tile == 0xffffffffu) { return vec4<f32>(0.0); }
let root_child_low = color_word(tile + 8u); let root_child_high = color_word(tile + 12u);
if (root_child_low == 0u && root_child_high == 0u) { return vec4<f32>(color_vec3(tile + 20u), select(0.0, 1.0, color_word(tile + 16u) != 0u)); }
let upper = color_child_address(root, tile + 8u, 532544u); if (upper == 0xffffffffu) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let upper_index = (((bitcast<u32>(coord.x) & 4095u) >> 7u) << 10u) | (((bitcast<u32>(coord.y) & 4095u) >> 7u) << 5u) | ((bitcast<u32>(coord.z) & 4095u) >> 7u);
if (!color_mask_on(upper + 4128u, upper_index)) { return vec4<f32>(color_vec3(upper + 8256u + upper_index * 16u), select(0.0, 1.0, color_mask_on(upper + 32u, upper_index))); }
let lower = color_child_address(upper, upper + 8256u + upper_index * 16u, 66624u); if (lower == 0xffffffffu) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let lower_index = (((bitcast<u32>(coord.x) & 127u) >> 3u) << 8u) | (((bitcast<u32>(coord.y) & 127u) >> 3u) << 4u) | ((bitcast<u32>(coord.z) & 127u) >> 3u);
if (!color_mask_on(lower + 544u, lower_index)) { return vec4<f32>(color_vec3(lower + 1088u + lower_index * 16u), select(0.0, 1.0, color_mask_on(lower + 32u, lower_index))); }
let leaf = color_child_address(lower, lower + 1088u + lower_index * 16u, 6272u); if (leaf == 0xffffffffu) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let voxel = ((bitcast<u32>(coord.x) & 7u) << 6u) | ((bitcast<u32>(coord.y) & 7u) << 3u) | (bitcast<u32>(coord.z) & 7u);
return vec4<f32>(color_vec3(leaf + 128u + voxel * 12u), select(0.0, 1.0, color_mask_on(leaf + 16u, voxel)));
}
fn sample_color_linear(position: vec3<f32>) -> vec4<f32> {
let base = vec3<i32>(floor(position)); let fraction = position - vec3<f32>(base); var value = vec3<f32>(0.0); var activity = 0.0;
for (var x = 0i; x < 2i; x += 1i) { for (var y = 0i; y < 2i; y += 1i) { for (var z = 0i; z < 2i; z += 1i) {
let sample = sample_color(base + vec3<i32>(x, y, z)); if (sample.w < 0.0) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let offset = vec3<f32>(f32(x), f32(y), f32(z)); let weight3 = select(vec3<f32>(1.0) - fraction, fraction, offset == vec3<f32>(1.0));
value += sample.xyz * weight3.x * weight3.y * weight3.z; activity = max(activity, sample.w);
}}}
return vec4<f32>(value, activity);
}
`;
export async function probeNanoVDBWebGPU(requiredBytes = 1): Promise<{ capability: NanoVDBWebGPUCapabilityIR; adapter?: GPUAdapter; device?: GPUDevice }> {
if (!navigator.gpu) return { capability: { available: false, reason: "WebGPU is unavailable" } };
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) return { capability: { available: false, reason: "No WebGPU adapter is available" } };
const maxStorageBufferBindingSize = Number(adapter.limits.maxStorageBufferBindingSize);
const maxBufferSize = Number(adapter.limits.maxBufferSize);
if (requiredBytes > maxStorageBufferBindingSize || requiredBytes > maxBufferSize) return { capability: { available: false, reason: "NanoVDB grid exceeds WebGPU adapter limits", maxStorageBufferBindingSize, maxBufferSize } };
const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: requiredBytes, maxBufferSize: requiredBytes } });
return { capability: { available: true, maxStorageBufferBindingSize, maxBufferSize }, adapter, device };
}
export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer): NanoVDBWebGPUGrid {
if (payload.byteLength === 0 || payload.byteLength % 32 !== 0 || payload.byteLength > device.limits.maxStorageBufferBindingSize || payload.byteLength > device.limits.maxBufferSize) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: grid payload cannot be uploaded");
const buffer = device.createBuffer({ label: "NanoVDB Float32 grid", size: payload.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Uint8Array(buffer.getMappedRange()).set(new Uint8Array(payload));
buffer.unmap();
const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Uint32Array(pageTable.getMappedRange())[0] = 0;
pageTable.unmap();
return {
buffer,
pageTable,
byteLength: payload.byteLength,
pageByteLength: payload.byteLength,
pageCount: 1,
residentPageCount: 1,
residentPageCapacity: 1,
paged: false,
residentVirtualPages: [0],
uploadPage: (pageIndex, data) => {
if (pageIndex !== 0 || (data && data.byteLength !== payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: direct NanoVDB grid has one immutable page");
},
evictPage: () => { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: direct NanoVDB grid cannot evict its only page"); },
hasResidentPage: (pageIndex) => pageIndex === 0,
dispose: () => { buffer.destroy(); pageTable.destroy(); },
};
}
export function uploadNanoVDBFloat32GridPaged(
device: GPUDevice,
payload: ArrayBuffer,
pageByteLength: number,
maxResidentBytes: number,
): NanoVDBWebGPUGrid {
if (payload.byteLength === 0 || payload.byteLength % 32 !== 0 || !Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged grid layout");
}
if (!Number.isSafeInteger(maxResidentBytes) || maxResidentBytes < pageByteLength) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged resident budget");
}
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
const residentPageCount = Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageByteLength)));
const physicalBytes = residentPageCount * pageByteLength;
if (pageCount > 8192 || physicalBytes > device.limits.maxStorageBufferBindingSize || physicalBytes > device.limits.maxBufferSize) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged grid exceeds the resident or adapter budget");
}
const buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
const pageTableBytes = Math.max(4, pageCount * 4);
const pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
const table = new Uint32Array(pageTable.getMappedRange());
table.fill(0xffffffff);
pageTable.unmap();
const resident = new Map<number, number>();
const upload = (pageIndex: number, data = payload.slice(pageIndex * pageByteLength, Math.min(payload.byteLength, (pageIndex + 1) * pageByteLength))): void => {
if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount || data.byteLength === 0 || data.byteLength > pageByteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB page is outside the virtual grid");
}
const existingSlot = resident.get(pageIndex);
const slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate));
if (slot === undefined) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available");
device.queue.writeBuffer(buffer, slot * pageByteLength, data);
table[pageIndex] = slot;
device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot]));
resident.set(pageIndex, slot);
};
const residentHasSlot = (slot: number): boolean => {
for (const current of resident.values()) if (current === slot) return true;
return false;
};
const initialPages = Math.min(pageCount, residentPageCount);
for (let page = 0; page < initialPages; page++) upload(page);
return {
buffer,
pageTable,
byteLength: payload.byteLength,
pageByteLength,
pageCount,
residentPageCount: resident.size,
residentPageCapacity: residentPageCount,
paged: true,
get residentVirtualPages() { return [...resident.keys()].sort((a, b) => a - b); },
uploadPage: upload,
evictPage: (pageIndex) => {
if (!resident.delete(pageIndex)) return;
table[pageIndex] = 0xffffffff;
device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([0xffffffff]));
},
hasResidentPage: (pageIndex) => resident.has(pageIndex),
dispose: () => { resident.clear(); buffer.destroy(); pageTable.destroy(); },
};
}
export class NanoVDBGpuPageAllocator {
private readonly pages = new Map<string, { buffer: GPUBuffer; bytes: number; used: number }>();
private clock = 0;
private evictions = 0;
constructor(
private readonly device: GPUDevice,
readonly pageByteLength: number,
readonly maxResidentBytes: number,
) {
if (!Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0 ||
!Number.isSafeInteger(maxResidentBytes) || maxResidentBytes < pageByteLength) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid GPU page allocator budget");
}
}
upload(key: string, data: ArrayBuffer): GPUBuffer {
if (!key || data.byteLength === 0 || data.byteLength > this.pageByteLength) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid GPU page");
const existing = this.pages.get(key);
if (existing) { existing.used = ++this.clock; return existing.buffer; }
while (this.residentBytes() + this.pageByteLength > this.maxResidentBytes) this.evictOldest();
const buffer = this.device.createBuffer({ label: `NanoVDB page ${key}`, size: this.pageByteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Uint8Array(buffer.getMappedRange()).set(new Uint8Array(data));
buffer.unmap();
this.pages.set(key, { buffer, bytes: this.pageByteLength, used: ++this.clock });
return buffer;
}
touch(key: string): boolean {
const page = this.pages.get(key);
if (!page) return false;
page.used = ++this.clock;
return true;
}
has(key: string): boolean { return this.pages.has(key); }
stats(): NanoVDBGpuPageAllocatorStatsIR {
return { residentBytes: this.residentBytes(), maxResidentBytes: this.maxResidentBytes, residentPages: this.pages.size, evictions: this.evictions, keys: [...this.pages.keys()].sort() };
}
dispose(): void {
for (const page of this.pages.values()) page.buffer.destroy();
this.pages.clear();
}
private residentBytes(): number { return [...this.pages.values()].reduce((sum, page) => sum + page.bytes, 0); }
private evictOldest(): void {
const oldest = [...this.pages].sort((left, right) => left[1].used - right[1].used || left[0].localeCompare(right[0]))[0];
if (!oldest) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no GPU page can be evicted");
oldest[1].buffer.destroy();
this.pages.delete(oldest[0]);
this.evictions++;
}
}
export class NanoVDBWebGPUDeviceSession {
device?: GPUDevice;
generation = 0;
status: "idle" | "ready" | "lost" | "disposed" = "idle";
private loss?: Promise<NanoVDBDeviceLossIR>;
private readonly lossListeners = new Set<(loss: NanoVDBDeviceLossIR) => void>();
onDeviceLost(listener: (loss: NanoVDBDeviceLossIR) => void): () => void {
this.lossListeners.add(listener);
return () => this.lossListeners.delete(listener);
}
async open(requiredBytes: number): Promise<GPUDevice> {
if (this.status === "disposed") throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session is disposed");
const probe = await probeNanoVDBWebGPU(requiredBytes);
if (!probe.capability.available || !probe.device) throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU unavailable"}`);
this.device = probe.device;
this.generation++;
this.status = "ready";
this.loss = probe.device.lost.then((info: NanoVDBDeviceLossIR) => {
if (this.device === probe.device && this.status !== "disposed") this.status = "lost";
for (const listener of this.lossListeners) listener(info);
return info;
});
return probe.device;
}
async waitForLoss(): Promise<NanoVDBDeviceLossIR> {
if (!this.loss) throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session has not opened");
return this.loss;
}
async recover(requiredBytes: number): Promise<GPUDevice> {
this.device?.destroy();
return this.open(requiredBytes);
}
dispose(): void {
this.status = "disposed";
this.device?.destroy();
this.device = undefined;
}
}
function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer {
const buffer = device.createBuffer({ size: Math.max(16, values.byteLength), usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(buffer, 0, values);
return buffer;
}
export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, coordinates: Array<readonly [number, number, number]>): Promise<Array<{ value: number; active: boolean; valid: boolean }>> {
if (coordinates.length < 1 || coordinates.length > 4096) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: sample count");
const coordinateData = new Int32Array(coordinates.length * 4);
coordinates.forEach((coord, index) => coordinateData.set(coord, index * 4));
const coordinateBuffer = device.createBuffer({ size: coordinateData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(coordinateBuffer, 0, coordinateData);
const resultBytes = coordinates.length * 16;
const resultBuffer = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const readback = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const params = paramsBuffer(device, new Uint32Array([uploaded.byteLength, coordinates.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0]));
const module = device.createShaderModule({ label: "NanoVDB Float32 sampler", code: /* wgsl */`
struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32 }
@group(0) @binding(0) var<storage, read> grid: array<u32>;
@group(0) @binding(1) var<storage, read> coords: array<vec4<i32>>;
@group(0) @binding(2) var<storage, read_write> results: array<vec4<f32>>;
@group(0) @binding(3) var<uniform> params: Params;
@group(0) @binding(4) var<storage, read> page_table: array<u32>;
${traversalWGSL}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= params.count) { return; }
let sample = sample_density(coords[id.x].xyz);
results[id.x] = vec4<f32>(sample.x, sample.y, 0.0, 0.0);
}` });
const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: coordinateBuffer } },
{ binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } },
{ binding: 4, resource: { buffer: uploaded.pageTable } },
] });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(coordinates.length / 64)); pass.end();
encoder.copyBufferToBuffer(resultBuffer, 0, readback, 0, resultBytes);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const values = new Float32Array(readback.getMappedRange().slice(0));
readback.unmap();
coordinateBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy();
return coordinates.map((_coord, index) => ({ value: values[index * 4], active: values[index * 4 + 1] > 0.5, valid: values[index * 4 + 1] >= 0 }));
}
export async function renderNanoVDBFloat32WebGPU(
device: GPUDevice,
uploaded: NanoVDBWebGPUGrid,
gridDefinition: NanoVDBGridIR,
material: NanoVDBMaterialIR,
width = 96,
height = 96,
materialGrids: NanoVDBMaterialGridUploadsIR = {},
): Promise<Uint8Array> {
if (gridDefinition.valueType !== "FLOAT32" || width < 1 || height < 1 || width > 2048 || height > 2048) throw new Error("NANOVDB_GRID_UNSUPPORTED: bounded Float32 render input required");
const outputBytes = width * height * 4;
const output = device.createBuffer({ size: outputBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const readback = device.createBuffer({ size: outputBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const paramsData = new ArrayBuffer(224);
const u32 = new Uint32Array(paramsData);
const i32 = new Int32Array(paramsData);
const f32 = new Float32Array(paramsData);
u32.set([uploaded.byteLength, material.interpolation === "LINEAR" ? 1 : 0, width, height], 0);
u32.set([uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0], 4);
const uploadFields = (grid: NanoVDBWebGPUGrid | undefined): [number, number, number, number, number, number, number, number] => grid
? [grid.byteLength, grid.pageByteLength, grid.pageCount, grid.residentPageCapacity, grid.paged ? 1 : 0, 0, 0, 0]
: [0, 0, 0, 0, 0, 0, 0, 0];
u32.set(uploadFields(materialGrids.temperature), 8);
u32.set(uploadFields(materialGrids.color), 16);
u32.set(uploadFields(materialGrids.emission), 24);
i32.set([...gridDefinition.indexBounds.min, 0], 32);
i32.set([...gridDefinition.indexBounds.max, 0], 36);
f32.set([material.densityScale, material.emissionScale, material.anisotropy, Math.max(0.01, gridDefinition.voxelSize[2])], 40);
f32.set([...(material.color ?? [0.72, 0.78, 0.86]), 1], 44);
f32.set([...(material.emissionColor ?? [1, 1, 1]), 1], 48);
f32.set([materialGrids.temperature ? 1 : 0, materialGrids.color ? 1 : 0, materialGrids.emission ? 1 : 0, material.temperatureScale], 52);
const params = device.createBuffer({ size: paramsData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(params, 0, paramsData);
const temperatureSource = materialGrids.temperature ? temperatureTraversalWGSL : /* wgsl */`
fn temperature_sample_density(coord: vec3<i32>) -> vec2<f32> { return vec2<f32>(0.0); }
fn temperature_sample_density_linear(position: vec3<f32>) -> vec2<f32> { return vec2<f32>(0.0); }
`;
const colorSource = materialGrids.color ? vec3TraversalWGSL : /* wgsl */`
fn sample_color(coord: vec3<i32>) -> vec4<f32> { return vec4<f32>(0.0); }
fn sample_color_linear(position: vec3<f32>) -> vec4<f32> { return vec4<f32>(0.0); }
`;
const emissionSource = materialGrids.emission ? emissionTraversalWGSL : /* wgsl */`
fn emission_sample_density(coord: vec3<i32>) -> vec2<f32> { return vec2<f32>(0.0); }
fn emission_sample_density_linear(position: vec3<f32>) -> vec2<f32> { return vec2<f32>(0.0); }
`;
const module = device.createShaderModule({ label: "NanoVDB bounded volume integrator", code: /* wgsl */`
struct Params {
data_bytes: u32, interpolation: u32, width: u32, height: u32,
page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32,
temperature_data_bytes: u32, temperature_page_bytes: u32, temperature_page_count: u32, temperature_resident_pages: u32,
temperature_paged: u32, temperature_pad0: u32, temperature_pad1: u32, temperature_pad2: u32,
color_data_bytes: u32, color_page_bytes: u32, color_page_count: u32, color_resident_pages: u32,
color_paged: u32, color_pad0: u32, color_pad1: u32, color_pad2: u32,
emission_data_bytes: u32, emission_page_bytes: u32, emission_page_count: u32, emission_resident_pages: u32,
emission_paged: u32, emission_pad0: u32, emission_pad1: u32, emission_pad2: u32,
index_min: vec4<i32>, index_max: vec4<i32>, material: vec4<f32>, color: vec4<f32>, emission_color: vec4<f32>, material_grids: vec4<f32>
}
@group(0) @binding(0) var<storage, read> grid: array<u32>;
@group(0) @binding(1) var<storage, read_write> pixels: array<u32>;
@group(0) @binding(2) var<uniform> params: Params;
@group(0) @binding(3) var<storage, read> page_table: array<u32>;
@group(0) @binding(4) var<storage, read> temperature_grid: array<u32>;
@group(0) @binding(5) var<storage, read> color_grid: array<u32>;
@group(0) @binding(6) var<storage, read> emission_grid: array<u32>;
${traversalWGSL}
${temperatureSource}
${colorSource}
${emissionSource}
fn blackbody_color(kelvin: f32) -> vec3<f32> {
let t = smoothstep(800.0, 12000.0, clamp(kelvin, 800.0, 12000.0));
return mix(vec3<f32>(1.0, 0.11, 0.015), vec3<f32>(0.62, 0.8, 1.0), t);
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= params.width || id.y >= params.height) { return; }
let extent = vec2<f32>(params.index_max.xy - params.index_min.xy + vec2<i32>(1));
let uv = (vec2<f32>(id.xy) + vec2<f32>(0.5)) / vec2<f32>(f32(params.width), f32(params.height));
let xy_position = vec2<f32>(params.index_min.xy) + uv * extent - vec2<f32>(0.5);
let xy = vec2<i32>(round(xy_position));
let z_count = max(1, params.index_max.z - params.index_min.z + 1);
let stride = max(1, (z_count + 255) / 256);
let g = clamp(params.material.z, -0.99, 0.99);
let phase = (1.0 - g * g) / (12.5663706 * pow(max(0.0001, 1.0 + g * g), 1.5));
var transmittance = 1.0;
var radiance = vec3<f32>(0.0);
for (var z = params.index_min.z; z <= params.index_max.z; z += stride) {
var sample = sample_density(vec3<i32>(xy, z));
if (params.interpolation == 1u) {
sample = sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5));
}
if (sample.y < 0.0) { radiance = vec3<f32>(1.0, 0.0, 1.0); transmittance = 0.0; break; }
let density = max(0.0, sample.x) * params.material.x;
let alpha = 1.0 - exp(-density * params.material.w * f32(stride));
var scattering_color = params.color.rgb;
if (params.material_grids.y > 0.5) {
var color_sample = sample_color(vec3<i32>(xy, z));
if (params.interpolation == 1u) { color_sample = sample_color_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
if (color_sample.w >= 0.0 && color_sample.w > 0.5) { scattering_color = max(vec3<f32>(0.0), color_sample.xyz); }
}
var emitted = params.emission_color.rgb * params.material.y;
if (params.material_grids.x > 0.5 && params.material.y > 0.0) {
var temperature_sample = temperature_sample_density(vec3<i32>(xy, z));
if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
if (temperature_sample.y > 0.5) { emitted += blackbody_color(temperature_sample.x * params.material_grids.w) * params.material.y; }
}
if (params.material_grids.z > 0.5 && params.material.y > 0.0) {
var emission_sample = emission_sample_density(vec3<i32>(xy, z));
if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
if (emission_sample.y > 0.5) { emitted += params.emission_color.rgb * max(0.0, emission_sample.x) * params.material.y; }
}
let source = scattering_color * (0.5 + 8.0 * phase) + emitted;
radiance += transmittance * alpha * source;
transmittance *= 1.0 - alpha;
if (transmittance < 0.005) { break; }
}
pixels[id.y * params.width + id.x] = pack4x8unorm(vec4<f32>(clamp(radiance, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0 - transmittance));
}` });
const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
const entries: Array<{ binding: number; resource: { buffer: GPUBuffer } }> = [
{ binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: output } }, { binding: 2, resource: { buffer: params } },
{ binding: 3, resource: { buffer: uploaded.pageTable } },
];
if (materialGrids.temperature) entries.push({ binding: 4, resource: { buffer: materialGrids.temperature.buffer } });
if (materialGrids.color) entries.push({ binding: 5, resource: { buffer: materialGrids.color.buffer } });
if (materialGrids.emission) entries.push({ binding: 6, resource: { buffer: materialGrids.emission.buffer } });
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8)); pass.end();
encoder.copyBufferToBuffer(output, 0, readback, 0, outputBytes);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const pixels = new Uint8Array(readback.getMappedRange().slice(0));
readback.unmap(); output.destroy(); readback.destroy(); params.destroy();
return pixels;
}

View File

@@ -0,0 +1,14 @@
export {
applySequencerEdit,
gateSequencerCodec,
parseSequencerTimeline,
resolveSequencerFrame,
resolveSequencerTransitionFrame,
sequencerRuntimeCapabilities,
sequencerSourceFrame,
} from "../../../protocol/sequencer";
export type {
SequencerFrameStripIR,
SequencerTimelineIR,
SequencerTransitionFrameIR,
} from "../../../protocol/sequencer";

View File

@@ -0,0 +1,8 @@
export {
applyBrowserTransformCachePreview,
BrowserTransformCachePlaybackSession,
} from "../../../protocol/physics-cache-playback";
export type {
BrowserTransformCacheFrameSource,
BrowserTransformCachePlaybackResult,
} from "../../../protocol/physics-cache-playback";

View File

@@ -5,6 +5,8 @@ import {
Group,
Line,
LineBasicMaterial,
Points,
PointsMaterial,
type Object3D,
} from "../vendor/three/three.module.js";
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
@@ -12,9 +14,26 @@ import type { SceneNodeIR } from "../../../protocol/scene-ir";
interface DrawingPreview {
drawing: GreasePencilDrawingIR;
frame: number;
onion: "NONE" | "PREVIOUS" | "NEXT";
}
export interface GreasePencilPointRef {
dataId: string;
layerId: string;
frame: number;
strokeIndex: number;
pointIndex: number;
}
export interface GreasePencilPointPreview extends GreasePencilPointRef {
position: [number, number, number];
}
function blenderPosition(position: readonly number[]): [number, number, number] {
return [position[0], position[2], -position[1]];
}
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
let selected: GreasePencilFrameIR | undefined;
for (const candidate of frames) {
@@ -26,20 +45,26 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
const current = activeFrame(layer.frames, frame);
if (!current) return [];
const result: DrawingPreview[] = [{ drawing: current.drawing, onion: "NONE" }];
const result: DrawingPreview[] = [{ drawing: current.drawing, frame: current.frame, onion: "NONE" }];
if (!layer.onionSkinning) return result;
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.frame);
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, onion: "PREVIOUS" });
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, onion: "NEXT" });
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, frame: sorted[currentIndex - 1].frame, onion: "PREVIOUS" });
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, frame: sorted[currentIndex + 1].frame, onion: "NEXT" });
return result;
}
function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
function pointKey(point: GreasePencilPointRef): string {
return `${point.dataId}\u0000${point.layerId}\u0000${point.frame}\u0000${point.strokeIndex}\u0000${point.pointIndex}`;
}
function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
let count = 0;
for (const stroke of preview.drawing.strokes) {
if (!stroke.points || stroke.points.length < 2) continue;
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
for (let strokeIndex = 0; strokeIndex < preview.drawing.strokes.length; strokeIndex++) {
const stroke = preview.drawing.strokes[strokeIndex];
if (!stroke.points || stroke.points.length === 0) continue;
const linePointCount = stroke.points.length + (stroke.cyclic && stroke.points.length > 1 ? 1 : 0);
const pointCount = Math.max(stroke.points.length, linePointCount);
const positions = new Float32Array(pointCount * 3);
let red = 0;
let green = 0;
@@ -59,19 +84,42 @@ function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPr
opacity += point.opacity * color[3];
}
const divisor = stroke.points.length;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null;
const material = new LineBasicMaterial({
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
transparent: true,
depthWrite: preview.onion === "NONE",
});
const line = new Line(geometry, material);
line.userData.greasePencilOnion = preview.onion;
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
group.add(line);
let strokeObject: Line | null = null;
if (stroke.points.length > 1) {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, linePointCount * 3), 3));
const material = new LineBasicMaterial({
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
transparent: true,
depthWrite: preview.onion === "NONE",
});
const line = new Line(geometry, material);
line.userData.greasePencilOnion = preview.onion;
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
line.userData.greasePencilPreviewDataId = dataId;
line.userData.greasePencilPreviewLayerId = layer.id;
line.userData.greasePencilPreviewFrame = preview.frame;
line.userData.greasePencilPreviewStrokeIndex = strokeIndex;
line.userData.greasePencilPointIndexMap = Array.from({ length: linePointCount }, (_, index) => index % stroke.points!.length);
line.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, linePointCount * 3));
group.add(line);
strokeObject = line;
}
if (preview.onion === "NONE") {
const pointGeometry = new BufferGeometry();
pointGeometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, stroke.points.length * 3), 3));
const points = new Points(pointGeometry, new PointsMaterial({ color: new Color(0x76baff), size: 0.1, sizeAttenuation: true, vertexColors: true }));
points.userData.greasePencilPointDataId = dataId;
points.userData.greasePencilPointLayerId = layer.id;
points.userData.greasePencilPointFrame = preview.frame;
points.userData.greasePencilPointStrokeIndex = strokeIndex;
points.userData.greasePencilPointIndexMap = Array.from({ length: stroke.points.length }, (_, index) => index);
points.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, stroke.points.length * 3));
points.visible = false;
(strokeObject ?? group).add(points);
}
count++;
}
return count;
@@ -85,7 +133,7 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number
for (const layer of data.layers) {
if (!layer.visible || layer.opacity <= 0) continue;
for (const preview of layerDrawings(layer, frame)) {
const added = addDrawing(group, layer, preview);
const added = addDrawing(group, data.id, layer, preview);
if (preview.onion === "NONE") currentDrawingCount += added;
else onionDrawingCount += added;
}
@@ -95,6 +143,66 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number
return group.children.length > 0 ? group : null;
}
export function greasePencilPointRef(object: Object3D, pointIndex: number): GreasePencilPointRef | null {
const dataId = object.userData.greasePencilPointDataId;
const layerId = object.userData.greasePencilPointLayerId;
const frame = object.userData.greasePencilPointFrame;
const strokeIndex = object.userData.greasePencilPointStrokeIndex;
if (typeof dataId !== "string" || typeof layerId !== "string" || !Number.isSafeInteger(frame) || !Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(pointIndex) || pointIndex < 0) return null;
return { dataId, layerId, frame, strokeIndex, pointIndex };
}
export function applyGreasePencilPointSelection(root: Object3D, selection: readonly GreasePencilPointRef[]): void {
const selected = new Set(selection.map(pointKey));
root.traverse((object) => {
if (!(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
const count = object.geometry.getAttribute("position")?.count ?? 0;
const first = greasePencilPointRef(object, 0);
if (!first) return;
const colors = new Float32Array(count * 3);
for (let pointIndex = 0; pointIndex < count; pointIndex++) {
const active = selected.has(pointKey({ ...first, pointIndex }));
colors.set(active ? [1, 0.38, 0.08] : [0.46, 0.73, 1], pointIndex * 3);
}
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
object.material.color.set(0xffffff);
object.material.vertexColors = true;
object.material.needsUpdate = true;
});
}
export function applyGreasePencilPointPreview(
root: Object3D,
dataId: string,
layerId: string,
frame: number,
points: readonly GreasePencilPointPreview[] | null,
): void {
const positions = new Map((points ?? []).map((point) => [`${point.strokeIndex}:${point.pointIndex}`, point.position]));
root.traverse((object) => {
const objectDataId = object.userData.greasePencilPreviewDataId ?? object.userData.greasePencilPointDataId;
const objectLayerId = object.userData.greasePencilPreviewLayerId ?? object.userData.greasePencilPointLayerId;
const objectFrame = object.userData.greasePencilPreviewFrame ?? object.userData.greasePencilPointFrame;
const strokeIndex = object.userData.greasePencilPreviewStrokeIndex ?? object.userData.greasePencilPointStrokeIndex;
if (objectDataId !== dataId
|| objectLayerId !== layerId
|| objectFrame !== frame
|| !(object instanceof Line || object instanceof Points)) return;
const position = object.geometry.getAttribute("position");
const base = object.userData.greasePencilPointBasePositions;
const indexMap = object.userData.greasePencilPointIndexMap as number[] | undefined;
if (!(base instanceof Float32Array) || !indexMap || !Number.isSafeInteger(strokeIndex) || position.count * 3 !== base.length) return;
const values = new Float32Array(base);
for (let index = 0; index < indexMap.length; index++) {
const previewPosition = positions.get(`${strokeIndex}:${indexMap[index]}`);
if (previewPosition) values.set(blenderPosition(previewPosition), index * 3);
}
position.array.set(values);
position.needsUpdate = true;
object.geometry.computeBoundingSphere();
});
}
export function applyGreasePencilTransform(object: Object3D, node: SceneNodeIR): void {
const [x, y, z] = node.transform.translation;
const [rx, ry, rz] = node.transform.rotationEuler;

View File

@@ -15,6 +15,7 @@ import {
} from "../vendor/three/three.module.js";
import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
export type NonMeshElementSelection = ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
@@ -90,6 +91,9 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
const lineGeometry = new BufferGeometry();
lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3));
const lines = new LineSegments(lineGeometry, new LineBasicMaterial({ color: new Color(0x9a7bff), transparent: true, opacity: 0.65 }));
lines.userData.nonMeshDataId = data.id;
lines.userData.nonMeshHandleLinePointIndexMap = [...handlePointIndices];
lines.userData.nonMeshHandleBasePositions = new Float32Array(handleLines);
group.add(lines);
const pointGeometry = new BufferGeometry();
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
@@ -97,6 +101,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
handles.userData.nonMeshDataId = data.id;
handles.userData.nonMeshPointIndexMap = handleIndexMap;
handles.userData.nonMeshPointKindMap = handleKindMap;
handles.userData.nonMeshHandleBasePositions = new Float32Array(handlePositions);
group.add(handles);
}
return group.children.length > 0 ? group : null;
@@ -195,3 +200,37 @@ export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshE
object.material.needsUpdate = true;
});
}
export function applyCurveHandlePreview(root: Object3D, dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
const positionsByIdentity = new Map((handles ?? []).map((handle) => [`${handle.pointIndex}:${handle.side}`, handle.position]));
root.traverse((object) => {
if (object.userData.nonMeshDataId !== dataId || !(object instanceof Points || object instanceof LineSegments)) return;
const position = object.geometry.getAttribute("position");
const base = object.userData.nonMeshHandleBasePositions;
if (!(base instanceof Float32Array) || position.count * 3 !== base.length) return;
const values = new Float32Array(base);
if (object instanceof Points) {
const indexMap = object.userData.nonMeshPointIndexMap as number[] | undefined;
const kindMap = object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
if (!indexMap || !kindMap) return;
for (let index = 0; index < indexMap.length; index++) {
const side = kindMap[index] === "HANDLE_LEFT" ? "LEFT" : kindMap[index] === "HANDLE_RIGHT" ? "RIGHT" : null;
const preview = side ? positionsByIdentity.get(`${indexMap[index]}:${side}`) : undefined;
if (preview) values.set(blenderPosition(...preview), index * 3);
}
}
else {
const indexMap = object.userData.nonMeshHandleLinePointIndexMap as number[] | undefined;
if (!indexMap) return;
for (let index = 0; index < indexMap.length; index++) {
const left = positionsByIdentity.get(`${indexMap[index]}:LEFT`);
const right = positionsByIdentity.get(`${indexMap[index]}:RIGHT`);
if (left) values.set(blenderPosition(...left), index * 12 + 3);
if (right) values.set(blenderPosition(...right), index * 12 + 9);
}
}
position.array.set(values);
position.needsUpdate = true;
object.geometry.computeBoundingSphere();
});
}

View File

@@ -3,14 +3,21 @@ import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
import type { NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
export type OffscreenViewportRequest =
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
| { type: "textureAssets"; assets: GPUTextureAsset[] }
| { type: "volumeAssets"; assets: NanoVDBViewportAssetIR[] }
| { type: "resize"; width: number; height: number; pixelRatio: number }
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }> }
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[] }
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
| { type: "curveHandlePreview"; dataId: string; handles: CurveGizmoHandleIR[] | null }
| { type: "greasePencilPointPreview"; dataId: string; layerId: string; frame: number; points: GreasePencilPointPreview[] | null }
| { type: "curveGizmoFrame"; dataId: string | null; frame: CurveGizmoFrameIR | null }
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
| { type: "pick"; x: number; y: number; additive: boolean }
| { type: "dispose" };
@@ -20,6 +27,9 @@ export type OffscreenViewportResponse =
| { type: "frame"; visiblePixels: number }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number }
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
| { type: "volumeStatus"; status: "none" | "loading" | "ready" | "blocked"; count: number; errorCode?: string }
| { type: "selected"; objectId: string; additive: boolean }
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
| { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean }
| { type: "curveGizmoScreenFrame"; frame: CurveGizmoScreenFrameIR | null }
| { type: "error"; message: string };

View File

@@ -8,12 +8,19 @@ import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary";
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
import { cloneNanoVDBViewportAssets, nanoVDBViewportAssetTransferables, type NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
export interface ViewportBackend {
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void;
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[]): void;
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void;
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void;
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void;
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
dispose(): void;
}
@@ -34,6 +41,7 @@ export function acquireOffscreenViewportRenderer(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
): OffscreenViewportRenderer {
const existing = sharedBackends.get(canvas);
if (existing) {
@@ -42,7 +50,7 @@ export function acquireOffscreenViewportRenderer(
existing.references += 1;
return existing.renderer;
}
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect);
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect);
sharedBackends.set(canvas, { renderer, references: 1 });
return renderer;
}
@@ -65,6 +73,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
private readonly resizeObserver: ResizeObserver;
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void;
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
private lastSnapshot: SceneSnapshotIR | null = null;
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
@@ -74,11 +83,13 @@ export class OffscreenViewportRenderer implements ViewportBackend {
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
) {
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
const offscreen = canvas.transferControlToOffscreen();
@@ -147,15 +158,34 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
}
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void {
const cloned = cloneNanoVDBViewportAssets(assets);
this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned));
}
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints: readonly GreasePencilPointRef[] = []): void {
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements } satisfies OffscreenViewportRequest);
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints] } satisfies OffscreenViewportRequest);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
}
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
this.worker.postMessage({ type: "curveHandlePreview", dataId, handles: handles ? handles.map((handle) => ({ ...handle, position: [...handle.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest);
}
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void {
this.canvas.dataset.greasePencilPreview = points ? String(points.length) : "0";
this.worker.postMessage({ type: "greasePencilPointPreview", dataId, layerId, frame, points: points ? points.map((point) => ({ ...point, position: [...point.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest);
}
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void {
this.worker.postMessage({ type: "curveGizmoFrame", dataId, frame } satisfies OffscreenViewportRequest);
}
installLODLevels(): void {
// The main-thread renderer remains the adaptive LOD owner; the worker path
// renders the source instanced mesh and retains native frustum culling.
@@ -204,6 +234,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
private handleMessage(message: OffscreenViewportResponse): void {
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind);
else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive);
else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
else if (message.type === "snapshotStatus") {
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
@@ -218,6 +249,14 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.canvas.dataset.textureBytes = String(message.bytes);
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
}
else if (message.type === "volumeStatus") {
this.canvas.dataset.volumeStatus = message.status;
this.canvas.dataset.volumeCount = String(message.count);
this.canvas.dataset.volumeErrorCode = message.errorCode ?? "";
}
else if (message.type === "curveGizmoScreenFrame") {
this.canvas.dispatchEvent(new CustomEvent("curve-gizmo-frame", { detail: message.frame }));
}
else if (message.type === "error") this.canvas.dataset.rendererError = message.message;
}

View File

@@ -72,8 +72,30 @@ export function blenderLightIntensity(definition: LightIR): number {
return Math.max(0, definition.energy) * 2 ** clamp(definition.exposure, -20, 20, 0) / 10;
}
function blackbodySrgb(temperature: number): [number, number, number] {
const value = clamp(temperature, 800, 20_000, 6500) / 100;
const red = value <= 66 ? 255 : 329.698727446 * (value - 60) ** -0.1332047592;
const green = value <= 66 ? 99.4708025861 * Math.log(value) - 161.1195681661 : 288.1221695283 * (value - 60) ** -0.0755148492;
const blue = value >= 66 ? 255 : value <= 19 ? 0 : 138.5177312231 * Math.log(value - 10) - 305.0447927307;
return [red, green, blue].map((component) => clamp(component / 255, 0, 1, 0)) as [number, number, number];
}
function srgbToLinear(value: number): number {
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
}
/** Returns a bounded linear-RGB light color with Blender's 6500 K default treated as neutral. */
export function blenderLightColor(definition: LightIR): [number, number, number] {
if (!definition.useTemperature) return [...definition.color];
const neutral = blackbodySrgb(6500);
const blackbody = blackbodySrgb(definition.temperature ?? 6500).map((component, index) => component / neutral[index]);
const peak = Math.max(1, ...blackbody);
const linear = blackbody.map((component) => srgbToLinear(component / peak));
return definition.color.map((component, index) => clamp(component, 0, 1, 0) * linear[index]) as [number, number, number];
}
export function createPBRLight(definition: LightIR): Light {
const color = new Color().setRGB(...definition.color);
const color = new Color().setRGB(...blenderLightColor(definition));
const intensity = blenderLightIntensity(definition);
const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) :
definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) :

View File

@@ -8,6 +8,7 @@ import {
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
HemisphereLight,
MeshPhysicalMaterial,
Raycaster,
@@ -24,7 +25,7 @@ import {
} from "../vendor/three/three.module.js";
import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js";
import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
import { applySceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import { applySceneDelta, sceneDeltaRequiresRendererRebuild, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
import {
@@ -40,9 +41,21 @@ import {
import { GPUTextureStore } from "./texture-assets";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
import { createNanoVDBViewportObject } from "./volume";
import {
applyGreasePencilPointSelection,
applyGreasePencilPointPreview,
applyGreasePencilTransform,
createGreasePencilObject,
greasePencilPointRef,
type GreasePencilPointRef,
type GreasePencilPointPreview,
} from "./grease-pencil";
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
const groups = new Map<string, string[]>();
@@ -68,6 +81,7 @@ export class ViewportRenderer {
private readonly resizeObserver: ResizeObserver;
private animationFrame = 0;
private disposed = false;
private contextLost = false;
private currentSnapshot: SceneSnapshotIR | null = null;
private readonly objectByBlenderId = new Map<string, Object3D>();
private readonly instanceIndexByBlenderId = new Map<string, number>();
@@ -77,17 +91,33 @@ export class ViewportRenderer {
private readonly pointer = new Vector2();
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void;
private editMode = false;
private selectionMode: MeshElementMode = "FACE";
private curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
private curveGizmoScreenFrame = "";
private volumeAssets: NanoVDBViewportAssetIR[] = [];
private readonly volumeRenderCache = new Map<string, NanoVDBViewportRenderResultIR>();
private volumeRenderGeneration = 0;
private readonly volumeRenderSession: NanoVDBViewportRenderSession;
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
) {
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
this.volumeRenderSession = new NanoVDBViewportRenderSession(() => {
if (this.disposed) return;
this.volumeRenderCache.clear();
this.canvas.dataset.volumeStatus = "loading";
void this.refreshVolumes();
});
this.raycaster.params.Points.threshold = 0.14;
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
configurePBRRenderer(this.renderer);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
@@ -96,6 +126,7 @@ export class ViewportRenderer {
this.canvas.dataset.pbrProfile = PBR_PROFILE;
this.canvas.dataset.toneMapping = PBR_TONE_MAPPING;
this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
this.canvas.dataset.deviceStatus = "ready";
this.scene = new Scene();
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
this.camera.position.set(4.5, -4.5, 3.5);
@@ -118,6 +149,8 @@ export class ViewportRenderer {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
this.canvas.addEventListener("click", this.handleClick);
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
this.resize();
this.renderLoop();
}
@@ -243,6 +276,7 @@ export class ViewportRenderer {
this.objectByBlenderId.set(node.id, mesh);
}
this.coalesceMeshInstances(snapshot);
void this.refreshVolumes();
if (this.importedRoot.children.length > 0) {
this.controls.target.set(0, 0, 0);
}
@@ -256,6 +290,7 @@ export class ViewportRenderer {
if (!node.visible || !node.dataId || node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA") continue;
const data = dataById.get(node.dataId);
if (!data) continue;
if (data.type === "VOLUME") continue;
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
if (!object) {
blockedCount++;
@@ -322,11 +357,68 @@ export class ViewportRenderer {
});
}
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void {
this.volumeAssets = [...assets];
void this.refreshVolumes();
}
private async refreshVolumes(): Promise<void> {
const generation = ++this.volumeRenderGeneration;
for (const child of [...this.importedRoot.children]) {
if (!child.userData.nanoVDBVolume) continue;
this.importedRoot.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 = this.currentSnapshot;
const volumeNodes = snapshot?.nodes.filter((node) => node.visible && node.type === "VOLUME" && node.dataId) ?? [];
if (!snapshot || volumeNodes.length === 0) {
this.canvas.dataset.volumeStatus = "none";
this.canvas.dataset.volumeCount = "0";
return;
}
this.canvas.dataset.volumeStatus = "loading";
this.canvas.dataset.volumeCount = "0";
try {
let rendered = 0;
for (const node of volumeNodes) {
const asset = this.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 = this.volumeRenderCache.get(cacheKey);
if (!result) {
result = await renderNanoVDBViewportAsset(asset, 128, 128, this.volumeRenderSession);
this.volumeRenderCache.set(cacheKey, result);
}
if (generation !== this.volumeRenderGeneration || this.currentSnapshot !== snapshot) return;
const object = createNanoVDBViewportObject(result, node);
this.importedRoot.add(object);
this.objectByBlenderId.set(node.id, object);
rendered++;
}
if (generation !== this.volumeRenderGeneration) return;
this.canvas.dataset.volumeCount = String(rendered);
this.canvas.dataset.volumeStatus = rendered === volumeNodes.length ? "ready" : "blocked";
this.canvas.dataset.volumeErrorCode = rendered === volumeNodes.length ? "" : "NON_MESH_RESOURCE_MISSING";
}
catch (error) {
if (generation !== this.volumeRenderGeneration) return;
this.canvas.dataset.volumeStatus = "blocked";
this.canvas.dataset.volumeErrorCode = error instanceof Error ? error.message.split(":", 1)[0] : "VOLUME_SHADER_UNAVAILABLE";
}
}
applyDelta(delta: SceneDelta, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot");
const next = applySceneDelta(this.currentSnapshot, delta);
const hasLifecycleChanges = Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length ||
delta.meshes || delta.materials || delta.cameras || delta.lights || delta.animations);
const hasLifecycleChanges = sceneDeltaRequiresRendererRebuild(delta);
if (hasLifecycleChanges) {
this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers);
return;
@@ -353,7 +445,11 @@ export class ViewportRenderer {
this.currentSnapshot = next;
}
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
setSelection(
objectIds: ReadonlySet<string>,
elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>,
greasePencilPoints: readonly GreasePencilPointRef[] = [],
): void {
const visitedInstances = new Set<InstancedMesh>();
for (const [objectId, object] of this.objectByBlenderId) {
if (object instanceof InstancedMesh) {
@@ -374,11 +470,30 @@ export class ViewportRenderer {
}
}
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.editMode = editMode;
this.selectionMode = selectionMode;
this.importedRoot.traverse((object) => {
if (typeof object.userData.greasePencilPointDataId === "string") object.visible = editMode;
});
}
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
applyCurveHandlePreview(this.importedRoot, dataId, handles);
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
}
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void {
applyGreasePencilPointPreview(this.importedRoot, dataId, layerId, frame, points);
this.canvas.dataset.greasePencilPreview = points ? String(points.length) : "0";
}
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void {
this.curveGizmoFrame = dataId && frame ? { dataId, frame } : null;
this.publishCurveGizmoFrame();
}
registerLOD(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void {
@@ -529,6 +644,7 @@ export class ViewportRenderer {
if (mesh.geometry && typeof mesh.geometry.dispose === "function") mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
if (mesh.userData.nanoVDBVolume && material instanceof MeshBasicMaterial) material.map?.dispose();
if (material && typeof material.dispose === "function") material.dispose();
}
});
@@ -602,8 +718,17 @@ export class ViewportRenderer {
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
this.raycaster.setFromCamera(this.pointer, this.camera);
const hit = this.raycaster.intersectObjects(this.importedRoot.children, true)
.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
const hits = this.raycaster.intersectObjects(this.importedRoot.children, true);
const greasePencilHit = this.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) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey);
return;
}
const preferredNonMeshHit = this.editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
const hit = preferredNonMeshHit ?? hits.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
if (!hit) return;
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
const nonMeshDataId = hit.object.userData.nonMeshDataId;
@@ -611,7 +736,9 @@ export class ViewportRenderer {
const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined;
const pointIndex = indexMap?.[hit.index] ?? Math.max(0, Math.floor(hit.object.userData.nonMeshPointOffset ?? 0) + hit.index);
const kindMap = hit.object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kindMap?.[hit.index] ?? "CONTROL_POINT");
const kind = kindMap?.[hit.index] ?? "CONTROL_POINT";
this.canvas.dataset.nonMeshLastPick = `${nonMeshDataId}:${kind}:${pointIndex}`;
this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kind);
return;
}
const meshId = hit.object.userData.meshId;
@@ -662,20 +789,74 @@ export class ViewportRenderer {
private renderLoop = (): void => {
if (this.disposed) return;
this.controls.update();
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
this.renderer.render(this.scene, this.camera);
if (!this.contextLost) {
this.controls.update();
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
this.renderer.render(this.scene, this.camera);
this.publishCurveGizmoFrame();
if (this.canvas.dataset.deviceStatus === "restoring") {
this.canvas.dataset.deviceStatus = "ready";
this.canvas.dispatchEvent(new CustomEvent("viewport-device-restored"));
}
}
this.animationFrame = window.requestAnimationFrame(this.renderLoop);
};
private handleContextLost = (event: Event): void => {
event.preventDefault();
this.contextLost = true;
this.canvas.dataset.deviceStatus = "lost";
};
private handleContextRestored = (): void => {
this.contextLost = false;
this.canvas.dataset.deviceStatus = "restoring";
configurePBRRenderer(this.renderer);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setClearColor(new Color("#25272b"));
this.resize();
this.volumeRenderCache.clear();
void this.refreshVolumes();
};
private publishCurveGizmoFrame(): void {
const active = this.curveGizmoFrame;
let frame: CurveGizmoScreenFrameIR | null = null;
const node = active ? this.currentSnapshot?.nodes.find((candidate) => candidate.dataId === active.dataId && candidate.id === this.currentSnapshot?.activeObjectId) : undefined;
const object = node ? this.objectByBlenderId.get(node.id) : undefined;
if (active && object) {
object.updateWorldMatrix(true, false);
this.camera.updateMatrixWorld(true);
const project = (value: readonly number[]): Vector3 => new Vector3(value[0], value[2], -value[1]).applyMatrix4(object.matrixWorld).project(this.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 };
}
const serialized = JSON.stringify(frame);
if (serialized === this.curveGizmoScreenFrame) return;
this.curveGizmoScreenFrame = serialized;
this.canvas.dispatchEvent(new CustomEvent<CurveGizmoScreenFrameIR | null>("curve-gizmo-frame", { detail: frame }));
}
dispose(): void {
this.disposed = true;
window.cancelAnimationFrame(this.animationFrame);
this.resizeObserver.disconnect();
this.canvas.removeEventListener("click", this.handleClick);
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
this.controls.dispose();
this.lodAdapter.clear();
this.clearImportedScene();
this.volumeRenderGeneration++;
this.volumeRenderCache.clear();
this.volumeRenderSession.dispose();
this.textureStore.dispose();
this.renderer.dispose();
}

View File

@@ -0,0 +1,43 @@
import {
BufferGeometry,
DataTexture,
DoubleSide,
Float32BufferAttribute,
Mesh,
MeshBasicMaterial,
RGBAFormat,
Uint32BufferAttribute,
UnsignedByteType,
} from "../vendor/three/three.module.js";
import type { SceneNodeIR } from "../../../protocol/scene-ir";
import type { NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
import { applyNonMeshTransform } from "./nonmesh";
export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultIR, node: SceneNodeIR): Mesh {
const { min, max } = result.grid.worldBounds;
const z = (min[2] + max[2]) / 2;
const positions = [
min[0], z, -min[1],
max[0], z, -min[1],
max[0], z, -max[1],
min[0], z, -max[1],
];
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 1], 2));
geometry.setIndex(new Uint32BufferAttribute([0, 1, 2, 0, 2, 3], 1));
const texture = new DataTexture(result.pixels, result.width, result.height, RGBAFormat, UnsignedByteType);
texture.needsUpdate = true;
const material = new MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false, side: DoubleSide, toneMapped: false });
const mesh = new Mesh(geometry, material);
mesh.name = `${node.name} (NanoVDB)`;
mesh.renderOrder = 4;
mesh.userData.sceneNodeId = node.id;
mesh.userData.blenderId = node.id;
mesh.userData.nonMeshDataId = result.dataId;
mesh.userData.nanoVDBVolume = true;
mesh.userData.nanoVDBGrid = result.grid.name;
mesh.userData.nanoVDBImageSize = [result.width, result.height];
applyNonMeshTransform(mesh, node);
return mesh;
}

View File

@@ -0,0 +1,8 @@
export {
raycastMaskProject,
selectMaskPointsInBounds,
} from "../../../protocol/tracking-mask";
export type {
MaskPointSelectionIR,
MaskRaycastHitIR,
} from "../../../protocol/tracking-mask";

46
web/app/src/types/webgpu.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
/* Minimal native WebGPU declarations used by the bounded NanoVDB renderer. */
interface GPUBuffer {
destroy(): void;
getMappedRange(): ArrayBuffer;
unmap(): void;
mapAsync(mode: number): Promise<void>;
}
interface GPUAdapter {
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number };
requestDevice(options?: { requiredLimits?: Record<string, number> }): Promise<GPUDevice>;
}
interface GPUQueue { writeBuffer(buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void; submit(commands: Array<GPUCommandBuffer>): void }
type GPUCommandBuffer = object;
interface GPUComputePassEncoder {
setPipeline(pipeline: GPUComputePipeline): void;
setBindGroup(index: number, bindGroup: GPUBindGroup): void;
dispatchWorkgroups(x: number, y?: number, z?: number): void;
end(): void;
}
interface GPUCommandEncoder {
beginComputePass(): GPUComputePassEncoder;
copyBufferToBuffer(source: GPUBuffer, sourceOffset: number, destination: GPUBuffer, destinationOffset: number, size: number): void;
finish(): GPUCommandBuffer;
}
type GPUShaderModule = object;
interface GPUComputePipeline {
getBindGroupLayout(index: number): GPUBindGroupLayout;
}
type GPUBindGroupLayout = object;
type GPUBindGroup = object;
interface GPUDevice {
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number };
queue: GPUQueue;
lost: Promise<{ reason?: string; message: string }>;
createBuffer(descriptor: { label?: string; size: number; usage: number; mappedAtCreation?: boolean }): GPUBuffer;
createShaderModule(descriptor: { label?: string; code: string }): GPUShaderModule;
createComputePipeline(descriptor: { layout: "auto"; compute: { module: GPUShaderModule; entryPoint: string } }): GPUComputePipeline;
createBindGroup(descriptor: { layout: GPUBindGroupLayout; entries: Array<{ binding: number; resource: { buffer: GPUBuffer } }> }): GPUBindGroup;
createCommandEncoder(): GPUCommandEncoder;
pushErrorScope(filter: string): void;
popErrorScope(): Promise<{ message?: string } | null>;
destroy(): void;
}
declare const GPUBufferUsage: { STORAGE: number; COPY_DST: number; COPY_SRC: number; MAP_READ: number; UNIFORM: number };
declare const GPUMapMode: { READ: number };
interface Navigator { gpu?: { requestAdapter(options?: { powerPreference?: string }): Promise<GPUAdapter | null> } }

Binary file not shown.

View File

@@ -0,0 +1,88 @@
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotate(value: number, amount: number): number {
return (value >>> amount) | (value << (32 - amount));
}
export class IncrementalSha256 {
private readonly state = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
private readonly block = new Uint8Array(64);
private blockLength = 0;
private bytes = 0;
private finished = false;
update(value: ArrayBuffer | Uint8Array): this {
if (this.finished) throw new Error("SHA-256 digest is already finalized");
const data = value instanceof Uint8Array ? value : new Uint8Array(value);
this.bytes += data.byteLength;
let offset = 0;
while (offset < data.byteLength) {
const length = Math.min(64 - this.blockLength, data.byteLength - offset);
this.block.set(data.subarray(offset, offset + length), this.blockLength);
this.blockLength += length;
offset += length;
if (this.blockLength === 64) {
this.compress(this.block);
this.blockLength = 0;
}
}
return this;
}
hex(): string {
if (!this.finished) {
const bitLength = this.bytes * 8;
this.block[this.blockLength++] = 0x80;
if (this.blockLength > 56) {
this.block.fill(0, this.blockLength);
this.compress(this.block);
this.blockLength = 0;
}
this.block.fill(0, this.blockLength, 56);
const view = new DataView(this.block.buffer);
view.setUint32(56, Math.floor(bitLength / 0x1_0000_0000), false);
view.setUint32(60, bitLength >>> 0, false);
this.compress(this.block);
this.finished = true;
}
return Array.from(this.state, (word) => word.toString(16).padStart(8, "0")).join("");
}
private compress(block: Uint8Array): void {
const words = new Uint32Array(64);
const view = new DataView(block.buffer, block.byteOffset, 64);
for (let index = 0; index < 16; index++) words[index] = view.getUint32(index * 4, false);
for (let index = 16; index < 64; index++) {
const s0 = rotate(words[index - 15], 7) ^ rotate(words[index - 15], 18) ^ (words[index - 15] >>> 3);
const s1 = rotate(words[index - 2], 17) ^ rotate(words[index - 2], 19) ^ (words[index - 2] >>> 10);
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = this.state;
for (let index = 0; index < 64; index++) {
const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
const choose = (e & f) ^ (~e & g);
const t1 = (h + s1 + choose + K[index] + words[index]) >>> 0;
const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
const majority = (a & b) ^ (a & c) ^ (b & c);
const t2 = (s0 + majority) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
this.state[0] = (this.state[0] + a) >>> 0;
this.state[1] = (this.state[1] + b) >>> 0;
this.state[2] = (this.state[2] + c) >>> 0;
this.state[3] = (this.state[3] + d) >>> 0;
this.state[4] = (this.state[4] + e) >>> 0;
this.state[5] = (this.state[5] + f) >>> 0;
this.state[6] = (this.state[6] + g) >>> 0;
this.state[7] = (this.state[7] + h) >>> 0;
}
}

View File

@@ -0,0 +1,99 @@
import type { NanoVDBFloat32TreeLayoutIR, NanoVDBGridIR } from "../../../protocol/volume-vdb";
export interface NanoVDBSampleIR { value: number; active: boolean }
export class NanoVDBFloat32Sampler {
private readonly view: DataView;
private readonly layout: NanoVDBFloat32TreeLayoutIR;
private readonly root: number;
constructor(payload: ArrayBuffer, grid: NanoVDBGridIR, layout: NanoVDBFloat32TreeLayoutIR | undefined) {
if (grid.valueType !== "FLOAT32" || !layout) throw new Error("NANOVDB_GRID_UNSUPPORTED: Float32 tree layout is required");
if (payload.byteLength !== grid.byteLength || payload.byteLength < layout.gridDataBytes + layout.treeDataBytes) throw new Error("NANOVDB_STREAM_INCOMPLETE: Float32 grid payload length mismatch");
this.view = new DataView(payload);
this.layout = layout;
if (this.u32(0) !== 0x6f6e614e || this.u32(4) !== 0x31424456) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB grid magic mismatch");
if ((this.u32(16) >>> 21) !== 32) throw new Error("NANOVDB_GRID_UNSUPPORTED: NanoVDB major version is unsupported");
if (this.u64(32) !== BigInt(payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB GridData size mismatch");
const tree = layout.gridDataBytes;
const rootOffset = this.i64(tree + layout.treeRootOffsetOffset);
if (rootOffset <= 0n || rootOffset > BigInt(payload.byteLength - layout.rootDataBytes)) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB root offset is outside the payload");
this.root = tree + Number(rootOffset);
const tableSize = this.u32(this.root + layout.rootTableSizeOffset);
this.range(this.root + layout.rootDataBytes, tableSize * layout.rootTileBytes);
}
nearest(coord: readonly [number, number, number]): NanoVDBSampleIR {
if (coord.some((value) => !Number.isSafeInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff)) throw new Error("NANOVDB_MANIFEST_INVALID: sample coordinate is outside int32");
const tableSize = this.u32(this.root + this.layout.rootTableSizeOffset);
const key = this.rootKey(coord);
let low = 0;
let high = tableSize - 1;
let tile = -1;
while (low <= high) {
const middle = (low + high) >>> 1;
const address = this.root + this.layout.rootDataBytes + middle * this.layout.rootTileBytes;
const candidate = this.u64(address + this.layout.rootTileKeyOffset);
if (candidate === key) { tile = address; break; }
// NanoVDB root tiles are serialized in descending key order.
if (candidate > key) low = middle + 1;
else high = middle - 1;
}
if (tile < 0) return { value: this.view.getFloat32(this.root + 28, true), active: false };
const child = this.i64(tile + this.layout.rootTileChildOffset);
if (child === 0n) return { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 };
const upper = this.child(this.root, child, this.layout.upperNodeBytes);
const upperOffset = (((coord[0] >>> 0 & 4095) >>> 7) << 10) | (((coord[1] >>> 0 & 4095) >>> 7) << 5) | ((coord[2] >>> 0 & 4095) >>> 7);
const upperSample = this.internal(upper, upperOffset, this.layout.upperValueMaskOffset, this.layout.upperChildMaskOffset, this.layout.upperTableOffset, this.layout.lowerNodeBytes);
if ("sample" in upperSample) return upperSample.sample;
const lower = upperSample.child;
const lowerOffset = (((coord[0] >>> 0 & 127) >>> 3) << 8) | (((coord[1] >>> 0 & 127) >>> 3) << 4) | ((coord[2] >>> 0 & 127) >>> 3);
const lowerSample = this.internal(lower, lowerOffset, this.layout.lowerValueMaskOffset, this.layout.lowerChildMaskOffset, this.layout.lowerTableOffset, this.layout.leafNodeBytes);
if ("sample" in lowerSample) return lowerSample.sample;
const leaf = lowerSample.child;
const voxel = ((coord[0] >>> 0 & 7) << 6) | ((coord[1] >>> 0 & 7) << 3) | (coord[2] >>> 0 & 7);
return { value: this.f32(leaf + this.layout.leafValuesOffset + voxel * 4), active: this.mask(leaf + this.layout.leafValueMaskOffset, voxel) };
}
linear(coord: readonly [number, number, number]): NanoVDBSampleIR {
const base = coord.map(Math.floor) as [number, number, number];
const fraction = coord.map((value, index) => value - base[index]) as [number, number, number];
let value = 0;
let active = false;
for (let x = 0; x < 2; x++) for (let y = 0; y < 2; y++) for (let z = 0; z < 2; z++) {
const sample = this.nearest([base[0] + x, base[1] + y, base[2] + z]);
const weight = (x ? fraction[0] : 1 - fraction[0]) * (y ? fraction[1] : 1 - fraction[1]) * (z ? fraction[2] : 1 - fraction[2]);
value += sample.value * weight;
active ||= sample.active;
}
return { value, active };
}
private internal(node: number, index: number, valueMaskOffset: number, childMaskOffset: number, tableOffset: number, childBytes: number): { child: number } | { sample: NanoVDBSampleIR } {
if (!this.mask(node + childMaskOffset, index)) return { sample: { value: this.f32(node + tableOffset + index * 8), active: this.mask(node + valueMaskOffset, index) } };
return { child: this.child(node, this.i64(node + tableOffset + index * 8), childBytes) };
}
private child(parent: number, offset: bigint, bytes: number): number {
if (offset <= 0n || offset > BigInt(this.view.byteLength)) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB child offset is invalid");
const child = parent + Number(offset);
this.range(child, bytes);
return child;
}
private rootKey(coord: readonly number[]): bigint {
const x = BigInt(coord[0] >>> 0) >> 12n;
const y = BigInt(coord[1] >>> 0) >> 12n;
const z = BigInt(coord[2] >>> 0) >> 12n;
return z | (y << 21n) | (x << 42n);
}
private mask(address: number, index: number): boolean { return (this.u32(address + (index >>> 5) * 4) & (1 << (index & 31))) !== 0; }
private u32(address: number): number { this.range(address, 4); return this.view.getUint32(address, true); }
private f32(address: number): number { this.range(address, 4); return this.view.getFloat32(address, true); }
private u64(address: number): bigint { this.range(address, 8); return this.view.getBigUint64(address, true); }
private i64(address: number): bigint { this.range(address, 8); return this.view.getBigInt64(address, true); }
private range(address: number, bytes: number): void {
if (!Number.isSafeInteger(address) || !Number.isSafeInteger(bytes) || address < 0 || bytes < 0 || address > this.view.byteLength - bytes) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB address is outside the grid payload");
}
}

View File

@@ -0,0 +1,268 @@
import {
evaluateVDBProjectBinding,
validateNanoVDBBundleManifest,
validateVDBProjectBinding,
verifyNanoVDBChunk,
type NanoVDBBundleManifestIR,
type VDBProjectBindingIR,
type VDBProjectBindingStatusIR,
type VDBProjectReopenContextIR,
} from "../../../protocol/volume-vdb";
import { validateProjectId, validateSha256 } from "../storage/opfs-files";
import { IncrementalSha256 } from "./incremental-sha256";
import { streamNanoVDBChunks, type NanoVDBRangeSource } from "./nanovdb-stream";
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
type MovableFile = FileSystemFileHandle & { move?: (name: string) => Promise<void> };
type DirectoryEntries = AsyncIterableIterator<[string, FileSystemHandle]>;
export interface NanoVDBOPFSCommitResult {
projectId: string;
bundleSha256: string;
bundleByteLength: number;
chunks: number;
deduplicated: boolean;
}
export interface NanoVDBOPFSOpenResult {
manifest: NanoVDBBundleManifestIR;
binding?: VDBProjectBindingIR;
bindingStatus?: VDBProjectBindingStatusIR;
source: NanoVDBRangeSource;
}
export async function listVDBProjectBindings(projectId: string, storage?: StorageManager): Promise<VDBProjectBindingIR[]> {
const cache = await rootFor(projectId, storage);
const bindings = await directory(cache, "bindings");
const result: VDBProjectBindingIR[] = [];
const entries = (bindings as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name, handle] of entries) {
if (handle.kind !== "file" || !/^[a-f0-9]{64}\.json$/.test(name)) continue;
try { result.push(validateVDBProjectBinding(await readJson<VDBProjectBindingIR>(bindings, name))); }
catch { /* Invalid binding records are ignored and cannot make a bundle discoverable. */ }
}
return result.sort((left, right) => right.committedAt.localeCompare(left.committedAt));
}
async function directory(parent: FileSystemDirectoryHandle, name: string, create = true): Promise<FileSystemDirectoryHandle> {
if (!/^[A-Za-z0-9._-]{1,128}$/.test(name) || name === "." || name === "..") throw new Error("NANOVDB_MANIFEST_INVALID: OPFS directory name");
return parent.getDirectoryHandle(name, { create });
}
async function rootFor(projectId: string, storage?: StorageManager): Promise<FileSystemDirectoryHandle> {
validateProjectId(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS is unavailable");
let current = await manager.getDirectory();
for (const name of ["projects", projectId, "cache", "vdb"]) current = await directory(current, name);
await directory(current, "bindings");
return current;
}
async function writeFile(parent: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise<void> {
const handle = await parent.getFileHandle(name, { create: true });
const writer = await handle.createWritable();
await writer.write(value);
await writer.close();
}
async function atomicWrite(parent: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise<void> {
const stageName = `${name}.${crypto.randomUUID()}.stage`;
await writeFile(parent, stageName, value);
const stage = await parent.getFileHandle(stageName) as MovableFile;
if (stage.move) await stage.move(name);
else {
const bytes = await (await stage.getFile()).arrayBuffer();
await writeFile(parent, name, bytes);
await parent.removeEntry(stageName);
}
}
async function readJson<T>(parent: FileSystemDirectoryHandle, name: string): Promise<T> {
const bytes = await (await (await parent.getFileHandle(name)).getFile()).arrayBuffer();
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
async function remove(parent: FileSystemDirectoryHandle, name: string, recursive = false): Promise<void> {
try { await parent.removeEntry(name, { recursive }); }
catch (error) { if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; }
}
function chunkName(index: number): string {
return `${String(index).padStart(5, "0")}.chunk`;
}
async function digestJson(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(JSON.stringify(value));
const hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function createVDBProjectBinding(
manifestValue: NanoVDBBundleManifestIR,
sourceBlendSha256: string,
): Promise<VDBProjectBindingIR> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
validateSha256(sourceBlendSha256);
const manifestSha256 = await digestJson(manifest);
return validateVDBProjectBinding({
schemaVersion: 1,
projectId: manifest.projectId,
sourceBlendSha256,
sourcePath: manifest.sourcePath,
sourceSha256: manifest.sourceSha256,
conversionRequestSha256: manifest.conversionRequestSha256,
bundleSha256: manifest.bundleSha256,
bundleByteLength: manifest.bundleByteLength,
manifestSha256,
converter: manifest.converter,
shaderSemanticVersion: manifest.gpu.shaderSemanticVersion,
material: manifest.material,
committedAt: new Date().toISOString(),
});
}
export async function commitNanoVDBToOPFS(
manifestValue: NanoVDBBundleManifestIR,
source: NanoVDBRangeSource,
signal: AbortSignal,
bindingValue?: VDBProjectBindingIR,
storage?: StorageManager,
): Promise<NanoVDBOPFSCommitResult> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const binding = bindingValue ? validateVDBProjectBinding(bindingValue) : undefined;
const manifestSha256 = await digestJson(manifest);
if (binding && (binding.projectId !== manifest.projectId || binding.bundleSha256 !== manifest.bundleSha256 || binding.conversionRequestSha256 !== manifest.conversionRequestSha256 || binding.manifestSha256 !== manifestSha256)) throw new Error("NANOVDB_HASH_MISMATCH: project binding does not match manifest");
const cache = await rootFor(manifest.projectId, storage);
const bundle = await directory(cache, manifest.bundleSha256);
try {
const existing = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(bundle, "manifest.json"));
if (existing.bundleSha256 === manifest.bundleSha256 && existing.conversionRequestSha256 === manifest.conversionRequestSha256 && await digestJson(existing) === manifestSha256) {
for (const chunk of existing.chunks) {
const data = await (await (await bundle.getFileHandle(chunkName(chunk.index))).getFile()).arrayBuffer();
await verifyNanoVDBChunk(chunk, data);
}
await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength }));
if (binding) {
const bindings = await directory(cache, "bindings");
await atomicWrite(bindings, `${binding.conversionRequestSha256}.json`, JSON.stringify(binding));
}
return { projectId: manifest.projectId, bundleSha256: manifest.bundleSha256, bundleByteLength: manifest.bundleByteLength, chunks: manifest.chunks.length, deduplicated: true };
}
}
catch { /* An incomplete directory is staging and remains undiscoverable until manifest commit. */ }
const hasher = new IncrementalSha256();
const staged: string[] = [];
try {
await streamNanoVDBChunks(manifest, source, async (range, data) => {
if (signal.aborted) throw new DOMException("NanoVDB OPFS commit cancelled", "AbortError");
hasher.update(data);
const name = `${chunkName(range.chunkIndex)}.${crypto.randomUUID()}.stage`;
staged.push(name);
await writeFile(bundle, name, data);
const written = await (await bundle.getFileHandle(name)).getFile();
await verifyNanoVDBChunk(manifest.chunks[range.chunkIndex], await written.arrayBuffer());
}, signal);
if (hasher.hex() !== manifest.bundleSha256) throw new Error("NANOVDB_HASH_MISMATCH: streamed bundle SHA-256 mismatch");
for (let index = 0; index < staged.length; index++) {
const handle = await bundle.getFileHandle(staged[index]) as MovableFile;
if (handle.move) await handle.move(chunkName(index));
else {
await writeFile(bundle, chunkName(index), await (await handle.getFile()).arrayBuffer());
await bundle.removeEntry(staged[index]);
}
}
await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength }));
await atomicWrite(bundle, "manifest.json", JSON.stringify(manifest));
if (binding) {
const bindings = await directory(cache, "bindings");
await atomicWrite(bindings, `${binding.conversionRequestSha256}.json`, JSON.stringify(binding));
}
return { projectId: manifest.projectId, bundleSha256: manifest.bundleSha256, bundleByteLength: manifest.bundleByteLength, chunks: manifest.chunks.length, deduplicated: false };
}
catch (error) {
await Promise.all(staged.map((name) => remove(bundle, name)));
await remove(bundle, "manifest.json");
throw error;
}
}
export async function openNanoVDBFromOPFS(
projectId: string,
bundleSha256: string,
conversionRequestSha256?: string,
reopenContext?: VDBProjectReopenContextIR,
storage?: StorageManager,
): Promise<NanoVDBOPFSOpenResult> {
validateSha256(bundleSha256);
if (conversionRequestSha256) validateSha256(conversionRequestSha256);
const cache = await rootFor(projectId, storage);
const bundle = await directory(cache, bundleSha256, false);
const manifest = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(bundle, "manifest.json"));
if (manifest.projectId !== projectId || manifest.bundleSha256 !== bundleSha256) throw new Error("NANOVDB_HASH_MISMATCH: OPFS bundle identity mismatch");
let binding: VDBProjectBindingIR | undefined;
let bindingStatus: VDBProjectBindingStatusIR | undefined;
if (conversionRequestSha256) {
const bindings = await directory(cache, "bindings");
try { binding = validateVDBProjectBinding(await readJson<VDBProjectBindingIR>(bindings, `${conversionRequestSha256}.json`)); }
catch { binding = undefined; }
if (binding && binding.manifestSha256 !== await digestJson(manifest)) throw new Error("NANOVDB_HASH_MISMATCH: OPFS manifest changed after project commit");
if (reopenContext) bindingStatus = evaluateVDBProjectBinding(binding, reopenContext);
}
await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength }));
const source: NanoVDBRangeSource = async (range, signal) => {
if (signal.aborted) throw new DOMException("NanoVDB OPFS read cancelled", "AbortError");
const declared = manifest.chunks[range.chunkIndex];
if (!declared || range.start !== declared.byteOffset || range.endExclusive !== declared.byteOffset + declared.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS range is not a declared chunk");
const file = await (await bundle.getFileHandle(chunkName(range.chunkIndex))).getFile();
if (file.size !== declared.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS chunk length mismatch");
const data = await file.arrayBuffer();
await verifyNanoVDBChunk(declared, data);
return data;
};
return { manifest, binding, bindingStatus, source };
}
export async function recoverNanoVDBOPFS(projectId: string, storage?: StorageManager): Promise<{ removedStaging: number; removedIncompleteBundles: number }> {
const cache = await rootFor(projectId, storage);
let removedStaging = 0;
let removedIncompleteBundles = 0;
const entries = (cache as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name, handle] of entries) {
if (handle.kind !== "directory" || name === "bindings") continue;
if (!/^[a-f0-9]{64}$/.test(name)) { await remove(cache, name, true); removedIncompleteBundles++; continue; }
const bundle = handle as FileSystemDirectoryHandle;
let validManifest: boolean;
try { validManifest = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(bundle, "manifest.json")).bundleSha256 === name; }
catch { validManifest = false; }
if (!validManifest) { await remove(cache, name, true); removedIncompleteBundles++; continue; }
const files = (bundle as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [fileName] of files) if (fileName.endsWith(".stage")) { await remove(bundle, fileName); removedStaging++; }
}
return { removedStaging, removedIncompleteBundles };
}
export async function pruneNanoVDBOPFS(projectId: string, maxBytes: number, storage?: StorageManager): Promise<{ removed: string[]; retainedBytes: number }> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid OPFS cache budget");
const cache = await rootFor(projectId, storage);
const bundles: Array<{ name: string; bytes: number; lastAccessAt: string }> = [];
const entries = (cache as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name, handle] of entries) {
if (handle.kind !== "directory" || !/^[a-f0-9]{64}$/.test(name)) continue;
try {
const access = await readJson<{ bytes: number; lastAccessAt: string }>(handle as FileSystemDirectoryHandle, "access.json");
if (Number.isSafeInteger(access.bytes) && access.bytes > 0 && Number.isFinite(Date.parse(access.lastAccessAt))) bundles.push({ name, ...access });
}
catch { /* Recovery owns incomplete entries. */ }
}
let total = bundles.reduce((sum, item) => sum + item.bytes, 0);
const removed: string[] = [];
for (const item of bundles.sort((left, right) => left.lastAccessAt.localeCompare(right.lastAccessAt))) {
if (total <= maxBytes) break;
await remove(cache, item.name, true);
total -= item.bytes;
removed.push(item.name);
}
return { removed, retainedBytes: total };
}

View File

@@ -0,0 +1,190 @@
import {
planNanoVDBRanges,
validateNanoVDBBundleManifest,
verifyNanoVDBChunk,
type NanoVDBBundleManifestIR,
type NanoVDBRangeIR,
} from "../../../protocol/volume-vdb";
export interface NanoVDBStreamProgressIR {
completedChunks: number;
totalChunks: number;
completedBytes: number;
totalBytes: number;
}
export interface NanoVDBStreamResultIR extends NanoVDBStreamProgressIR {
declaredBundleSha256: string;
}
export type NanoVDBRangeSource = (range: NanoVDBRangeIR, signal: AbortSignal) => Promise<ArrayBuffer>;
export type NanoVDBChunkConsumer = (range: NanoVDBRangeIR, data: ArrayBuffer, signal: AbortSignal) => Promise<void> | void;
function cancelled(signal: AbortSignal): void {
if (signal.aborted) throw new DOMException("NanoVDB stream cancelled", "AbortError");
}
export async function streamNanoVDBChunks(
manifestValue: NanoVDBBundleManifestIR,
source: NanoVDBRangeSource,
consume: NanoVDBChunkConsumer,
signal: AbortSignal,
onProgress?: (progress: NanoVDBStreamProgressIR) => void,
): Promise<NanoVDBStreamResultIR> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const ranges = planNanoVDBRanges(manifest);
let completedBytes = 0;
for (const range of ranges) {
cancelled(signal);
const data = await source(range, signal);
cancelled(signal);
await verifyNanoVDBChunk(manifest.chunks[range.chunkIndex], data);
cancelled(signal);
await consume(range, data, signal);
completedBytes += data.byteLength;
onProgress?.({
completedChunks: range.chunkIndex + 1,
totalChunks: ranges.length,
completedBytes,
totalBytes: manifest.bundleByteLength,
});
}
return {
completedChunks: ranges.length,
totalChunks: ranges.length,
completedBytes,
totalBytes: manifest.bundleByteLength,
declaredBundleSha256: manifest.bundleSha256,
};
}
function parseContentRange(value: string | null): { start: number; endInclusive: number; total: number } | undefined {
const match = value?.match(/^bytes (\d+)-(\d+)\/(\d+)$/);
if (!match) return undefined;
const start = Number(match[1]);
const endInclusive = Number(match[2]);
const total = Number(match[3]);
if (![start, endInclusive, total].every(Number.isSafeInteger)) return undefined;
return { start, endInclusive, total };
}
export function createHttpNanoVDBRangeSource(
url: string,
expectedBundleBytes: number,
fetcher: typeof fetch = fetch,
): NanoVDBRangeSource {
return createResumableHttpNanoVDBRangeSource(url, expectedBundleBytes, { fetcher, retries: 0, requireStableEtag: false });
}
export interface NanoVDBHttpRangeOptions {
fetcher?: typeof fetch;
retries?: number;
retryDelayMs?: number;
requireStableEtag?: boolean;
}
async function waitForHttpRetry(delayMs: number, attempt: number, signal: AbortSignal): Promise<void> {
if (delayMs === 0) return;
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer);
reject(new DOMException("NanoVDB HTTP range cancelled", "AbortError"));
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, delayMs * (attempt + 1));
signal.addEventListener("abort", onAbort, { once: true });
});
}
function protocolFailure(error: unknown): boolean {
return error instanceof Error && /^(?:NANOVDB_|VDB_)/.test(error.message);
}
export function createResumableHttpNanoVDBRangeSource(
url: string,
expectedBundleBytes: number,
options: NanoVDBHttpRangeOptions = {},
): NanoVDBRangeSource {
if (!url || !Number.isSafeInteger(expectedBundleBytes) || expectedBundleBytes <= 0) throw new Error("NANOVDB_MANIFEST_INVALID: HTTP range source is invalid");
const fetcher = options.fetcher ?? fetch;
const retries = options.retries ?? 2;
const retryDelayMs = options.retryDelayMs ?? 25;
if (!Number.isSafeInteger(retries) || retries < 0 || retries > 8 || !Number.isSafeInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 10_000) {
throw new Error("NANOVDB_MANIFEST_INVALID: HTTP retry policy is invalid");
}
let etag: string | undefined;
return async (range, signal) => {
if (!Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.endExclusive) || range.start < 0 || range.endExclusive <= range.start || range.endExclusive > expectedBundleBytes) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range is outside the NanoVDB bundle");
}
const output = new Uint8Array(range.endExclusive - range.start);
let written = 0;
let lastStatus = 0;
let lastFailure = "network interruption";
for (let attempt = 0; attempt <= retries; attempt++) {
if (signal.aborted) throw new DOMException("NanoVDB HTTP range cancelled", "AbortError");
const requestStart = range.start + written;
const headers: Record<string, string> = { Range: `bytes=${requestStart}-${range.endExclusive - 1}` };
if (etag) headers["If-Range"] = etag;
let response: Response;
try {
response = await fetcher(url, { method: "GET", headers, signal, cache: "no-store" });
}
catch (error) {
if (signal.aborted || error instanceof DOMException && error.name === "AbortError") throw new DOMException("NanoVDB HTTP range cancelled", "AbortError");
lastFailure = error instanceof Error ? error.message : String(error);
if (attempt === retries) break;
await waitForHttpRetry(retryDelayMs, attempt, signal);
continue;
}
lastStatus = response.status;
if (response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500) {
if (attempt === retries) break;
await waitForHttpRetry(retryDelayMs, attempt, signal);
continue;
}
if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: HTTP range request returned ${response.status}, expected 206`);
const responseEtag = response.headers.get("ETag") ?? undefined;
if (etag && responseEtag !== etag) throw new Error("NANOVDB_HASH_MISMATCH: HTTP ETag changed during NanoVDB streaming");
if (!etag && responseEtag) etag = responseEtag;
if (options.requireStableEtag && !etag) throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP ETag is required for resumable streaming");
const contentRange = parseContentRange(response.headers.get("Content-Range"));
if (!contentRange || contentRange.start !== requestStart || contentRange.endInclusive !== range.endExclusive - 1 || contentRange.total !== expectedBundleBytes) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP Content-Range does not match the NanoVDB manifest");
}
if (!response.body) throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response has no body");
const reader = response.body.getReader();
try {
while (true) {
const next = await reader.read();
if (next.done) break;
const value = next.value;
if (written + value.byteLength > output.byteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response exceeds the requested byte length");
}
output.set(value, written);
written += value.byteLength;
}
}
catch (error) {
if (signal.aborted || error instanceof DOMException && error.name === "AbortError") throw new DOMException("NanoVDB HTTP range cancelled", "AbortError");
if (protocolFailure(error)) throw error;
if (written === output.byteLength) return output.buffer;
lastFailure = error instanceof Error ? error.message : String(error);
if (attempt === retries) break;
await waitForHttpRetry(retryDelayMs, attempt, signal);
continue;
}
finally {
reader.releaseLock();
}
if (written !== output.byteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response has an unexpected byte length");
}
return output.buffer;
}
throw new Error(`NANOVDB_STREAM_INCOMPLETE: HTTP range retry budget exhausted after ${lastStatus ? `status ${lastStatus}` : lastFailure}`);
};
}

View File

@@ -0,0 +1,256 @@
import {
validateNanoVDBBundleManifest,
verifyNanoVDBChunk,
type NanoVDBBundleManifestIR,
type NanoVDBGridIR,
type NanoVDBMaterialIR,
type NanoVDBRangeIR,
} from "../../../protocol/volume-vdb";
import {
NanoVDBWebGPUDeviceSession,
probeNanoVDBWebGPU,
renderNanoVDBFloat32WebGPU,
uploadNanoVDBFloat32GridPaged,
type NanoVDBWebGPUCapabilityIR,
} from "../render/nanovdb-volume-renderer";
import { createResumableHttpNanoVDBRangeSource } from "./nanovdb-stream";
import type { NanoVDBRangeSource } from "./nanovdb-stream";
import {
commitNanoVDBToOPFS,
createVDBProjectBinding,
listVDBProjectBindings,
openNanoVDBFromOPFS,
} from "./nanovdb-opfs";
export interface NanoVDBViewportGridPayloadIR {
name: string;
data: ArrayBuffer;
}
export interface NanoVDBViewportAssetIR {
dataId: string;
manifest: NanoVDBBundleManifestIR;
grids: NanoVDBViewportGridPayloadIR[];
material?: NanoVDBMaterialIR;
}
export interface NanoVDBViewportRenderResultIR {
dataId: string;
grid: NanoVDBGridIR;
material: NanoVDBMaterialIR;
pixels: Uint8Array;
width: number;
height: number;
capability: NanoVDBWebGPUCapabilityIR;
}
export interface NanoVDBViewportProjectContextIR {
projectId: string;
sourceBlendSha256: string;
}
function residentBytes(payloadBytes: number, pageBytes: number, maxResidentBytes: number): number {
const pageCount = Math.ceil(payloadBytes / pageBytes);
return Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageBytes))) * pageBytes;
}
/** Keeps the WebGPU device alive for a production viewport and rebuilds it after loss. */
export class NanoVDBViewportRenderSession {
private readonly deviceSession = new NanoVDBWebGPUDeviceSession();
private readonly removeLossListener: () => void;
private disposed = false;
constructor(private readonly onDeviceLost?: () => void) {
this.removeLossListener = this.deviceSession.onDeviceLost(() => this.onDeviceLost?.());
}
async render(value: NanoVDBViewportAssetIR, width = 128, height = 128): Promise<NanoVDBViewportRenderResultIR> {
if (this.disposed) throw new Error("VOLUME_SHADER_UNAVAILABLE: viewport render session is disposed");
const asset = validateNanoVDBViewportAsset(value);
const grid = densityGrid(asset.manifest);
const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data;
const requiredBytes = residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
let device = this.deviceSession.device;
if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes);
for (let attempt = 0; attempt < 2; attempt++) {
const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
try {
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, grid, asset.material ?? asset.manifest.material, width, height);
return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: {
available: true,
maxStorageBufferBindingSize: Number(device.limits.maxStorageBufferBindingSize),
maxBufferSize: Number(device.limits.maxBufferSize),
} };
}
catch (error) {
if (this.deviceSession.status !== "lost" || attempt !== 0) throw error;
device = await this.deviceSession.recover(requiredBytes);
}
finally { uploaded.dispose(); }
}
throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU device recovery exhausted");
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.removeLossListener();
this.deviceSession.dispose();
}
}
function densityGrid(manifest: NanoVDBBundleManifestIR): NanoVDBGridIR {
const grid = manifest.grids.find((candidate) => candidate.name === manifest.material.densityGrid);
if (!grid || grid.semantic !== "DENSITY" || grid.valueType !== "FLOAT32") {
throw new Error("NANOVDB_GRID_UNSUPPORTED: production viewport requires a Float32 density grid");
}
return grid;
}
async function loadDensityPayload(
manifest: NanoVDBBundleManifestIR,
source: NanoVDBRangeSource,
signal: AbortSignal,
): Promise<ArrayBuffer> {
const grid = densityGrid(manifest);
const payload = new Uint8Array(grid.byteLength);
let copiedBytes = 0;
for (const chunk of manifest.chunks) {
const chunkEnd = chunk.byteOffset + chunk.byteLength;
const gridEnd = grid.byteOffset + grid.byteLength;
const overlapStart = Math.max(chunk.byteOffset, grid.byteOffset);
const overlapEnd = Math.min(chunkEnd, gridEnd);
if (overlapEnd <= overlapStart) continue;
const range: NanoVDBRangeIR = { chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunkEnd, sha256: chunk.sha256 };
const data = await source(range, signal);
await verifyNanoVDBChunk(chunk, data);
const sourceOffset = overlapStart - chunk.byteOffset;
const targetOffset = overlapStart - grid.byteOffset;
const overlapLength = overlapEnd - overlapStart;
payload.set(new Uint8Array(data, sourceOffset, overlapLength), targetOffset);
copiedBytes += overlapLength;
}
if (copiedBytes !== grid.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: density grid ranges are incomplete");
return payload.buffer;
}
export function validateNanoVDBViewportAsset(value: NanoVDBViewportAssetIR): NanoVDBViewportAssetIR {
if (!value.dataId || value.dataId.length > 256) throw new Error("NANOVDB_MANIFEST_INVALID: viewport dataId");
const manifest = validateNanoVDBBundleManifest(value.manifest);
const grid = densityGrid(manifest);
const payload = value.grids.find((candidate) => candidate.name === grid.name);
if (!payload || payload.data.byteLength !== grid.byteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: viewport density payload does not match the manifest");
}
return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material };
}
export function cloneNanoVDBViewportAssets(assets: readonly NanoVDBViewportAssetIR[]): NanoVDBViewportAssetIR[] {
return assets.map((asset) => ({
...asset,
manifest: structuredClone(asset.manifest),
material: asset.material ? structuredClone(asset.material) : undefined,
grids: asset.grids.map((grid) => ({ name: grid.name, data: grid.data.slice(0) })),
}));
}
export function nanoVDBViewportAssetTransferables(assets: readonly NanoVDBViewportAssetIR[]): Transferable[] {
return assets.flatMap((asset) => asset.grids.map((grid) => grid.data));
}
export async function loadNanoVDBViewportAsset(
dataId: string,
manifestUrl: string,
bundleUrl: string,
signal: AbortSignal,
fetcher: typeof fetch = fetch,
): Promise<NanoVDBViewportAssetIR> {
const response = await fetcher(manifestUrl, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`);
const manifest = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR);
const grid = densityGrid(manifest);
const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true });
const payload = await loadDensityPayload(manifest, source, signal);
return validateNanoVDBViewportAsset({
dataId,
manifest,
grids: [{ name: grid.name, data: payload }],
});
}
export async function reopenNanoVDBViewportAssetFromOPFS(
dataId: string,
sourcePath: string,
context: NanoVDBViewportProjectContextIR,
signal: AbortSignal,
): Promise<NanoVDBViewportAssetIR> {
const bindings = await listVDBProjectBindings(context.projectId);
const candidates = bindings.filter((binding) => binding.sourcePath === sourcePath);
let lastBlockedCode = "VDB_BINDING_MISSING";
for (const binding of candidates) {
const opened = await openNanoVDBFromOPFS(context.projectId, binding.bundleSha256, binding.conversionRequestSha256, {
projectId: context.projectId,
sourceBlendSha256: context.sourceBlendSha256,
sourcePath,
sourceSha256: binding.sourceSha256,
converter: binding.converter,
shaderSemanticVersion: "volume-wgsl-v1",
});
if (opened.bindingStatus?.status !== "READY") {
lastBlockedCode = opened.bindingStatus?.code ?? lastBlockedCode;
continue;
}
const grid = densityGrid(opened.manifest);
return validateNanoVDBViewportAsset({
dataId,
manifest: opened.manifest,
grids: [{ name: grid.name, data: await loadDensityPayload(opened.manifest, opened.source, signal) }],
});
}
throw new Error(`${lastBlockedCode}: no current NanoVDB project binding is available`);
}
export async function loadAndCommitNanoVDBViewportAsset(
dataId: string,
sourcePath: string,
manifestUrl: string,
bundleUrl: string,
context: NanoVDBViewportProjectContextIR,
signal: AbortSignal,
fetcher: typeof fetch = fetch,
): Promise<NanoVDBViewportAssetIR> {
const response = await fetcher(manifestUrl, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`);
const received = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR);
if (received.sourcePath !== sourcePath) throw new Error("NANOVDB_HASH_MISMATCH: manifest source path does not match the Volume binding");
const manifest = validateNanoVDBBundleManifest({ ...received, projectId: context.projectId });
const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true });
const binding = await createVDBProjectBinding(manifest, context.sourceBlendSha256);
await commitNanoVDBToOPFS(manifest, source, signal, binding);
return reopenNanoVDBViewportAssetFromOPFS(dataId, sourcePath, context, signal);
}
export async function renderNanoVDBViewportAsset(
value: NanoVDBViewportAssetIR,
width = 128,
height = 128,
session?: NanoVDBViewportRenderSession,
): Promise<NanoVDBViewportRenderResultIR> {
if (session) return session.render(value, width, height);
const asset = validateNanoVDBViewportAsset(value);
const grid = densityGrid(asset.manifest);
const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data;
const probe = await probeNanoVDBWebGPU(residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes));
if (!probe.capability.available || !probe.device) {
throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU device unavailable"}`);
}
const uploaded = uploadNanoVDBFloat32GridPaged(probe.device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
try {
const pixels = await renderNanoVDBFloat32WebGPU(probe.device, uploaded, grid, asset.material ?? asset.manifest.material, width, height);
return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: probe.capability };
}
finally {
uploaded.dispose();
probe.device.destroy();
}
}

View File

@@ -0,0 +1,94 @@
import {
validateNanoVDBBundleManifest,
type NanoVDBBundleManifestIR,
type NanoVDBGridSemantic,
type NanoVDBMaterialIR,
} from "../../../protocol/volume-vdb";
export interface PrincipledVolumeMappingInputIR {
densityGrid: string;
densityScale: number;
color?: [number, number, number];
colorGrid?: string;
temperatureGrid?: string;
temperatureScale?: number;
blackbodyEnabled?: boolean;
emissionGrid?: string;
emissionColor?: [number, number, number];
emissionScale?: number;
velocityGrid?: string;
anisotropy?: number;
interpolation?: "NEAREST" | "LINEAR";
}
export interface VolumeMaterialMappingLossIR {
code:
| "VOLUME_COLOR_GRID_UNSUPPORTED"
| "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED"
| "VOLUME_EMISSION_GRID_UNSUPPORTED"
| "VOLUME_VELOCITY_RENDER_UNSUPPORTED";
field: "colorGrid" | "temperatureGrid" | "emissionGrid" | "velocityGrid";
fallback: string;
}
export interface VolumeMaterialMappingResultIR {
material: NanoVDBMaterialIR;
losses: VolumeMaterialMappingLossIR[];
supportedSemantics: Array<"DENSITY_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">;
}
function finite(value: number, minimum: number, maximum: number, name: string): number {
if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`NANOVDB_MANIFEST_INVALID: ${name}`);
return value;
}
function color(value: [number, number, number] | undefined, fallback: [number, number, number], name: string): [number, number, number] {
const result = value ?? fallback;
if (!Array.isArray(result) || result.length !== 3 || result.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1_000_000)) throw new Error(`NANOVDB_MANIFEST_INVALID: ${name}`);
return [...result];
}
function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string): string | undefined {
if (name === undefined) return undefined;
const grid = manifest.grids.find((candidate) => candidate.name === name);
if (!grid || grid.semantic !== semantic) throw new Error(`NANOVDB_MANIFEST_INVALID: ${field} must reference a ${semantic} grid`);
return name;
}
export function mapPrincipledVolumeToNanoVDB(
sourceManifest: NanoVDBBundleManifestIR,
input: PrincipledVolumeMappingInputIR,
): VolumeMaterialMappingResultIR {
const manifest = validateNanoVDBBundleManifest(sourceManifest);
const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid");
if (!densityGrid) throw new Error("NANOVDB_MANIFEST_INVALID: a density grid is required");
const colorGrid = requireGrid(manifest, input.colorGrid, "COLOR", "colorGrid");
const temperatureGrid = requireGrid(manifest, input.temperatureGrid, "TEMPERATURE", "temperatureGrid");
const emissionGrid = requireGrid(manifest, input.emissionGrid, "EMISSION", "emissionGrid");
const velocityGrid = requireGrid(manifest, input.velocityGrid, "VELOCITY", "velocityGrid");
const material: NanoVDBMaterialIR = {
densityGrid,
...(colorGrid ? { colorGrid } : {}),
...(temperatureGrid ? { temperatureGrid } : {}),
...(emissionGrid ? { emissionGrid } : {}),
...(velocityGrid ? { velocityGrid } : {}),
densityScale: finite(input.densityScale, 0, 1_000_000, "densityScale"),
emissionScale: finite(input.emissionScale ?? 0, 0, 1_000_000, "emissionScale"),
temperatureScale: finite(input.temperatureScale ?? 1, 0, 1_000_000, "temperatureScale"),
anisotropy: finite(input.anisotropy ?? 0, -0.99, 0.99, "anisotropy"),
interpolation: input.interpolation ?? "LINEAR",
color: color(input.color, [0.72, 0.78, 0.86], "color"),
emissionColor: color(input.emissionColor, [1, 1, 1], "emissionColor"),
};
if (material.interpolation !== "NEAREST" && material.interpolation !== "LINEAR") throw new Error("NANOVDB_MANIFEST_INVALID: interpolation");
const losses: VolumeMaterialMappingLossIR[] = [];
if (colorGrid) losses.push({ code: "VOLUME_COLOR_GRID_UNSUPPORTED", field: "colorGrid", fallback: "constant color" });
if (temperatureGrid && input.blackbodyEnabled) losses.push({ code: "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED", field: "temperatureGrid", fallback: "constant emission color" });
if (emissionGrid) losses.push({ code: "VOLUME_EMISSION_GRID_UNSUPPORTED", field: "emissionGrid", fallback: "constant emission color and scale" });
if (velocityGrid) losses.push({ code: "VOLUME_VELOCITY_RENDER_UNSUPPORTED", field: "velocityGrid", fallback: "velocity metadata retained without motion rendering" });
return {
material,
losses,
supportedSemantics: ["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"],
};
}

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View 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);
};

View File

@@ -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);
};

View File

@@ -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("");

View File

@@ -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);
};

View File

@@ -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);
};

View 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);
};

View File

@@ -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); }

View File

@@ -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);
};

View File

@@ -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); }

View 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) }));
};

View 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) }));
};

View File

@@ -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) }));
};

View 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) }));
};

View File

@@ -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) {

View File

@@ -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 } {