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,7 +13,7 @@
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c",
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
"required": true
}
]

Binary file not shown.

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

View File

@@ -1,5 +1,9 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import fs from "node:fs";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import path from "node:path";
const isolationHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
@@ -21,9 +25,51 @@ function preserveIsolationHeaders(): Plugin {
};
}
function localVDBFixture(): Plugin {
const resourceRoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb";
const files = new Map([
["/__vdb_fixture__/manifest", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }],
["/__vdb_fixture__/report", { path: path.join(resourceRoot, "reports/generated-smoke-conversion.json"), type: "application/json" }],
["/__vdb_fixture__/bundle", { path: path.join(resourceRoot, "nanovdb/generated-smoke.nvdb"), type: "application/x-nanovdb" }],
["/assets/volumes/generated-smoke.nanovdb.json", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }],
["/assets/volumes/generated-smoke.nvdb", { path: path.join(resourceRoot, "nanovdb/generated-smoke.nvdb"), type: "application/x-nanovdb" }],
["/volumes/generated-smoke.nanovdb.json", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }],
["/volumes/generated-smoke.nvdb", { path: path.join(resourceRoot, "nanovdb/generated-smoke.nvdb"), type: "application/x-nanovdb" }],
]);
return {
name: "local-vdb-fixture",
configureServer(server) {
server.middlewares.use((request, response, next) => {
const nodeRequest = request as unknown as { url?: string; headers: { range?: string } };
const pathname = nodeRequest.url?.split("?", 1)[0] ?? "";
const fixture = files.get(pathname);
if (!fixture || !fs.existsSync(fixture.path)) { next(); return; }
const stat = fs.statSync(fixture.path);
response.setHeader("Content-Type", fixture.type);
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Cache-Control", "no-store");
response.setHeader("ETag", `"vdb-${stat.size}-${Math.trunc(stat.mtimeMs)}"`);
const match = nodeRequest.headers.range?.match(/^bytes=(\d+)-(\d+)$/);
if (match) {
const start = Number(match[1]);
const end = Number(match[2]);
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= stat.size) { response.writeHead(416); response.end(); return; }
response.statusCode = 206;
response.setHeader("Content-Range", `bytes ${start}-${end}/${stat.size}`);
response.setHeader("Content-Length", end - start + 1);
fs.createReadStream(fixture.path, { start, end }).pipe(response);
return;
}
response.setHeader("Content-Length", stat.size);
fs.createReadStream(fixture.path).pipe(response);
});
},
};
}
export default defineConfig({
root: "app",
plugins: [preserveIsolationHeaders(), react()],
plugins: [preserveIsolationHeaders(), localVDBFixture(), react()],
server: {
port: 5173,
strictPort: false,

View File

@@ -13,6 +13,11 @@
"test:e2e": "playwright test --config playwright.config.ts",
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
"test:simulation-cache-performance": "playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts",
"test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts",
"test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts",
"test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts",
"test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts",
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
"test:browser": "playwright test --config playwright.release.config.ts",
"test:cross-browser": "npm run test:browser",
@@ -21,7 +26,7 @@
"test:topology-collapse": "node ../tools/web/check-topology-collapse.mjs",
"test:depsgraph": "node ../tools/web/check-depsgraph.mjs",
"test:nonmesh-binary": "playwright test --config playwright.config.ts -g \"one-million-point binary transfer gate\"",
"test:vdb": "playwright test --config playwright.config.ts -g \"OpenVDB metadata\"",
"test:vdb": "playwright test --config playwright.config.ts -g \"VDB conversion boundary\"",
"test:frame-evaluation": "node ../tools/web/check-frame-evaluation.mjs",
"test:pose-constraint-goldens": "node ../tools/web/check-pose-constraint-goldens.mjs",
"test:main-roundtrip": "node ../tools/web/check-main-roundtrip.mjs",
@@ -30,6 +35,12 @@
"test:selection-history": "playwright test --config playwright.config.ts -g \"N-015 selection history\"",
"test:nonmesh-interaction": "playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"",
"test:vdb-availability": "node ../tools/web/check-vdb-availability.mjs",
"test:vdb-native": "node ../tools/web/check-vdb-native-pipeline.mjs",
"test:vdb-server": "node ../tools/web/check-vdb-server-job.mjs",
"test:vdb-opfs": "playwright test --config playwright.config.ts -g \"hash-bound NanoVDB project through OPFS\"",
"test:vdb-webgpu": "playwright test --config playwright.config.ts -g \"real NanoVDB Float32 tree with WebGPU\"",
"test:vdb-viewport": "playwright test --config playwright.config.ts -g \"both production viewport backends\"",
"test:vdb-faults": "playwright test --config playwright.config.ts -g \"NanoVDB paging from network, Worker and WebGPU device faults\"",
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
"test:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"",
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",

View File

@@ -16,7 +16,7 @@ export default defineConfig({
headless: true,
launchOptions: {
executablePath: chromePath,
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
},
screenshot: "only-on-failure",
trace: "retain-on-failure",

View File

@@ -36,6 +36,7 @@ export interface AssetEntryIR {
export interface AssetLibraryIR { id: string; name: string; sourcePath: string; sourceSha256: string; dependencyIds: string[]; readOnly: boolean }
export interface AssetLibraryManifestIR { schemaVersion: typeof ASSET_LIBRARY_SCHEMA; revision: number; catalogs: AssetCatalogIR[]; assets: AssetEntryIR[]; libraries: AssetLibraryIR[] }
export interface IOArchiveEntryIR { path: string; compressedBytes: number; uncompressedBytes: number }
export interface IOArchiveRangeIR extends IOArchiveEntryIR { compressedOffset: number }
export interface IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] }
export class AssetLibraryValidationError extends Error {
@@ -101,6 +102,25 @@ export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): vo
if (!SHA256.test(actualSha256) || actualSha256 !== asset.sourceSha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Source hash does not match ${asset.id}`);
}
async function sha256(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("");
}
export async function verifyAssetPreview(preview: AssetPreviewIR, data: ArrayBuffer): Promise<void> {
if (!(data instanceof ArrayBuffer) || data.byteLength !== preview.byteLength || await sha256(data) !== preview.sha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Preview hash or byte length does not match ${preview.assetId}`);
const bytes = new Uint8Array(data);
if (preview.mimeType === "image/png") {
if (bytes.length < 24 || ![137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not PNG data`);
const view = new DataView(data);
if (view.getUint32(16, false) !== preview.width || view.getUint32(20, false) !== preview.height) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} PNG dimensions do not match the manifest`);
}
else {
const riff = bytes.length >= 30 && String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP";
if (!riff) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not WebP data`);
}
}
export function parseIORequest(value: unknown): IORequestIR {
if (!record(value) || !FORMATS.has(value.format as IOFormat) || !["IMPORT", "EXPORT", "ANALYZE"].includes(value.operation as string) || !Array.isArray(value.externalUris) || !Array.isArray(value.archiveEntries)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "IO request is invalid");
if (value.externalUris.length > ASSET_LIBRARY_BUDGET.maxExternalUris || value.archiveEntries.length > ASSET_LIBRARY_BUDGET.maxArchiveEntries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "IO request exceeds the resource budget");
@@ -108,16 +128,32 @@ export function parseIORequest(value: unknown): IORequestIR {
if (value.sourcePath !== undefined) request.sourcePath = projectPath(value.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED");
if (value.sourceSha256 !== undefined) request.sourceSha256 = digest(value.sourceSha256, "sourceSha256");
if (value.byteLength !== undefined) request.byteLength = integer(value.byteLength, "byteLength", 0, ASSET_LIBRARY_BUDGET.maxArchiveBytes);
let totalUncompressed = 0;
let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set<string>();
request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => {
if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`);
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = integer(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = integer(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
totalUncompressed += uncompressedBytes; if (!Number.isSafeInteger(totalUncompressed) || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget");
if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`);
archivePaths.add(path);
totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes;
if (!Number.isSafeInteger(totalCompressed) || !Number.isSafeInteger(totalUncompressed) || totalCompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget");
return { path, compressedBytes, uncompressedBytes };
});
if (request.byteLength !== undefined && totalCompressed > request.byteLength) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive compressed entries exceed the declared source byte length");
return request;
}
/** Builds a deterministic bounded range plan; it does not decode or trust an archive container. */
export function planIOArchiveRanges(value: unknown): IOArchiveRangeIR[] {
const request = parseIORequest(value);
let compressedOffset = 0;
return [...request.archiveEntries].sort((left, right) => left.path.localeCompare(right.path)).map((entry) => {
const range = { ...entry, compressedOffset };
compressedOffset += entry.compressedBytes;
if (!Number.isSafeInteger(compressedOffset) || compressedOffset > ASSET_LIBRARY_BUDGET.maxArchiveBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive range offset exceeds the byte budget");
return range;
});
}
export function gateIORequest(value: unknown): CapabilityGateResult {
const request = parseIORequest(value); const capability = `${request.format}_${request.operation}`;
if ((request.format === "GLB" && (request.operation === "ANALYZE" || request.operation === "EXPORT")) || (request.format === "USD" && request.operation === "ANALYZE")) return readyGate("N-023", capability);

View File

@@ -11,6 +11,7 @@ export const COMPOSITOR_BUDGET = {
maxImageBytes: 256 * 1024 * 1024,
maxBlurRadius: 32,
maxOperations: 100_000_000,
maxFrameCacheBytes: 256 * 1024 * 1024,
} as const;
export const COMPOSITOR_NODE_TYPES = [
@@ -77,6 +78,69 @@ export interface CompositorExecutionResult {
evaluatedNodeIds: string[];
}
export interface CompositorCachedExecutionResult extends CompositorExecutionResult {
cacheKey: string;
cacheHit: boolean;
}
interface CompositorFrameCacheEntry {
result: CompositorExecutionResult;
byteLength: number;
}
function cloneImage(image: CompositorImageBuffer): CompositorImageBuffer {
return { ...image, data: image.data.slice() };
}
function cloneExecution(result: CompositorExecutionResult): CompositorExecutionResult {
return { composite: cloneImage(result.composite), viewers: new Map([...result.viewers].map(([id, image]) => [id, cloneImage(image)])), evaluatedNodeIds: [...result.evaluatedNodeIds] };
}
function executionBytes(result: CompositorExecutionResult): number {
const unique = new Set<ArrayBuffer>();
unique.add(result.composite.data.buffer as ArrayBuffer);
for (const image of result.viewers.values()) unique.add(image.data.buffer as ArrayBuffer);
return [...unique].reduce((total, buffer) => total + buffer.byteLength, 0);
}
export class CompositorFrameCache {
readonly maxBytes: number;
private readonly entries = new Map<string, CompositorFrameCacheEntry>();
private currentBytes = 0;
constructor(maxBytes = COMPOSITOR_BUDGET.maxFrameCacheBytes) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > COMPOSITOR_BUDGET.maxFrameCacheBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame cache byte budget is invalid");
this.maxBytes = maxBytes;
}
get byteLength(): number { return this.currentBytes; }
get size(): number { return this.entries.size; }
get(key: string): CompositorExecutionResult | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
this.entries.delete(key);
this.entries.set(key, entry);
return cloneExecution(entry.result);
}
set(key: string, result: CompositorExecutionResult): void {
const clone = cloneExecution(result);
const byteLength = executionBytes(clone);
if (byteLength > this.maxBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame exceeds the cache byte budget");
const previous = this.entries.get(key);
if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); }
while (this.currentBytes + byteLength > this.maxBytes) {
const oldest = this.entries.entries().next().value as [string, CompositorFrameCacheEntry] | undefined;
if (!oldest) break;
this.entries.delete(oldest[0]);
this.currentBytes -= oldest[1].byteLength;
}
this.entries.set(key, { result: clone, byteLength });
this.currentBytes += byteLength;
}
}
export class CompositorValidationError extends Error {
readonly code: ErrorCode;
@@ -267,6 +331,11 @@ export function executeCompositorGraph(
const outputs = new Map<string, CompositorImageBuffer>();
const viewers = new Map<string, CompositorImageBuffer>();
const evaluatedNodeIds: string[] = [];
let operationCounter = 0;
const checkCancelled = (operations = 1): void => {
operationCounter += operations;
if ((operationCounter === operations || operationCounter % 16_384 < operations) && options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
};
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
const image = source ? outputs.get(source) : undefined;
@@ -279,7 +348,7 @@ export function executeCompositorGraph(
const evaluate = (id: string): CompositorImageBuffer => {
const existing = outputs.get(id);
if (existing) return existing;
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
checkCancelled();
const node = byId.get(id);
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`);
for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId);
@@ -297,7 +366,7 @@ export function executeCompositorGraph(
const height = options.height ?? 1;
output = allocate(width, height);
const color = node.properties.color as number[];
for (let offset = 0; offset < output.data.length; offset += 4) output.data.set(color, offset);
for (let offset = 0; offset < output.data.length; offset += 4) { checkCancelled(); output.data.set(color, offset); }
}
else if (node.type === "TRANSFORM") {
const input = requireInput(id, "Image");
@@ -305,6 +374,7 @@ export function executeCompositorGraph(
const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0);
const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1);
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
checkCancelled();
const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy);
if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue;
output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4);
@@ -315,6 +385,7 @@ export function executeCompositorGraph(
output = allocate(input.width, input.height);
const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1;
for (let offset = 0; offset < input.data.length; offset += 4) {
checkCancelled();
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier;
output.data[offset + 3] = input.data[offset + 3];
}
@@ -325,6 +396,7 @@ export function executeCompositorGraph(
sameSize(left, right, id);
output = allocate(left.width, left.height);
for (let offset = 0; offset < left.data.length; offset += 4) {
checkCancelled();
if (node.type === "MIX") {
const factor = Number(node.properties.factor ?? 0.5);
for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor;
@@ -344,6 +416,7 @@ export function executeCompositorGraph(
if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`);
output = allocate(input.width, input.height);
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
checkCancelled((radius * 2 + 1) ** 2);
const target = (y * input.width + x) * 4;
let samples = 0;
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
@@ -366,3 +439,52 @@ export function executeCompositorGraph(
};
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
}
async function sha256Bytes(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("");
}
function stableJSON(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
return JSON.stringify(value);
}
export async function compositorFrameCacheKey(
value: unknown,
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
frame: number,
width?: number,
height?: number,
): Promise<string> {
const graph = parseCompositorGraph(value);
if (!Number.isSafeInteger(frame) || frame < -1_000_000 || frame > 1_000_000) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "Compositor cache frame is invalid");
const sources: Array<{ id: string; width: number; height: number; sha256: string }> = [];
for (const resource of [...graph.resources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))) {
const image = sourceImages.get(resource.sourceId);
if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource.sourceId}`);
validateImage(image, resource.sourceId);
const sha256 = await sha256Bytes(image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer);
if (resource.sha256 && resource.sha256 !== sha256) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Compositor resource ${resource.sourceId} failed SHA-256 verification`);
sources.push({ id: resource.sourceId, width: image.width, height: image.height, sha256 });
}
const descriptor = new TextEncoder().encode(stableJSON({ graph, sources, frame, width: width ?? null, height: height ?? null }));
return sha256Bytes(descriptor.buffer as ArrayBuffer);
}
export async function executeCompositorGraphCached(
value: unknown,
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
cache: CompositorFrameCache,
options: { frame: number; width?: number; height?: number; cancelled?: () => boolean },
): Promise<CompositorCachedExecutionResult> {
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height);
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
const cached = cache.get(cacheKey);
if (cached) return { ...cached, cacheKey, cacheHit: true };
const result = executeCompositorGraph(value, sourceImages, options);
cache.set(cacheKey, result);
return { ...result, cacheKey, cacheHit: false };
}

View File

@@ -12,8 +12,18 @@ export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: b
export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean }
export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number }
export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number }
export interface KeymapBindingIR { id: string; key: string; modifiers: string[]; command: string; enabled: boolean }
export interface KeymapBindingIR {
id: string;
key: string;
modifiers: string[];
command: string;
enabled: boolean;
workspaceIds?: string[];
editors?: EditorTypeIR[];
modes?: EditorMode[];
}
export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] }
export interface KeyChordIR { key: string; modifiers: Array<"ALT" | "CTRL" | "META" | "SHIFT"> }
export type EditorWorkflowEditIR =
| { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string }
| { type: "SET_ACTIVE_AREA"; revision: number; areaId: string }
@@ -38,6 +48,8 @@ function rect(value: unknown, name: string): EditorAreaIR["rect"] {
return next;
}
function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }
function scopeOverlaps<T>(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean { return !left?.length || !right?.length || left.some((value) => right.includes(value)); }
function keymapScopesOverlap(left: KeymapBindingIR, right: KeymapBindingIR): boolean { return scopeOverlaps(left.workspaceIds, right.workspaceIds) && scopeOverlaps(left.editors, right.editors) && scopeOverlaps(left.modes, right.modes); }
export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema");
@@ -64,10 +76,47 @@ export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid");
if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget");
if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid");
const keymapIds = new Set<string>(); const keymaps = value.keymaps.map((bindingValue, index): KeymapBindingIR => { const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16)); if (new Set(modifiers).size !== modifiers.length) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers duplicate`); return { id, key: text(bindingValue.key, `${name}.key`, 32), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false }; });
const keymapIds = new Set<string>(); const keymaps: KeymapBindingIR[] = [];
value.keymaps.forEach((bindingValue, index) => {
const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`);
const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id);
const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16).toUpperCase()); if (new Set(modifiers).size !== modifiers.length || modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers are invalid or duplicate`); modifiers.sort();
const parseScope = <T extends string>(field: "workspaceIds" | "editors" | "modes", allowed?: readonly T[]): T[] | undefined => {
const source = bindingValue[field]; if (source === undefined) return undefined;
if (!Array.isArray(source) || source.length === 0 || source.length > 64) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid`);
const parsed = source.map((item, scopeIndex) => text(item, `${name}.${field}[${scopeIndex}]`, 256) as T);
if (new Set(parsed).size !== parsed.length || (allowed && parsed.some((item) => !allowed.includes(item)))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid or duplicated`);
return parsed;
};
const binding: KeymapBindingIR = { id, key: text(bindingValue.key, `${name}.key`, 32).toUpperCase(), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false, workspaceIds: parseScope("workspaceIds"), editors: parseScope("editors", EDITOR_TYPES), modes: parseScope("modes", ["OBJECT", "EDIT", "POSE"] as const) };
if (binding.workspaceIds?.some((workspace) => !workspaceIds.has(workspace))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.workspaceIds references a missing workspace`);
if (binding.enabled && keymaps.some((candidate) => candidate.enabled && candidate.key === binding.key && candidate.modifiers.join("+") === binding.modifiers.join("+") && keymapScopesOverlap(candidate, binding))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} conflicts with another enabled keymap in the same context`);
keymaps.push(binding);
});
return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps };
}
export function keyChordFromKeyboardEvent(event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">): KeyChordIR {
const key = text(event.key, "event.key", 32).toUpperCase();
const modifiers: KeyChordIR["modifiers"] = [];
if (event.altKey) modifiers.push("ALT");
if (event.ctrlKey) modifiers.push("CTRL");
if (event.metaKey) modifiers.push("META");
if (event.shiftKey) modifiers.push("SHIFT");
return { key, modifiers };
}
export function resolveKeymapCommand(value: unknown, chordValue: unknown): string | null {
const workflow = parseEditorWorkflow(value);
if (!record(chordValue) || !Array.isArray(chordValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord is invalid");
const chord = { key: text(chordValue.key, "keyChord.key", 32).toUpperCase(), modifiers: chordValue.modifiers.map((modifier, index) => text(modifier, `keyChord.modifiers[${index}]`, 16).toUpperCase()).sort() };
if (new Set(chord.modifiers).size !== chord.modifiers.length || chord.modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord modifiers are invalid");
return workflow.keymaps.find((binding) => binding.enabled && binding.key === chord.key && binding.modifiers.join("+") === chord.modifiers.join("+") &&
(!binding.workspaceIds || binding.workspaceIds.includes(workflow.context.workspaceId)) &&
(!binding.editors || binding.editors.includes(workflow.context.activeEditor)) &&
(!binding.modes || binding.modes.includes(workflow.context.mode)))?.command ?? null;
}
export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR {
const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow);
if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; }

View File

@@ -58,6 +58,14 @@ export type ErrorCode =
| "NON_MESH_RESOURCE_OUTSIDE_PROJECT"
| "NON_MESH_VDB_BUDGET_EXCEEDED"
| "NON_MESH_BINARY_INVALID"
| "VDB_CONVERSION_REQUIRED"
| "VDB_CONVERTER_UNAVAILABLE"
| "VDB_CONVERSION_INVALID"
| "NANOVDB_MANIFEST_INVALID"
| "NANOVDB_HASH_MISMATCH"
| "NANOVDB_STREAM_INCOMPLETE"
| "NANOVDB_GRID_UNSUPPORTED"
| "NANOVDB_GPU_BUDGET_EXCEEDED"
| "NON_MESH_DATA_SHARED"
| "NON_MESH_PROPERTY_INVALID"
| "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED"

View File

@@ -21,14 +21,26 @@ export interface CurveGizmoDragIR {
baseRevision: number;
phase: CurveGizmoPhase;
axis: 0 | 1 | 2;
axisVector?: [number, number, number];
delta: [number, number, number];
handles: CurveGizmoHandleIR[];
}
export interface CurveGizmoFrameIR {
origin: [number, number, number];
axes: [[number, number, number], [number, number, number], [number, number, number]];
}
export interface CurveGizmoScreenFrameIR {
origin: [number, number];
axes: [[number, number], [number, number], [number, number]];
}
export interface AppliedCurveGizmoDragIR {
dataId: string;
phase: CurveGizmoPhase;
axis: 0 | 1 | 2;
axisVector?: [number, number, number];
revision: number;
handles: CurveGizmoHandleIR[];
}
@@ -57,6 +69,64 @@ function vector(value: unknown, path: string): [number, number, number] {
return [finite(value[0], `${path}[0]`), finite(value[1], `${path}[1]`), finite(value[2], `${path}[2]` )];
}
function length(value: readonly number[]): number {
return Math.hypot(value[0], value[1], value[2]);
}
function normalize(value: readonly number[], path: string): [number, number, number] {
const magnitude = length(value);
if (!Number.isFinite(magnitude) || magnitude < 1e-8) fail(path, "must have a finite non-zero direction");
return [value[0] / magnitude, value[1] / magnitude, value[2] / magnitude];
}
function dot(left: readonly number[], right: readonly number[]): number {
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
}
function cross(left: readonly number[], right: readonly number[]): [number, number, number] {
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
}
export function deriveCurveHandleGizmoFrame(controlPoints: ArrayLike<number>, handles: readonly CurveGizmoHandleIR[]): CurveGizmoFrameIR {
if (controlPoints.length === 0 || controlPoints.length % 3 !== 0) fail("controlPoints", "must contain finite XYZ coordinates");
for (let index = 0; index < controlPoints.length; index += 1) {
if (!Number.isFinite(controlPoints[index])) fail("controlPoints", "must contain finite XYZ coordinates");
}
if (handles.length === 0 || handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
const ordered = [...handles].sort((left, right) => left.pointIndex - right.pointIndex || left.side.localeCompare(right.side));
const origin: [number, number, number] = [0, 0, 0];
const directions: Array<[number, number, number]> = [];
for (const [index, handle] of ordered.entries()) {
if (!Number.isSafeInteger(handle.pointIndex) || handle.pointIndex < 0 || handle.pointIndex * 3 + 2 >= controlPoints.length || handle.side === "CONTROL") fail(`handles[${index}]`, "must identify a Curve handle with an existing control point");
vector(handle.position, `handles[${index}].position`);
origin[0] += handle.position[0];
origin[1] += handle.position[1];
origin[2] += handle.position[2];
const point = handle.pointIndex * 3;
directions.push(normalize([
handle.position[0] - controlPoints[point],
handle.position[1] - controlPoints[point + 1],
handle.position[2] - controlPoints[point + 2],
], `handles[${index}].direction`));
}
origin[0] /= ordered.length;
origin[1] /= ordered.length;
origin[2] /= ordered.length;
const reference = directions[0];
const aligned = directions.map((direction) => dot(direction, reference) < 0 ? direction.map((value) => -value) as [number, number, number] : direction);
const axisX = normalize(aligned.reduce<[number, number, number]>((sum, direction) => [sum[0] + direction[0], sum[1] + direction[1], sum[2] + direction[2]], [0, 0, 0]), "handles.directionAverage");
const up: [number, number, number] = Math.abs(axisX[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0];
const axisY = normalize(cross(up, axisX), "gizmo.axisY");
const axisZ = normalize(cross(axisX, axisY), "gizmo.axisZ");
return { origin, axes: [axisX, axisY, axisZ] };
}
export function curveGizmoAxisDelta(frame: CurveGizmoFrameIR, axis: 0 | 1 | 2, amount: number): [number, number, number] {
if (!Number.isFinite(amount) || Math.abs(amount) > CURVE_GIZMO_BUDGET.maxCoordinate) fail("amount", "is outside the finite coordinate budget");
const direction = normalize(frame.axes[axis], `frame.axes[${axis}]`);
return [direction[0] * amount, direction[1] * amount, direction[2] * amount];
}
export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): CurveGizmoDragIR {
const drag = record(value, "drag");
if (drag.schemaVersion !== CURVE_GIZMO_SCHEMA) fail("schemaVersion", "is unsupported");
@@ -66,7 +136,12 @@ export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number):
if (drag.phase !== "PREVIEW" && drag.phase !== "COMMIT") fail("phase", "is invalid");
if (drag.axis !== 0 && drag.axis !== 1 && drag.axis !== 2) fail("axis", "must be X, Y or Z");
const delta = vector(drag.delta, "delta");
if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
const axisVector = drag.axisVector === undefined ? undefined : normalize(vector(drag.axisVector, "axisVector"), "axisVector");
if (axisVector) {
const deltaLength = length(delta);
if (deltaLength > 0 && length(cross(delta, axisVector)) > Math.max(1e-7, deltaLength * 1e-6)) fail("delta", "must be parallel to the selected local axis");
}
else if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
if (!Array.isArray(drag.handles) || drag.handles.length === 0 || drag.handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
const seen = new Set<string>();
const handles = drag.handles.map((item, index) => {
@@ -79,7 +154,7 @@ export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number):
seen.add(key);
return { pointIndex, side, position: vector(handle.position, `handles[${index}].position`) };
});
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, delta, handles };
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, axisVector, delta, handles };
}
export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): AppliedCurveGizmoDragIR {
@@ -89,5 +164,5 @@ export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number):
position: [handle.position[0] + drag.delta[0], handle.position[1] + drag.delta[1], handle.position[2] + drag.delta[2]] as [number, number, number],
}));
handles.forEach((handle, index) => vector(handle.position, `handles[${index}].position`));
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, axisVector: drag.axisVector, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
}

View File

@@ -3,6 +3,7 @@ export const PAINT_BUDGET = {
maxWeightEntries: 1_000_000,
maxTextureTileBytes: 256 * 1024 * 1024,
maxStrokeBytes: 64 * 1024 * 1024,
maxSpatialCells: 1_000_000,
} as const;
export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
@@ -51,6 +52,112 @@ export interface PaintBrushVertexIR {
export interface PaintBrushWeightIR { index: number; weight: number }
export interface PaintBrushQueryOptionsIR {
ignoreOccluded?: boolean;
frontFaceOnly?: boolean;
viewDirection?: [number, number, number];
visibleVertexIndices?: readonly number[];
requireVisibility?: boolean;
selectedVertexIndices?: readonly number[];
requireSelection?: boolean;
maskWeights?: readonly PaintBrushWeightIR[];
}
function validateBrushGateIdentities(vertices: readonly PaintBrushVertexIR[], options: PaintBrushQueryOptionsIR): void {
const known = new Set(vertices.map((vertex) => vertex.index));
for (const [path, values] of [["visibleVertexIndices", options.visibleVertexIndices], ["selectedVertexIndices", options.selectedVertexIndices]] as const) {
values?.forEach((value, index) => {
const vertexIndex = integer(value, `${path}[${index}]`);
if (!known.has(vertexIndex)) fail(`${path}[${index}]`, "references an unknown vertex identity");
});
}
options.maskWeights?.forEach((value, index) => {
const entry = record(value, `maskWeights[${index}]`);
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
if (!known.has(vertexIndex)) fail(`maskWeights[${index}].index`, "references an unknown vertex identity");
});
}
export interface PaintBrushSpatialIndex {
readonly schemaVersion: 1;
readonly cellSize: number;
readonly vertices: readonly PaintBrushVertexIR[];
readonly cells: ReadonlyMap<string, readonly number[]>;
}
export interface PaintBrushSpatialQueryIR {
weights: PaintBrushWeightIR[];
candidateCount: number;
visitedCellCount: number;
}
export interface PaintColorPatchIR { indices: number[]; colors: number[] }
function parseBrushWeights(value: unknown, path = "brushWeights"): PaintBrushWeightIR[] {
if (!Array.isArray(value) || value.length > PAINT_BUDGET.maxWeightEntries) fail(path, "exceeds the brush patch budget", true);
if (value.length === 0) fail(path, "must contain at least one brush hit");
const seen = new Set<number>();
return value.map((item, index) => {
const entry = record(item, `${path}[${index}]`);
const vertexIndex = integer(entry.index, `${path}[${index}].index`);
const weight = finite(entry.weight, `${path}[${index}].weight`);
if (weight < 0 || weight > 1) fail(`${path}[${index}].weight`, "must be in [0,1]");
if (seen.has(vertexIndex)) fail(`${path}[${index}].index`, "contains a duplicate vertex");
seen.add(vertexIndex);
return { index: vertexIndex, weight };
}).sort((left, right) => left.index - right.index);
}
export function composePaintWeightPatch(
objectId: string,
vertexGroup: string,
revision: number,
currentRevision: number,
currentWeightsValue: unknown,
brushWeightsValue: unknown,
targetValue: unknown,
): WeightPatchIR {
const parsedRevision = integer(revision, "revision");
if (parsedRevision !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint weight stroke is stale");
if (!Array.isArray(currentWeightsValue)) fail("currentWeights", "must be an array");
const currentWeights = currentWeightsValue.map((value, index) => {
const weight = finite(value, `currentWeights[${index}]`);
if (weight < 0 || weight > 1) fail(`currentWeights[${index}]`, "must be in [0,1]");
return weight;
});
const target = finite(targetValue, "targetWeight");
if (target < 0 || target > 1) fail("targetWeight", "must be in [0,1]");
const weights = parseBrushWeights(brushWeightsValue);
if (weights.some((entry) => entry.index >= currentWeights.length)) fail("brushWeights", "references an unknown current weight");
return parseWeightPatch({ schemaVersion: 1, objectId, revision: parsedRevision, vertexGroup, indices: weights.map((entry) => entry.index), values: weights.map((entry) => currentWeights[entry.index] + (target - currentWeights[entry.index]) * entry.weight), normalize: false });
}
export function composePaintColorPatch(
revision: number,
currentRevision: number,
currentColorsValue: unknown,
brushWeightsValue: unknown,
targetColorValue: unknown,
): PaintColorPatchIR {
if (integer(revision, "revision") !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint color stroke is stale");
if (!Array.isArray(currentColorsValue) || currentColorsValue.length % 4 !== 0) fail("currentColors", "must contain RGBA values");
const currentColors = currentColorsValue.map((value, index) => {
const component = finite(value, `currentColors[${index}]`);
if (component < 0 || component > 1) fail(`currentColors[${index}]`, "must be in [0,1]");
return component;
});
const target = tuple(targetColorValue, 4, "targetColor");
if (target.some((component) => component < 0 || component > 1)) fail("targetColor", "must be in [0,1]");
const weights = parseBrushWeights(brushWeightsValue);
if (weights.some((entry) => entry.index >= currentColors.length / 4)) fail("brushWeights", "references an unknown current color");
return {
indices: weights.map((entry) => entry.index),
colors: weights.flatMap((entry) => Array.from({ length: 4 }, (_, component) => currentColors[entry.index * 4 + component] + (target[component] - currentColors[entry.index * 4 + component]) * entry.weight)),
};
}
const paintBrushSpatialIndexes = new WeakMap<object, { cellSize: number; vertices: PaintBrushVertexIR[]; cells: Map<string, number[]> }>();
export interface UdimTilePatchIR {
schemaVersion: 1;
textureAssetId: string;
@@ -178,14 +285,33 @@ export function parseWeightPatch(value: unknown): WeightPatchIR {
return result;
}
export function computePaintBrushWeights(
verticesValue: unknown,
function parsePaintBrushVertices(verticesValue: unknown): PaintBrushVertexIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
const seen = new Set<number>();
return verticesValue.map((item, vertexIndex) => {
const vertex = record(item, `vertices[${vertexIndex}]`);
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
seen.add(index);
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
if (position.some((component) => Math.abs(component) > 1_000_000_000)) fail(`vertices[${vertexIndex}].position`, "is outside the spatial index range");
const result: PaintBrushVertexIR = { index, position };
if (vertex.occluded !== undefined) {
if (typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
result.occluded = vertex.occluded;
}
if (vertex.normal !== undefined) result.normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
return result;
});
}
function brushWeights(
vertices: readonly PaintBrushVertexIR[],
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {},
options: PaintBrushQueryOptionsIR = {},
): PaintBrushWeightIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
const center = tuple(centerValue, 3, "center") as [number, number, number];
const radius = finite(radiusValue, "radius");
const strength = finite(strengthValue, "strength");
@@ -193,30 +319,112 @@ export function computePaintBrushWeights(
if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]");
const viewDirection = options.viewDirection ?? [0, 0, -1];
tuple(viewDirection, 3, "viewDirection");
if (options.requireVisibility && options.visibleVertexIndices === undefined) fail("visibleVertexIndices", "is required for depth-gated brush queries");
if (options.visibleVertexIndices !== undefined && (!Array.isArray(options.visibleVertexIndices) || options.visibleVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("visibleVertexIndices", "exceeds the visibility budget", true);
const visible = options.visibleVertexIndices === undefined ? undefined : new Set(options.visibleVertexIndices.map((value, index) => integer(value, `visibleVertexIndices[${index}]`)));
if (visible && visible.size !== options.visibleVertexIndices?.length) fail("visibleVertexIndices", "contains duplicates");
if (options.requireSelection && options.selectedVertexIndices === undefined) fail("selectedVertexIndices", "is required for selection-gated brush queries");
if (options.selectedVertexIndices !== undefined && (!Array.isArray(options.selectedVertexIndices) || options.selectedVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("selectedVertexIndices", "exceeds the selection budget", true);
const selected = options.selectedVertexIndices === undefined ? undefined : new Set(options.selectedVertexIndices.map((value, index) => integer(value, `selectedVertexIndices[${index}]`)));
if (selected && selected.size !== options.selectedVertexIndices?.length) fail("selectedVertexIndices", "contains duplicates");
if (options.maskWeights !== undefined && (!Array.isArray(options.maskWeights) || options.maskWeights.length > PAINT_BUDGET.maxWeightEntries)) fail("maskWeights", "exceeds the mask budget", true);
const mask = options.maskWeights === undefined ? undefined : new Map<number, number>();
options.maskWeights?.forEach((value, index) => {
const entry = record(value, `maskWeights[${index}]`);
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
const weight = finite(entry.weight, `maskWeights[${index}].weight`);
if (weight < 0 || weight > 1) fail(`maskWeights[${index}].weight`, "must be in [0,1]");
if (mask!.has(vertexIndex)) fail(`maskWeights[${index}].index`, "contains a duplicate vertex");
mask!.set(vertexIndex, weight);
});
const result: PaintBrushWeightIR[] = [];
const seen = new Set<number>();
for (const [vertexIndex, item] of verticesValue.entries()) {
const vertex = record(item, `vertices[${vertexIndex}]`);
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
seen.add(index);
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
for (const vertex of vertices) {
const { index, position } = vertex;
if (visible && !visible.has(index)) continue;
if (selected && !selected.has(index)) continue;
const maskWeight = mask?.get(index) ?? (mask ? 0 : 1);
if (maskWeight === 0) continue;
if (options.ignoreOccluded !== false && vertex.occluded === true) continue;
if (vertex.normal !== undefined) {
const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
const normal = tuple(vertex.normal, 3, `vertices[${index}].normal`) as [number, number, number];
if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue;
}
const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]);
if (distance > radius) continue;
const normalized = distance / radius;
const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized);
const weight = Math.max(0, Math.min(1, strength * smoothstep));
const weight = Math.max(0, Math.min(1, strength * smoothstep * maskWeight));
if (weight > 0) result.push({ index, weight });
}
return result.sort((left, right) => left.index - right.index);
}
export function computePaintBrushWeights(
verticesValue: unknown,
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: PaintBrushQueryOptionsIR = {},
): PaintBrushWeightIR[] {
const vertices = parsePaintBrushVertices(verticesValue);
validateBrushGateIdentities(vertices, options);
return brushWeights(vertices, centerValue, radiusValue, strengthValue, options);
}
function spatialCell(position: readonly number[], cellSize: number): [number, number, number] {
return [Math.floor(position[0] / cellSize), Math.floor(position[1] / cellSize), Math.floor(position[2] / cellSize)];
}
function spatialKey(x: number, y: number, z: number): string { return `${x}:${y}:${z}`; }
export function buildPaintBrushSpatialIndex(verticesValue: unknown, cellSizeValue: unknown): PaintBrushSpatialIndex {
const vertices = parsePaintBrushVertices(verticesValue);
const cellSize = finite(cellSizeValue, "cellSize");
if (cellSize < 1e-6 || cellSize > 100_000) fail("cellSize", "is outside the bounded range");
const cells = new Map<string, number[]>();
vertices.forEach((vertex, offset) => {
const cell = spatialCell(vertex.position, cellSize);
if (cell.some((component) => !Number.isSafeInteger(component))) fail("vertices", "produces an unsafe spatial cell");
const key = spatialKey(...cell);
const offsets = cells.get(key) ?? [];
offsets.push(offset);
cells.set(key, offsets);
});
if (cells.size > PAINT_BUDGET.maxSpatialCells) fail("vertices", "exceeds the spatial cell budget", true);
const publicVertices = vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], ...(vertex.normal ? { normal: [...vertex.normal] as [number, number, number] } : {}) }));
const publicCells = new Map([...cells].map(([key, offsets]) => [key, [...offsets]]));
const index: PaintBrushSpatialIndex = Object.freeze({ schemaVersion: 1, cellSize, vertices: publicVertices, cells: publicCells });
paintBrushSpatialIndexes.set(index, { cellSize, vertices, cells });
return index;
}
export function queryPaintBrushSpatialIndex(
index: PaintBrushSpatialIndex,
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: PaintBrushQueryOptionsIR = {},
): PaintBrushSpatialQueryIR {
const source = paintBrushSpatialIndexes.get(index);
if (!source) fail("spatialIndex", "is invalid");
validateBrushGateIdentities(source.vertices, options);
const center = tuple(centerValue, 3, "center") as [number, number, number];
const radius = finite(radiusValue, "radius");
if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range");
const minimum = spatialCell(center.map((component) => component - radius), source.cellSize);
const maximum = spatialCell(center.map((component) => component + radius), source.cellSize);
const spans = maximum.map((component, axis) => component - minimum[axis] + 1);
if (spans.some((span) => !Number.isSafeInteger(span) || span <= 0) || spans[0] > PAINT_BUDGET.maxSpatialCells / spans[1] / spans[2]) fail("spatialQuery", "exceeds the visited cell budget", true);
const offsets = new Set<number>();
let visitedCellCount = 0;
for (let x = minimum[0]; x <= maximum[0]; x++) for (let y = minimum[1]; y <= maximum[1]; y++) for (let z = minimum[2]; z <= maximum[2]; z++) {
visitedCellCount++;
for (const offset of source.cells.get(spatialKey(x, y, z)) ?? []) offsets.add(offset);
}
const candidates = [...offsets].sort((left, right) => left - right).map((offset) => source.vertices[offset]);
return { weights: brushWeights(candidates, center, radius, strengthValue, options), candidateCount: candidates.length, visitedCellCount };
}
export function parseUdimTilePatch(value: unknown): UdimTilePatchIR {
const patch = record(value, "udimPatch");
if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format");

View File

@@ -0,0 +1,182 @@
import { decodeBrowserTransformCacheFrame, PhysicsSimulationValidationError, type BrowserTransformCacheObjectIR } from "./physics-simulation";
import type { SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
export interface BrowserTransformCacheFrameSource {
readonly frameStart: number;
readonly frameEnd: number;
readFrame(frame: number, signal: AbortSignal): Promise<ArrayBuffer>;
}
export interface BrowserTransformCachePlaybackResult {
status: "COMPLETED" | "CANCELLED";
appliedFrames: number;
lastFrame: number | null;
}
function quaternionFromEuler([x, y, z]: readonly number[]): [number, number, number, number] {
const cx = Math.cos(x / 2); const sx = Math.sin(x / 2);
const cy = Math.cos(y / 2); const sy = Math.sin(y / 2);
const cz = Math.cos(z / 2); const sz = Math.sin(z / 2);
return [sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz];
}
function eulerFromQuaternion([x, y, z, w]: readonly number[]): [number, number, number] {
return [
Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
Math.asin(Math.max(-1, Math.min(1, 2 * (w * y - z * x)))),
Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)),
];
}
function composeMatrix(translation: readonly number[], quaternion: readonly number[], scale: readonly number[]): number[] {
const [x, y, z, w] = quaternion;
const x2 = x + x; const y2 = y + y; const z2 = z + z;
const xx = x * x2; const xy = x * y2; const xz = x * z2;
const yy = y * y2; const yz = y * z2; const zz = z * z2;
const wx = w * x2; const wy = w * y2; const wz = w * z2;
return [
(1 - (yy + zz)) * scale[0], (xy + wz) * scale[0], (xz - wy) * scale[0], 0,
(xy - wz) * scale[1], (1 - (xx + zz)) * scale[1], (yz + wx) * scale[1], 0,
(xz + wy) * scale[2], (yz - wx) * scale[2], (1 - (xx + yy)) * scale[2], 0,
translation[0], translation[1], translation[2], 1,
];
}
function multiplyMatrix(left: readonly number[], right: readonly number[]): number[] {
const output = new Array<number>(16);
for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) {
output[column * 4 + row] = left[row] * right[column * 4] + left[4 + row] * right[column * 4 + 1] + left[8 + row] * right[column * 4 + 2] + left[12 + row] * right[column * 4 + 3];
}
return output;
}
function cacheTransform(node: SceneNodeIR, cached: BrowserTransformCacheObjectIR | undefined): { node: SceneNodeIR; quaternion: [number, number, number, number] } {
if (!cached) {
return { node: { ...node, transform: { ...node.transform }, localMatrix: [...node.localMatrix], worldMatrix: [...node.worldMatrix] }, quaternion: quaternionFromEuler(node.transform.rotationEuler) };
}
const transform = {
...node.transform,
translation: [...cached.translation] as [number, number, number],
rotationEuler: eulerFromQuaternion(cached.rotationQuaternion),
scale: [...cached.scale] as [number, number, number],
};
return { node: { ...node, transform, localMatrix: composeMatrix(transform.translation, cached.rotationQuaternion, transform.scale), worldMatrix: [] }, quaternion: cached.rotationQuaternion };
}
/** Applies the browser-owned BTF1 transform cache as an immutable SceneIR preview. */
export function applyBrowserTransformCachePreview(snapshot: SceneSnapshotIR, value: ArrayBuffer, expectedFrame: number): SceneSnapshotIR {
if (!Number.isSafeInteger(expectedFrame) || expectedFrame < snapshot.frame.start || expectedFrame > snapshot.frame.end) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${expectedFrame} is outside the scene range`);
}
const frame = decodeBrowserTransformCacheFrame(value);
if (frame.frame !== expectedFrame) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${frame.frame} does not match requested frame ${expectedFrame}`);
const sourceById = new Map(snapshot.nodes.map((node) => [node.id, node]));
for (const item of frame.objects) if (!sourceById.has(item.objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache references missing ${item.objectId}`);
const cachedById = new Map(frame.objects.map((item) => [item.objectId, item]));
const states = new Map(snapshot.nodes.map((node) => [node.id, cacheTransform(node, cachedById.get(node.id))]));
const resolving = new Set<string>();
const resolved = new Set<string>();
const updateWorld = (id: string): number[] => {
const state = states.get(id);
if (!state) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy references missing ${id}`);
if (resolved.has(id)) return state.node.worldMatrix;
if (resolving.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy contains a cycle at ${id}`);
resolving.add(id);
if (state.node.localMatrix.length !== 16) state.node.localMatrix = composeMatrix(state.node.transform.translation, state.quaternion, state.node.transform.scale);
state.node.worldMatrix = state.node.parentId ? multiplyMatrix(updateWorld(state.node.parentId), state.node.localMatrix) : [...state.node.localMatrix];
resolving.delete(id);
resolved.add(id);
return state.node.worldMatrix;
};
for (const node of snapshot.nodes) updateWorld(node.id);
return { ...snapshot, frame: { ...snapshot.frame, current: frame.frame }, nodes: snapshot.nodes.map((node) => states.get(node.id)!.node) };
}
/** Coordinates exact-frame BTF1 reads while preventing cancelled or superseded reads from publishing. */
export class BrowserTransformCachePlaybackSession {
private generation = 0;
private controller: AbortController | null = null;
constructor(
private readonly baseSnapshot: SceneSnapshotIR,
private readonly source: BrowserTransformCacheFrameSource,
private readonly publish: (preview: SceneSnapshotIR) => void,
) {
if (!Number.isSafeInteger(source.frameStart) || !Number.isSafeInteger(source.frameEnd) ||
source.frameEnd < source.frameStart || source.frameStart < baseSnapshot.frame.start || source.frameEnd > baseSnapshot.frame.end) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache source range is outside the scene range");
}
}
cancel(): void {
this.generation += 1;
this.controller?.abort();
this.controller = null;
}
async seek(frame: number): Promise<SceneSnapshotIR | null> {
this.validateRange(frame, frame);
const generation = this.begin();
const controller = this.controller!;
try {
const data = await this.source.readFrame(frame, controller.signal);
if (!this.isCurrent(generation, controller)) return null;
const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame);
if (!this.isCurrent(generation, controller)) return null;
this.publish(preview);
return preview;
}
catch (error) {
if (!this.isCurrent(generation, controller)) return null;
throw error;
}
finally {
if (this.generation === generation) this.controller = null;
}
}
async play(frameStart = this.source.frameStart, frameEnd = this.source.frameEnd): Promise<BrowserTransformCachePlaybackResult> {
this.validateRange(frameStart, frameEnd);
const generation = this.begin();
const controller = this.controller!;
let appliedFrames = 0;
let lastFrame: number | null = null;
try {
for (let frame = frameStart; frame <= frameEnd; frame += 1) {
const data = await this.source.readFrame(frame, controller.signal);
if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame };
const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame);
if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame };
this.publish(preview);
appliedFrames += 1;
lastFrame = frame;
}
return { status: "COMPLETED", appliedFrames, lastFrame };
}
catch (error) {
if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame };
throw error;
}
finally {
if (this.generation === generation) this.controller = null;
}
}
private begin(): number {
this.controller?.abort();
this.controller = new AbortController();
this.generation += 1;
return this.generation;
}
private isCurrent(generation: number, controller: AbortController): boolean {
return generation === this.generation && this.controller === controller && !controller.signal.aborted;
}
private validateRange(frameStart: number, frameEnd: number): void {
if (!Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd) || frameStart < this.source.frameStart ||
frameEnd > this.source.frameEnd || frameEnd < frameStart) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache has no verified range ${frameStart}-${frameEnd}`);
}
}
}

View File

@@ -3,7 +3,7 @@ import type { ErrorCode } from "./error";
export const RELEASE_GATE_SCHEMA = 3 as const;
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] }
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; excludedSlices: string[]; acceptance: string[]; dependencies: string[] }
export interface ReleaseEvidenceIR {
browser: { chromium: boolean };
runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean };
@@ -26,6 +26,7 @@ function record(value: unknown): value is Record<string, unknown> { return typeo
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return value; }
function bool(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be boolean`); return value; }
function strings(value: unknown, name: string, maximum = 100_000): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return [...value] as string[]; }
function utcTimestamp(value: unknown, name: string): string { const result = text(value, name, 128); const date = new Date(result); if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || !Number.isFinite(date.getTime()) || date.toISOString() !== result) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be a canonical UTC timestamp`); return result; }
function parseEvidence(value: unknown): ReleaseEvidenceIR {
if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing");
@@ -37,10 +38,13 @@ function parseEvidence(value: unknown): ReleaseEvidenceIR {
const id = text(item.id, `${name}.id`); if (recordIds.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate evidence record ${id}`); recordIds.add(id);
const fields = strings(item.fields, `${name}.fields`, 64); if (new Set(fields).size !== fields.length || fields.some((field) => !/^(browser|runtime|performance|faults|provenance)\.[A-Za-z0-9]+$/.test(field))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.fields is invalid`);
if (item.exitCode !== 0 || typeof item.durationMs !== "number" || !Number.isSafeInteger(item.durationMs) || item.durationMs < 0 || item.durationMs > 86_400_000) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} did not complete successfully`);
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if (artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is invalid`);
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if ((fields.length > 0 && artifactSha256.length === 0) || new Set(artifactSha256).size !== artifactSha256.length || artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is missing, duplicate or invalid`);
return { id, fields, command: text(item.command, `${name}.command`, 2048), exitCode: 0, durationMs: item.durationMs, output: text(item.output, `${name}.output`, 4096), artifactSha256 };
});
const parsed = { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"], records };
const knownFields = new Map<string, boolean>();
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) for (const [key, enabled] of Object.entries(groupValues)) knownFields.set(`${groupName}.${key}`, enabled);
for (const evidenceRecord of records) for (const field of evidenceRecord.fields) if (knownFields.get(field) !== true) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Evidence record ${evidenceRecord.id} binds unknown or disabled field ${field}`);
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) {
for (const [key, enabled] of Object.entries(groupValues)) if (enabled && !records.some((item) => item.fields.includes(`${groupName}.${key}`))) throw new ReleaseGateValidationError("RELEASE_EVIDENCE_MISSING", `Enabled evidence ${groupName}.${key} has no successful record`);
}
@@ -55,10 +59,10 @@ function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one state`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, excludedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
assertDependencies(families);
const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: utcTimestamp(value.generatedAt, "generatedAt"), families, evidence: parseEvidence(value.evidence) };
}
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {

View File

@@ -1,4 +1,4 @@
import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneIR, SceneNodeIR, SceneSnapshotIR, WorldIR } from "./scene-ir";
export interface SceneCollectionDelta<T extends { id: string }> {
updated: Array<Pick<T, "id"> & Partial<T>>;
@@ -20,11 +20,13 @@ export interface SceneDelta {
animations?: SceneCollectionDelta<AnimationIR>;
cameras?: SceneCollectionDelta<CameraIR>;
lights?: SceneCollectionDelta<LightIR>;
worlds?: SceneCollectionDelta<WorldIR>;
scenes?: SceneCollectionDelta<SceneIR>;
activeObjectId?: string | null;
frame?: SceneSnapshotIR["frame"];
}
const collectionFields = ["meshes", "materials", "animations", "cameras", "lights"] as const;
const collectionFields = ["meshes", "materials", "animations", "cameras", "lights", "worlds", "scenes"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -115,6 +117,8 @@ export function diffSceneSnapshots(before: SceneSnapshotIR, after: SceneSnapshot
delta.animations = diffCollection(before.animations, after.animations);
delta.cameras = diffCollection(before.cameras, after.cameras);
delta.lights = diffCollection(before.lights, after.lights);
delta.worlds = diffCollection(before.worlds, after.worlds);
delta.scenes = diffCollection(before.scenes, after.scenes);
if (before.activeObjectId !== after.activeObjectId) delta.activeObjectId = after.activeObjectId;
if (JSON.stringify(before.frame) !== JSON.stringify(after.frame)) delta.frame = after.frame;
return delta;
@@ -153,7 +157,14 @@ export function applySceneDelta(snapshot: SceneSnapshotIR, delta: SceneDelta): S
animations: applyCollectionDelta(snapshot.animations, delta.animations),
cameras: applyCollectionDelta(snapshot.cameras, delta.cameras),
lights: applyCollectionDelta(snapshot.lights, delta.lights),
worlds: applyCollectionDelta(snapshot.worlds, delta.worlds),
scenes: applyCollectionDelta(snapshot.scenes, delta.scenes),
activeObjectId: delta.activeObjectId === undefined ? snapshot.activeObjectId : delta.activeObjectId,
frame: delta.frame ?? snapshot.frame,
};
}
export function sceneDeltaRequiresRendererRebuild(delta: SceneDelta): boolean {
return Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length || delta.meshes || delta.materials ||
delta.cameras || delta.lights || delta.worlds || delta.scenes || delta.animations);
}

View File

@@ -371,6 +371,14 @@ export interface VFontResourceIR {
packed: boolean;
}
export interface NonMeshVolumePropertiesIR {
displayDensity: number;
interpolation: "NEAREST" | "LINEAR";
stepSize: number;
velocityGrid: string;
velocityScale: number;
}
export interface NonMeshDataIR {
id: string;
name: string;
@@ -405,6 +413,7 @@ export interface NonMeshDataIR {
resourceKind?: "OPENVDB";
resourceByteLength?: number;
volumeGrids?: VolumeGridMetadataIR[];
volumeProperties?: NonMeshVolumePropertiesIR;
errorCode?: "NON_MESH_DATA_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_RESOURCE_MISSING" | "NON_MESH_BINARY_INVALID" | "NON_MESH_RESOURCE_OUTSIDE_PROJECT" | "NON_MESH_VDB_BUDGET_EXCEEDED";
}

View File

@@ -4,7 +4,9 @@ import type { ErrorCode } from "./error";
export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const;
export const SCRIPT_EXECUTION_AUDIT_SCHEMA = 1 as const;
export const SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA = 1 as const;
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536, maxAuditEntries: 65_536 } as const;
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
@@ -44,6 +46,30 @@ export interface ScriptSourceIR {
}
export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] }
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
export interface ScriptExecutionAuditIR {
schemaVersion: typeof SCRIPT_EXECUTION_AUDIT_SCHEMA;
requestId: string;
requestedAt: string;
scriptId: string;
sourceSha256: string;
manifestSha256: string;
permissions: ScriptPermission[];
budget: { cpuMs: number; memoryBytes: number; wallMs: number };
approvedKey: boolean;
decision: "DENY";
reason: "SCRIPT_SIGNATURE_INVALID" | "SCRIPT_SANDBOX_UNAVAILABLE";
requestSha256: string;
}
export interface ScriptExecutionAuditLogEntryIR {
sequence: number;
previousEntrySha256: string | null;
audit: ScriptExecutionAuditIR;
entrySha256: string;
}
export interface ScriptExecutionAuditLogIR {
schemaVersion: typeof SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA;
entries: ScriptExecutionAuditLogEntryIR[];
}
export class ScriptingPlatformValidationError extends Error {
readonly code: ErrorCode;
@@ -56,6 +82,122 @@ function text(value: unknown, name: string, maximum = 256): string { if (typeof
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; }
function stableJSON(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
return JSON.stringify(value);
}
async function sha256(value: string): Promise<string> {
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function canonicalAuditRequest(audit: Omit<ScriptExecutionAuditIR, "schemaVersion" | "requestSha256">): Omit<ScriptExecutionAuditIR, "schemaVersion" | "requestSha256"> {
return { ...audit, permissions: [...audit.permissions].sort(), budget: { ...audit.budget } };
}
function isoDate(value: unknown, name: string): string {
const result = text(value, name, 64); const date = new Date(result);
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || !Number.isFinite(date.getTime()) || date.toISOString() !== result) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
return result;
}
function auditRequestId(value: unknown, name: string): string {
const result = text(value, name);
if (!/^[-A-Za-z0-9:_./]{1,256}$/.test(result)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
return result;
}
function canonicalManifest(manifest: ScriptingManifestIR): ScriptingManifestIR {
return {
schemaVersion: manifest.schemaVersion,
scripts: manifest.scripts
.map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id.localeCompare(b.id)) }))
.sort((a, b) => a.id.localeCompare(b.id)),
};
}
export async function createScriptExecutionAudit(
manifest: unknown,
scriptId: string,
approvedKeyIds: ReadonlySet<string>,
options: { requestId?: string; requestedAt?: string } = {},
): Promise<ScriptExecutionAuditIR> {
const parsed = parseScriptingManifest(manifest);
const script = parsed.scripts.find((item) => item.id === scriptId);
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
const requestId = auditRequestId(options.requestId ?? `script-audit:${scriptId}:${Date.now()}`, "requestId");
const requestedAt = isoDate(options.requestedAt ?? new Date().toISOString(), "requestedAt");
const approvedKey = approvedKeyIds.has(script.keyId);
const reason = approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID";
const manifestSha256 = await sha256(stableJSON(canonicalManifest(parsed)));
const request = canonicalAuditRequest({ requestId, requestedAt, scriptId, sourceSha256: script.sourceSha256, manifestSha256, permissions: [...script.permissions], budget: { cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }, approvedKey, decision: "DENY", reason });
const requestSha256 = await sha256(stableJSON(request));
return Object.freeze({
schemaVersion: SCRIPT_EXECUTION_AUDIT_SCHEMA,
requestId,
requestedAt,
scriptId,
sourceSha256: script.sourceSha256,
manifestSha256,
permissions: Object.freeze([...script.permissions].sort()) as unknown as ScriptPermission[],
budget: Object.freeze({ cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }),
approvedKey,
decision: "DENY",
reason,
requestSha256,
});
}
export async function parseScriptExecutionAudit(value: unknown): Promise<ScriptExecutionAuditIR> {
if (!record(value) || value.schemaVersion !== SCRIPT_EXECUTION_AUDIT_SCHEMA || !Array.isArray(value.permissions) || !record(value.budget)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Script execution audit is invalid");
const permissions = value.permissions.map((permission, index) => text(permission, `audit.permissions[${index}]`, 64) as ScriptPermission);
if (permissions.length > SCRIPTING_BUDGET.maxPermissions || new Set(permissions).size !== permissions.length || permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission)) || permissions.some((permission, index) => index > 0 && permissions[index - 1] > permission)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit permissions are invalid or not canonical");
if (typeof value.approvedKey !== "boolean" || value.decision !== "DENY" || !["SCRIPT_SIGNATURE_INVALID", "SCRIPT_SANDBOX_UNAVAILABLE"].includes(value.reason as string) || value.reason !== (value.approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Audit decision is inconsistent with the default-deny policy");
const request = canonicalAuditRequest({
requestId: auditRequestId(value.requestId, "audit.requestId"),
requestedAt: isoDate(value.requestedAt, "audit.requestedAt"),
scriptId: text(value.scriptId, "audit.scriptId"),
sourceSha256: digest(value.sourceSha256, "audit.sourceSha256"),
manifestSha256: digest(value.manifestSha256, "audit.manifestSha256"),
permissions,
budget: { cpuMs: integer(value.budget.cpuMs, "audit.budget.cpuMs", 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(value.budget.memoryBytes, "audit.budget.memoryBytes", 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(value.budget.wallMs, "audit.budget.wallMs", 1, SCRIPTING_BUDGET.maxWallMs) },
approvedKey: value.approvedKey,
decision: "DENY",
reason: value.reason as ScriptExecutionAuditIR["reason"],
});
const requestSha256 = digest(value.requestSha256, "audit.requestSha256");
if (await sha256(stableJSON(request)) !== requestSha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit request digest does not match its canonical content");
return { schemaVersion: SCRIPT_EXECUTION_AUDIT_SCHEMA, ...request, requestSha256 };
}
export async function parseScriptExecutionAuditLog(value: unknown): Promise<ScriptExecutionAuditLogIR> {
if (!record(value) || value.schemaVersion !== SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA || !Array.isArray(value.entries)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script execution audit log schema");
if (value.entries.length > SCRIPTING_BUDGET.maxAuditEntries) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script audit log exceeds the entry budget");
const entries: ScriptExecutionAuditLogEntryIR[] = []; const requestIds = new Set<string>();
for (const [index, entryValue] of value.entries.entries()) {
if (!record(entryValue) || !record(entryValue.audit) || entryValue.sequence !== index + 1) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} has an invalid sequence`);
const audit = await parseScriptExecutionAudit(entryValue.audit);
if (requestIds.has(audit.requestId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit request ${audit.requestId} is replayed`);
if (entries.length > 0 && audit.requestedAt <= entries[entries.length - 1].audit.requestedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit log timestamps are not strictly increasing");
const previousEntrySha256 = index === 0 ? null : entries[index - 1].entrySha256;
if (entryValue.previousEntrySha256 !== previousEntrySha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} breaks the hash chain`);
const entrySha256 = digest(entryValue.entrySha256, `entries[${index}].entrySha256`);
if (await sha256(stableJSON({ sequence: index + 1, previousEntrySha256, audit })) !== entrySha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} digest does not match`);
requestIds.add(audit.requestId); entries.push({ sequence: index + 1, previousEntrySha256, audit, entrySha256 });
}
return { schemaVersion: SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA, entries };
}
export async function appendScriptExecutionAudit(logValue: unknown, auditValue: unknown): Promise<ScriptExecutionAuditLogIR> {
const log = await parseScriptExecutionAuditLog(logValue); const audit = await parseScriptExecutionAudit(auditValue);
if (log.entries.length >= SCRIPTING_BUDGET.maxAuditEntries) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script audit log exceeds the entry budget");
if (log.entries.some((entry) => entry.audit.requestId === audit.requestId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit request ${audit.requestId} is replayed`);
const previous = log.entries.at(-1);
if (previous && audit.requestedAt <= previous.audit.requestedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit log timestamps must be strictly increasing");
const sequence = log.entries.length + 1; const previousEntrySha256 = previous?.entrySha256 ?? null;
const entrySha256 = await sha256(stableJSON({ sequence, previousEntrySha256, audit }));
return parseScriptExecutionAuditLog({ schemaVersion: SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA, entries: [...log.entries, { sequence, previousEntrySha256, audit, entrySha256 }] });
}
export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR {
if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema");

View File

@@ -1,4 +1,4 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import { readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const SELECTION_HISTORY_SCHEMA = 2 as const;
@@ -291,6 +291,5 @@ export function parseRaycastSelectionHit(value: unknown, expectedRevision: numbe
}
export function gateSelectionInteraction(operation: "RAYCAST" | "HISTORY" | "GIZMO"): CapabilityGateResult {
if (operation !== "GIZMO") return readyGate("N-015", operation);
return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve gizmo preview remains unavailable; bounded multi-handle commit is supported")]);
return readyGate("N-015", operation);
}

View File

@@ -67,6 +67,21 @@ export interface SequencerRuntimeCapabilityIR {
localEncoding: "BLOCKED";
}
export interface SequencerFrameStripIR {
stripId: string;
channel: number;
sourceFrame: number;
dependencyStripIds: string[];
}
export interface SequencerTransitionFrameIR {
effectStripId: string;
effectType: "CROSS" | "GAMMA_CROSS";
factor: number;
from: { stripId: string; sourceFrame: number };
to: { stripId: string; sourceFrame: number };
}
export class SequencerValidationError extends Error {
readonly code: ErrorCode;
@@ -236,6 +251,54 @@ export function sequencerSourceFrame(strip: SequencerStripIR, timelineFrame: num
return Math.min(strip.sourceEnd, Math.max(strip.sourceStart, strip.sourceStart + (timelineFrame - strip.frameStart) * strip.speed));
}
export function resolveSequencerFrame(value: unknown, timelineFrame: number): SequencerFrameStripIR[] {
const timeline = parseSequencerTimeline(value);
if (!Number.isFinite(timelineFrame) || timelineFrame < timeline.frameStart || timelineFrame > timeline.frameEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Timeline frame ${timelineFrame} is outside the scene range`);
const active = timeline.strips.filter((strip) => !strip.muted && timelineFrame >= strip.frameStart && timelineFrame < strip.frameEnd);
const activeIds = new Set(active.map((strip) => strip.id));
const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? []));
const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => {
const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])];
if (dependencies.some((id) => !activeIds.has(id))) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive frame dependency`);
return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, timelineFrame), dependencyStripIds: [...dependencies] };
});
result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId));
return result;
}
/** Resolves the ordered inputs and bounded progress for the verified cross-transition subset. */
export function resolveSequencerTransitionFrame(
value: unknown,
effectStripId: string,
timelineFrame: number,
): SequencerTransitionFrameIR {
const timeline = parseSequencerTimeline(value);
const effect = timeline.strips.find((strip) => strip.id === effectStripId);
if (!effect || effect.type !== "EFFECT" ||
(effect.effectType !== "CROSS" && effect.effectType !== "GAMMA_CROSS") ||
effect.inputStripIds?.length !== 2) {
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is not a supported two-input cross transition`);
}
if (!Number.isFinite(timelineFrame) || timelineFrame < effect.frameStart || timelineFrame >= effect.frameEnd) {
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is inactive at frame ${timelineFrame}`);
}
const [fromId, toId] = effect.inputStripIds;
const from = timeline.strips.find((strip) => strip.id === fromId);
const to = timeline.strips.find((strip) => strip.id === toId);
if (!from || !to || from.muted || to.muted || timelineFrame < from.frameStart || timelineFrame >= from.frameEnd ||
timelineFrame < to.frameStart || timelineFrame >= to.frameEnd) {
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${effectStripId} has an inactive transition input`);
}
const factor = (timelineFrame - effect.frameStart) / (effect.frameEnd - effect.frameStart);
return {
effectStripId,
effectType: effect.effectType,
factor,
from: { stripId: from.id, sourceFrame: sequencerSourceFrame(from, timelineFrame) },
to: { stripId: to.id, sourceFrame: sequencerSourceFrame(to, timelineFrame) },
};
}
export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalThis): SequencerRuntimeCapabilityIR {
return {
webCodecsVideo: "VideoDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",

View File

@@ -118,6 +118,24 @@ export interface TrackingMaskProjectIR {
bindings: TrackingMaskBindingIR[];
}
export interface MaskRaycastHitIR {
maskId: string;
layerId: string;
splineId: string;
kind: "POINT" | "SEGMENT";
pointId: string;
nextPointId?: string;
distance: number;
parameter?: number;
}
export interface MaskPointSelectionIR {
maskId: string;
layerId: string;
splineId: string;
pointId: string;
}
export type TrackingMaskEditIR =
| { type: "SET_MARKER"; revision: number; clipId: string; trackId: string; marker: TrackingMarkerIR }
| { type: "DELETE_MARKER"; revision: number; clipId: string; trackId: string; frame: number }
@@ -327,3 +345,86 @@ export function gateTrackingOperation(operation: "MARKER_EDIT" | "MASK_EDIT" | "
if (operation === "BROWSER_TRACKING" && browserProbe === "VERIFIED") return readyGate("N-022", operation);
return blockedGate("N-022", operation, [capabilityIssue("TRACKING_SOLVE_UNAVAILABLE", operation === "CAMERA_SOLVE" ? "Camera solve requires a verified server Blender implementation" : "Browser tracking requires an explicit feature probe")]);
}
function bezierPoint(a: Vec2, b: Vec2, c: Vec2, d: Vec2, t: number): Vec2 {
const inverse = 1 - t;
return [inverse ** 3 * a[0] + 3 * inverse ** 2 * t * b[0] + 3 * inverse * t ** 2 * c[0] + t ** 3 * d[0], inverse ** 3 * a[1] + 3 * inverse ** 2 * t * b[1] + 3 * inverse * t ** 2 * c[1] + t ** 3 * d[1]];
}
export function raycastMaskProject(value: unknown, positionValue: unknown, thresholdValue = 0.02, segmentSamples = 24): MaskRaycastHitIR | null {
const project = parseTrackingMaskProject(value);
const position = vec2(positionValue, "position", -4, 4);
const threshold = finite(thresholdValue, "threshold", 0.000001, 1);
const samples = integer(segmentSamples, "segmentSamples", 2, 128);
let best: MaskRaycastHitIR | null = null;
const consider = (hit: MaskRaycastHitIR): void => { if (hit.distance <= threshold && (!best || hit.distance < best.distance || (hit.distance === best.distance && hit.kind === "POINT" && best.kind === "SEGMENT"))) best = hit; };
for (const mask of project.masks) for (const layer of mask.layers) {
if (!layer.visible || layer.locked || layer.opacity <= 0) continue;
for (const spline of layer.splines) {
for (const point of spline.points) consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "POINT", pointId: point.id, distance: Math.hypot(point.co[0] - position[0], point.co[1] - position[1]) });
const segmentCount = spline.cyclic ? spline.points.length : spline.points.length - 1;
for (let segment = 0; segment < segmentCount; segment++) {
const first = spline.points[segment]; const next = spline.points[(segment + 1) % spline.points.length];
for (let sample = 0; sample <= samples; sample++) {
const parameter = sample / samples;
const point = bezierPoint(first.co, first.handleRight, next.handleLeft, next.co, parameter);
consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "SEGMENT", pointId: first.id, nextPointId: next.id, distance: Math.hypot(point[0] - position[0], point[1] - position[1]), parameter });
}
}
}
}
return best;
}
function maskSelectionKey(selection: MaskPointSelectionIR): string {
return `${selection.maskId}\0${selection.layerId}\0${selection.splineId}\0${selection.pointId}`;
}
/** Applies deterministic replace/add/toggle marquee selection to editable Mask control points. */
export function selectMaskPointsInBounds(
value: unknown,
minimumValue: unknown,
maximumValue: unknown,
currentValue: unknown = [],
mode: "REPLACE" | "ADD" | "TOGGLE" = "REPLACE",
): MaskPointSelectionIR[] {
const project = parseTrackingMaskProject(value);
if (!(["REPLACE", "ADD", "TOGGLE"] as const).includes(mode)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection mode is invalid");
const minimum = vec2(minimumValue, "minimum", -4, 4);
const maximum = vec2(maximumValue, "maximum", -4, 4);
if (minimum[0] > maximum[0] || minimum[1] > maximum[1]) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection bounds are inverted");
if (!Array.isArray(currentValue) || currentValue.length > TRACKING_MASK_BUDGET.maxMaskPoints) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask selection exceeds the point budget");
const all: MaskPointSelectionIR[] = [];
const editable = new Set<string>();
for (const mask of project.masks) for (const layer of mask.layers) for (const spline of layer.splines) for (const point of spline.points) {
const selection = { maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id };
all.push(selection);
if (layer.visible && !layer.locked && layer.opacity > 0) editable.add(maskSelectionKey(selection));
}
const allKeys = new Set(all.map(maskSelectionKey));
const current = new Set<string>();
currentValue.forEach((item, index) => {
if (!record(item)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is invalid`);
const selection = { maskId: text(item.maskId, `current[${index}].maskId`), layerId: text(item.layerId, `current[${index}].layerId`), splineId: text(item.splineId, `current[${index}].splineId`), pointId: text(item.pointId, `current[${index}].pointId`) };
const key = maskSelectionKey(selection);
if (!allKeys.has(key)) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `current[${index}] references a missing Mask point`);
if (current.has(key)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is duplicated`);
current.add(key);
});
const hits = new Set<string>();
for (const mask of project.masks) for (const layer of mask.layers) {
if (!layer.visible || layer.locked || layer.opacity <= 0) continue;
for (const spline of layer.splines) for (const point of spline.points) {
if (point.co[0] >= minimum[0] && point.co[0] <= maximum[0] && point.co[1] >= minimum[1] && point.co[1] <= maximum[1]) {
hits.add(maskSelectionKey({ maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id }));
}
}
}
const selected = mode === "REPLACE" ? new Set<string>() : new Set(current);
for (const key of hits) {
if (!editable.has(key)) continue;
if (mode === "TOGGLE" && selected.has(key)) selected.delete(key);
else selected.add(key);
}
return all.filter((selection) => selected.has(maskSelectionKey(selection)));
}

View File

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

View File

@@ -113,6 +113,7 @@ export type WebEngineEditCommand =
| { type: "setFontProperties"; dataId: string; properties: Partial<NonMeshFontPropertiesIR> }
| { type: "setFontAdvanced"; dataId: string; characters: NonMeshFontCharacterIR[]; textBoxes: NonMeshFontTextBoxIR[]; activeTextBox: number }
| { type: "setFontLinks"; dataId: string; links: NonMeshFontLinksIR }
| { type: "setVolumeProperties"; dataId: string; sourcePath: string; displayDensity: number; interpolation: "NEAREST" | "LINEAR"; stepSize: number; velocityGrid?: string; velocityScale?: number }
| { type: "createGreasePencilLayer"; dataId: string; name: string }
| { type: "removeGreasePencilLayer"; dataId: string; layerId: string }
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" }

View File

@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("recovers the main-thread Chromium viewport after a real WebGL context loss", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-device-status", "ready");
const result = await canvas.evaluate(async (element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
const extension = gl?.getExtension("WEBGL_lose_context");
if (!gl || !extension) return { supported: false, pixels: 0 };
const waitFor = (status: string): Promise<void> => new Promise((resolve, reject) => {
const started = performance.now();
const poll = (): void => {
if (element.dataset.deviceStatus === status) { resolve(); return; }
if (performance.now() - started > 10_000) { reject(new Error(`Timed out waiting for device status ${status}`)); return; }
requestAnimationFrame(poll);
};
poll();
});
extension.loseContext();
await waitFor("lost");
extension.restoreContext();
await waitFor("ready");
const pixels = new Uint8Array(16 * 16 * 4);
gl.readPixels(0, 0, 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return { supported: true, pixels: pixels.reduce((total, value) => total + (value > 0 ? 1 : 0), 0) };
});
expect(result.supported).toBe(true);
expect(result.pixels).toBeGreaterThan(0);
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)");
});

View File

@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("keeps Main edit and save-reopen available during a Chromium network interruption", async ({ context, page }) => {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 20_000 });
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
await context.setOffline(true);
try {
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("blender-web.blend");
const savedPath = await download.path();
expect(savedPath).not.toBeNull();
await page.setInputFiles("[data-testid=blend-file-input]", savedPath!);
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r3 (4 objects)");
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
}
finally {
await context.setOffline(false);
}
});

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
test("meets the Chromium OPFS Simulation cache playback performance gate", async ({ page }) => {
test.setTimeout(45_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
const frameCount = 600;
const frameBytes = 88;
const digest = async (data: ArrayBuffer): Promise<string> => {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
};
const payload = new ArrayBuffer(frameCount * frameBytes);
const payloadBytes = new Uint8Array(payload);
const objectId = new TextEncoder().encode("object:CacheTarget");
for (let frame = 1; frame <= frameCount; frame += 1) {
const offset = (frame - 1) * frameBytes;
const view = new DataView(payload, offset, frameBytes);
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);
view.setUint8(16, objectId.length);
payloadBytes.set(objectId, offset + 17);
[frame / 10, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
}
const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({
frame: index + 1,
byteOffset: index * frameBytes,
byteLength: frameBytes,
sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)),
})));
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer;
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
const manifest = {
schemaVersion: 1 as const,
graphId: "geometry-node-tree:simulation-performance",
graphHash: fixedHash,
sourceBlendSha256: await digest(sourceBlend),
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
cacheSha256: await digest(payload),
blenderVersion: "5.2.0",
frameStart: 1,
frameEnd: frameCount,
byteLength: payload.byteLength,
frames,
};
const projectId = `simulation-performance-${Date.now()}`;
const started = performance.now();
const writer = new StorageClient();
const saved = await writer.saveProject(projectId, 1, sourceBlend.slice(0));
const stored = await writer.putSimulationCache(projectId, manifest, payload);
writer.terminate();
const storedAt = performance.now();
const reader = new StorageClient();
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: 1,
sceneId: "scene:CachePerformance",
source: { kind: "mock" as const },
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
activeObjectId: "object:CacheTarget",
frame: { current: 1, start: 1, end: frameCount },
nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", 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: [],
};
let publishedFrames = 0;
let lastTranslation = 0;
const playback = new BrowserTransformCachePlaybackSession(scene, {
frameStart: 1,
frameEnd: frameCount,
readFrame: async (frame, signal) => {
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame);
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
return read.data;
},
}, (preview) => {
publishedFrames += 1;
lastTranslation = preview.nodes[0].transform.translation[0];
});
const playbackResult = await playback.play();
const finished = performance.now();
reader.terminate();
return {
backend: saved.backend,
frameCount,
byteLength: manifest.byteLength,
status: playbackResult.status,
appliedFrames: playbackResult.appliedFrames,
lastFrame: playbackResult.lastFrame,
publishedFrames,
lastTranslation,
storeMs: Math.round(storedAt - started),
playbackMs: Math.round(finished - storedAt),
elapsedMs: Math.round(finished - started),
};
});
expect(result.backend).toBe("opfs");
expect(result).toMatchObject({ frameCount: 600, byteLength: 52_800, status: "COMPLETED", appliedFrames: 600, lastFrame: 600, publishedFrames: 600 });
expect(result.lastTranslation).toBeCloseTo(60, 5);
expect(result.storeMs).toBeLessThan(30_000);
expect(result.playbackMs).toBeLessThan(30_000);
expect(result.elapsedMs).toBeLessThan(30_000);
});

View File

@@ -7,6 +7,9 @@ const animationBlend = path.resolve(import.meta.dirname, "../../../tests/files/w
const riggedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/rigged_shape_scene.blend");
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
const greasePencilBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
const compositorBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/compositor_scene.blend");
const sequencerBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/sequencer_scene.blend");
const maskBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/mask_scene.blend");
const localTexturePng = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png");
const deformationGolden = path.resolve(import.meta.dirname, "../../../tests/golden/W-079/blender-deformation.json");
@@ -298,7 +301,9 @@ test("reads bounded non-mesh data blocks and previews supported geometry", async
await expect(page.getByText("WebVolumeObject", { exact: true })).toBeVisible();
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6");
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "1");
await expect(canvas).toHaveAttribute("data-volume-status", "blocked");
await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING");
const renderedPixels = await canvas.evaluate((element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
if (!gl) return 0;
@@ -518,6 +523,45 @@ test("validates the N-016 Grease Pencil editor context transaction boundary", as
expect(result.missingPoint).toContain("GREASE_PENCIL_EDITOR_INVALID");
});
test("raycasts and highlights N-016 Grease Pencil points with stable drawing identity", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/grease-pencil-viewport-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, strokeIndex: 0, pointIndex: 1 });
expect(result.selectedColor).toEqual([expect.closeTo(1), expect.closeTo(0.38), expect.closeTo(0.08)]);
expect(result.preview).toEqual([[0.5, 2, -1], [0.5, 2, -1]]);
expect(result.restored).toEqual([[0, 0, -0], [0, 0, -0]]);
expect(result.proxyCount).toBe(1);
});
for (const offscreen of [false, true]) test(`previews N-016 Grease Pencil points and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
await page.goto(offscreen ? "/?offscreen=1" : "/");
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 });
await page.getByText("GreasePencilObject", { exact: true }).click();
await page.getByRole("button", { name: "Object Mode" }).click();
await page.getByRole("button", { name: "Select All" }).click();
await expect(page.getByText(/\d+ vert selected/)).toBeVisible();
const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1");
const axis = page.getByRole("button", { name: "X 轴变换手柄" });
const bounds = await axis.boundingBox();
if (!bounds) throw new Error("Grease Pencil gizmo X axis is unavailable");
const x = bounds.x + bounds.width / 2;
const y = bounds.y + bounds.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 24, y, { steps: 3 });
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-grease-pencil-preview", /[1-9]\d*/);
await page.mouse.up();
await expect(canvas).toHaveAttribute("data-grease-pencil-preview", "0");
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
});
test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
@@ -533,6 +577,21 @@ test("edits N-016 Grease Pencil layers and frames from the bounded editor panel"
await expect(editor).toContainText("2 layers / 2 frames / 1 strokes");
});
test("navigates real N-016 Grease Pencil drawing frames in the bounded Dope Sheet", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
await page.getByText("GreasePencilObject", { exact: true }).click();
const dopeSheet = page.getByLabel("Dope Sheet");
await expect(dopeSheet).toContainText("GreasePencilData");
await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true })).toBeVisible();
await page.getByLabel("当前帧").fill("12");
await expect(page.locator("output.frame-number")).toHaveText("12");
await page.getByTestId("grease-pencil-editor").getByRole("button", { name: "Add Frame" }).click();
await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 12" })).toBeVisible({ timeout: 20_000 });
await dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true }).click();
await expect(page.locator("output.frame-number")).toHaveText("1");
});
test("moves an N-016 Grease Pencil point through one revision-bound Main transaction", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
@@ -544,6 +603,48 @@ test("moves an N-016 Grease Pencil point through one revision-bound Main transac
await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1, 0, 0", { timeout: 20_000 });
});
test("reopens an edited N-016 Grease Pencil drawing after WebEngine Worker restart", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(greasePencilBlend));
const result = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const first = new WebEngineClient({ timeoutMs: 20_000 });
const opened = await first.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
const data = opened.snapshot.greasePencils?.[0];
const layer = data?.layers[0];
const drawingFrame = layer?.frames[0];
if (!data || !layer || !drawingFrame) throw new Error("Grease Pencil fixture is incomplete");
const strokes = drawingFrame.drawing.strokes.map((stroke, strokeIndex) => ({
cyclic: stroke.cyclic,
materialIndex: stroke.materialIndex,
points: (stroke.points ?? []).map((point, pointIndex) => ({
...point,
position: strokeIndex === 0 && pointIndex === 0 ? [point.position[0] + 0.25, point.position[1], point.position[2]] as [number, number, number] : [...point.position] as [number, number, number],
})),
}));
const edited = await first.applyCommand({ type: "setGreasePencilStrokes", dataId: data.id, layerId: layer.id, frame: drawingFrame.frame, baseRevision: opened.snapshot.revision, strokes });
const saved = await first.saveBlend();
first.terminate();
const restarted = new WebEngineClient({ timeoutMs: 20_000 });
const reopened = await restarted.openBlend(saved);
restarted.terminate();
const reopenedData = reopened.snapshot.greasePencils?.find((candidate) => candidate.id === data.id);
const point = reopenedData?.layers.find((candidate) => candidate.id === layer.id)?.frames.find((candidate) => candidate.frame === drawingFrame.frame)?.drawing.strokes[0]?.points?.[0];
return {
revision: edited.snapshot.revision,
identity: [reopenedData?.id, reopenedData?.layers[0]?.id, reopenedData?.layers[0]?.frames[0]?.drawing.id],
position: point?.position,
radius: point?.radius,
opacity: point?.opacity,
};
}, new Uint8Array(bytes));
expect(result.revision).toBeGreaterThan(0);
expect(result.identity).toEqual(["grease-pencil:GreasePencilData", "grease-pencil-layer:GreasePencilData:Lines", "grease-pencil-drawing:GreasePencilData:0"]);
expect(result.position).toEqual([-1.25, 0, 0]);
expect(result.radius).toBeGreaterThan(0);
expect(result.opacity).toBeCloseTo(0.9);
});
test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -557,6 +658,19 @@ test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", as
expect(result.hit).toContain("PAINT_SCHEMA_INVALID");
expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED");
expect(result.brush).toEqual([{ index: 1, weight: 0.4 }, { index: 2, weight: 0.8 }]);
expect(result.spatialBrush).toEqual([16, 64, [450, 549, 550, 551, 650]]);
expect(result.selectedMasked).toEqual([{ index: 450, weight: expect.closeTo(0.005823, 5) }, { index: 550, weight: 0.5 }]);
expect(result.spatialVisibility).toContain("PAINT_SCHEMA_INVALID");
expect(result.spatialSelection).toContain("PAINT_SCHEMA_INVALID");
expect(result.selectionDuplicate).toContain("PAINT_SCHEMA_INVALID");
expect(result.maskInvalid).toContain("PAINT_SCHEMA_INVALID");
expect(result.selectionUnknown).toContain("PAINT_SCHEMA_INVALID");
expect(result.spatialForgery).toContain("PAINT_SCHEMA_INVALID");
expect(result.spatialMutation).toBe(16);
expect(result.weightPatch).toMatchObject({ indices: [0, 2], values: [1, 0.7], normalize: false });
expect(result.colorPatch).toEqual({ indices: [0, 2], colors: [0, 1, 0, 0.5, 0.75, 0.25, 0, 0.875] });
expect(result.patchRevision).toContain("REVISION_CONFLICT");
expect(result.patchIdentity).toContain("PAINT_SCHEMA_INVALID");
expect((result.udim as number[]).slice(4, 8)).toEqual([10, 20, 30, 255]);
expect(result.udimRevision).toContain("REVISION_CONFLICT");
expect(result.udimStale).toContain("PAINT_TILE_HASH_MISMATCH");
@@ -607,6 +721,28 @@ test("commits N-017 vertex color and weight patches from the bounded paint panel
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 });
});
test("blends N-017 selection-masked color and weight patches through one Main transaction each", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
await page.getByText("AttributeMeshObject", { exact: true }).click();
await page.getByRole("button", { name: /Object Mode/ }).click();
await page.getByRole("button", { name: "1 Vertex" }).click();
await page.getByRole("button", { name: "Select All" }).click();
const editor = page.getByTestId("paint-editor");
await editor.getByLabel("Paint selection mask").fill("0.5");
await editor.getByLabel("Paint vertex color").fill("#0080ff");
let revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1");
await editor.getByRole("button", { name: "Blend Color" }).click();
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
await expect(editor.getByTestId("paint-color-attribute")).toHaveText("WebPaintColor POINT");
revision += 1;
await editor.getByLabel("Paint vertex group").fill("SelectionMaskPaint");
await editor.getByLabel("Paint vertex weight").fill("0.8");
await editor.getByRole("button", { name: "Blend Weight" }).click();
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
await expect(editor.getByTestId("paint-vertex-group")).toHaveText("SelectionMaskPaint");
});
test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -625,7 +761,12 @@ test("validates the N-018 physics capability and cache manifests without claimin
expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE");
expect(result.manifest).toBe("READY");
expect(result.browserPlayback).toEqual([7, "object:Cloth", [1, 2, 3]]);
expect(result.browserPreview).toEqual([7, [1, 2, 3], [1, 2, 3]]);
expect(result.browserFrameMismatch).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
expect(result.browserRotation).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
expect(result.browserSession).toEqual(["COMPLETED", 2, 8, [7, 8]]);
expect(result.browserSupersede).toEqual([true, 8, [8]]);
expect(result.browserCancel).toEqual([true, []]);
});
test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => {
@@ -637,7 +778,7 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W
await expect(canvas).toHaveAttribute("data-view-look", "None");
await expect(canvas).toHaveAttribute("data-mist", "disabled");
const mapping = await page.evaluate(async () => {
const { blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
const { blenderLightColor, blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
const { Object3D } = await import("/src/vendor/three/three.module.js");
const definition = {
id: "light:test", name: "Test", lightType: 0, color: [1, 1, 1], energy: 100, exposure: 2,
@@ -650,9 +791,44 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W
selectable: true, localMatrix: new Array(16).fill(0), worldMatrix: new Array(16).fill(0),
transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 },
}, new Object3D());
return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow };
const warmDefinition = { ...definition, color: [1, 1, 1] as [number, number, number], useTemperature: true, temperature: 5000 };
const neutralDefinition = { ...warmDefinition, temperature: 6500 };
const disabledDefinition = { ...warmDefinition, color: [0.25, 0.5, 0.75] as [number, number, number], useTemperature: false };
return {
intensity: blenderLightIntensity(definition),
castShadow: light.castShadow,
sourceShadow: light.userData.blenderCastsShadow,
warm: blenderLightColor(warmDefinition),
neutral: blenderLightColor(neutralDefinition),
disabled: blenderLightColor(disabledDefinition),
appliedWarm: createPBRLight(warmDefinition).color.toArray(),
};
});
expect(mapping).toEqual({ intensity: 40, castShadow: false, sourceShadow: false });
expect(mapping.intensity).toBe(40);
expect(mapping.castShadow).toBe(false);
expect(mapping.sourceShadow).toBe(false);
expect(mapping.neutral).toEqual([1, 1, 1]);
expect(mapping.disabled).toEqual([0.25, 0.5, 0.75]);
expect(mapping.warm[0]).toBe(1);
expect(mapping.warm[1]).toBeGreaterThan(0.7);
expect(mapping.warm[1]).toBeLessThan(0.9);
expect(mapping.warm[2]).toBeGreaterThan(0.5);
expect(mapping.warm[2]).toBeLessThan(0.75);
expect(mapping.appliedWarm).toEqual(mapping.warm);
});
test("preserves N-019 World and Scene color management in renderer-bound SceneDelta", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scene-delta-render-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.collections).toEqual([1, 1]);
expect(result.applied).toEqual([[0.8, 0.4, 0.2], 2, "Standard", 1]);
expect(result.rebuild).toBe(true);
expect(result.invalid).toContain("SceneDelta.worlds is invalid");
});
test("executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates", async ({ page }) => {
@@ -670,6 +846,34 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes
expect(result.unsupported).toBe("COMPOSITOR_NODE_UNSUPPORTED");
expect(result.budget).toContain("COMPOSITOR_BUDGET_EXCEEDED");
expect(result.cancelled).toContain("COMPOSITOR_CANCELLED");
expect(result.cache).toEqual([false, true, false, true, true, 0.25, 2, 256]);
});
test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 graph", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend));
const result = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const { executeCompositorGraph, gateCompositorGraph } = await import("/src/compositor/CompositorExecutor.ts");
const client = new WebEngineClient({ timeoutMs: 20_000 });
try {
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "CompositorScene");
if (!scene?.compositorGraph) throw new Error("Real compositor graph is missing");
const execution = executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 });
return {
status: scene.compositorStatus,
pixel: Array.from(execution.composite.data),
evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name),
gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code,
};
}
finally { client.terminate(); }
}, new Uint8Array(bytes));
expect(result.status).toBe("AVAILABLE");
expect(result.pixel).toEqual([0.75, 0.5, 0, 0.75]);
expect(result.evaluated).toEqual(["WebConstantColor", "WebExposure", "WebInvert", "WebComposite"]);
expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED");
});
test("validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates", async ({ page }) => {
@@ -682,14 +886,39 @@ test("validates N-021 sequencer strips, deterministic edits, sandbox paths and c
}));
expect(result.valid).toBe(105);
expect(result.edit).toEqual([5, [["strip:Movie", 15, 20, 100, 105], ["strip:MovieRight", 20, 25, 105, 110]]]);
expect(result.frame).toEqual([["strip:MovieRight", 3, 105]]);
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.path).toContain("SEQUENCER_RESOURCE_OUTSIDE_PROJECT");
expect(result.cycle).toContain("SEQUENCER_DEPENDENCY_CYCLE");
expect(result.budget).toContain("SEQUENCER_BUDGET_EXCEEDED");
expect(result.codec).toBe("SEQUENCER_CODEC_UNSUPPORTED");
expect(result.transition).toEqual(["CROSS", 0.5, "strip:From", 105, "strip:To", 205]);
expect(result.transitionBoundary).toContain("SEQUENCER_SCHEMA_INVALID");
expect((result.runtime as { localEncoding: string }).localEncoding).toBe("BLOCKED");
});
test("resolves the N-021 transition frame from a real Blender 5.2 sequencer", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(sequencerBlend));
const result = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const { resolveSequencerTransitionFrame } = await import("/src/sequencer/SequencerTimeline.ts");
const client = new WebEngineClient({ timeoutMs: 20_000 });
try {
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "SequencerScene");
const timeline = scene?.sequencerTimeline;
const effect = timeline?.strips.find((strip) => strip.name === "WebCross");
if (!timeline || !effect) throw new Error("Real sequencer transition is missing");
const transition = resolveSequencerTransitionFrame(timeline, effect.id, 22);
const names = new Map(timeline.strips.map((strip) => [strip.id, strip.name]));
return { status: scene.sequencerStatus, type: transition.effectType, factor: transition.factor, from: [names.get(transition.from.stripId), transition.from.sourceFrame], to: [names.get(transition.to.stripId), transition.to.sourceFrame] };
}
finally { client.terminate(); }
}, new Uint8Array(bytes));
expect(result).toEqual({ status: "AVAILABLE", type: "CROSS", factor: 0.5, from: ["WebImage", 1], to: ["WebImageB", 1] });
});
test("validates N-022 tracking markers, masks, resource bindings and solve gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -699,6 +928,7 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates
worker.postMessage({});
}));
expect(result.edit).toEqual([6, [1, 10], [0.4, 0.6]]);
expect(result.raycast).toMatchObject({ maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", kind: "POINT", pointId: "point:1", distance: 0 });
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.path).toContain("TRACKING_RESOURCE_OUTSIDE_PROJECT");
expect(result.binding).toContain("TRACKING_BINDING_MISSING");
@@ -708,6 +938,34 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates
expect(result.solveGate).toBe("BLOCKED");
});
test("raycasts and marquee-selects editable N-022 points from a real Blender 5.2 Mask", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(maskBlend));
const result = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const { raycastMaskProject, selectMaskPointsInBounds } = await import("/src/tracking/MaskSelection.ts");
const client = new WebEngineClient({ timeoutMs: 20_000 });
try {
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
const project = opened.snapshot.trackingMasks;
if (!project) throw new Error("Real Mask project is missing");
const locked = raycastMaskProject(project, [0.1, 0.2], 0.001);
const editable = raycastMaskProject(project, [0.2, 0.2], 0.001);
const selected = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25]);
const toggled = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25], selected, "TOGGLE");
const layerNames = new Map(project.masks[0].layers.map((layer) => [layer.id, layer.name]));
return { status: opened.snapshot.trackingMaskStatus, locked, editable: editable && { ...editable, layerName: layerNames.get(editable.layerId) }, selected: selected.map((item) => [layerNames.get(item.layerId), item.pointId]), toggled };
}
finally { client.terminate(); }
}, new Uint8Array(bytes));
expect(result.status).toBe("AVAILABLE");
expect(result.locked).toBeNull();
expect(result.editable).toMatchObject({ kind: "POINT", layerName: "WebEditableLayer", pointId: "mask-point:1:0:0" });
expect(result.editable?.distance).toBeLessThan(1e-7);
expect(result.selected).toEqual([["WebEditableLayer", "mask-point:1:0:0"]]);
expect(result.toggled).toEqual([]);
});
test("validates N-023 asset catalogs, library graphs, archive budgets and IO gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -721,11 +979,19 @@ test("validates N-023 asset catalogs, library graphs, archive budgets and IO gat
expect(result.license).toContain("ASSET_LICENSE_MISSING");
expect(result.cycle).toContain("LIBRARY_DEPENDENCY_CYCLE");
expect(result.archive).toContain("IO_ARCHIVE_UNSAFE");
expect(result.archivePath).toContain("IO_ARCHIVE_UNSAFE");
expect(result.archiveLength).toContain("IO_ARCHIVE_UNSAFE");
expect(result.archivePlan).toEqual([
{ path: "a.bin", compressedBytes: 2, uncompressedBytes: 2, compressedOffset: 0 },
{ path: "z.bin", compressedBytes: 3, uncompressedBytes: 4, compressedOffset: 2 },
]);
expect(result.uri).toContain("IO_EXTERNAL_URI_BLOCKED");
expect(result.glb).toBe("READY");
expect(result.obj).toBe("IO_FORMAT_UNSUPPORTED");
expect(result.library).toBe("BLOCKED");
expect((result.storage as { contentAddressedIndex: string }).contentAddressedIndex).toBe("LOCAL_BOUNDED");
expect(result.preview).toMatch(/^[a-f0-9]{64}$/);
expect(result.previewSize).toContain("ASSET_MANIFEST_INVALID");
});
test("validates N-024 editor context, selection sync, layout budgets and workflow gates", async ({ page }) => {
@@ -743,6 +1009,9 @@ test("validates N-024 editor context, selection sync, layout budgets and workflo
expect(result.view).toBe("READY");
expect(result.writer).toBe("EDITOR_WRITER_UNAVAILABLE");
expect(result.gizmo).toBe("EDITOR_GIZMO_UNAVAILABLE");
expect(result.keymap).toBe("object.delete");
expect(result.keymapConflict).toContain("EDITOR_KEYMAP_INVALID");
expect(result.scopedKeymap).toEqual(["view.command", "timeline.command"]);
});
test("validates N-025 script policy, signatures, budgets and platform gates", async ({ page }) => {
@@ -755,6 +1024,11 @@ test("validates N-025 script policy, signatures, budgets and platform gates", as
}));
expect(result.valid).toBe("scripts/clean.py");
expect(result.exec).toBe("SCRIPT_SANDBOX_UNAVAILABLE");
expect(result.audit).toEqual(["DENY", "SCRIPT_SANDBOX_UNAVAILABLE", true, ["READ_MAIN"], 1000, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]);
expect(result.auditLog).toEqual([2, null, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]);
expect(result.auditReplay).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.auditTamper).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.auditDate).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.server).toBe("SERVER_JOB_UNAVAILABLE");
expect(result.path).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.policy).toContain("SCRIPT_POLICY_DENIED");
@@ -777,6 +1051,10 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a
expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE");
expect(result.status).toContain("RELEASE_MANIFEST_INVALID");
expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING");
expect(result.excludedOverlap).toContain("RELEASE_MANIFEST_INVALID");
expect(result.disabledEvidence).toContain("RELEASE_MANIFEST_INVALID");
expect(result.emptyArtifact).toContain("RELEASE_MANIFEST_INVALID");
expect(result.generatedAt).toContain("RELEASE_MANIFEST_INVALID");
});
test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => {
@@ -797,7 +1075,7 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]);
expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]);
expect(result.migrated).toEqual([2, "mesh:1"]);
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
expect(result.gates).toEqual(["READY", "READY", "READY"]);
});
test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => {
@@ -808,23 +1086,324 @@ test("validates the N-015 curve gizmo interaction transaction boundary", async (
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.preview).toEqual([3, [1.25, 2, 3]]);
expect(result.commit).toEqual([4, [1.25, 2, 3]]);
expect(result.rendererPreview).toEqual([[ -0.25, -0.25 ], -0.25, -0.5]);
expect(result.localFrame).toEqual([[0, 1, 0], [[0, 1, 0], [-1, 0, 0], [0, -0, 1]], [0, 1.25, 0]]);
expect(result.stale).toContain("REVISION_CONFLICT");
expect(result.duplicate).toContain("CURVE_GIZMO_INVALID");
expect(result.axis).toContain("CURVE_GIZMO_INVALID");
});
test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => {
for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(offscreen ? "/?offscreen=1" : "/");
await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend);
await expect(page.getByText("WebCurveObject", { exact: true })).toBeVisible({ timeout: 20_000 });
await page.getByText("WebCurveObject", { exact: true }).click();
await page.getByRole("button", { name: "Object Mode" }).click();
const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend));
const handle = await page.evaluate((input) => new Promise<[number, number, number]>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
worker.onmessage = (event) => {
if (event.data.kind !== "result" || event.data.requestId !== "curve-handle-open") return;
worker.terminate();
if (!event.data.ok) { reject(new Error(event.data.error?.message ?? "Curve fixture open failed")); return; }
const curve = event.data.result?.snapshot?.nonMeshData?.find((item: { id: string }) => item.id === "curve:WebCurveData");
if (!curve?.handlePoints?.length) { reject(new Error("Curve handle metadata is missing")); return; }
resolve(curve.handlePoints.slice(0, 3));
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: "curve-handle-open", command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
const hit = await page.locator("canvas.viewport-canvas").evaluate(async (canvas, input) => {
const { PerspectiveCamera, Vector3 } = await import("/src/vendor/three/three.module.js");
const bounds = canvas.getBoundingClientRect();
const camera = new PerspectiveCamera(45, bounds.width / bounds.height, 0.01, 1000);
if (input.offscreen) camera.position.set(7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), 7 * Math.sin(0.55));
else camera.position.set(4.5, -4.5, 3.5);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
const projected = new Vector3(input.position[0], input.position[2], -input.position[1]).project(camera);
return { x: bounds.left + (projected.x + 1) * bounds.width / 2, y: bounds.top + (1 - projected.y) * bounds.height / 2 };
}, { position: handle, offscreen });
await page.mouse.click(hit.x, hit.y);
if (!offscreen) await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-non-mesh-last-pick", /HANDLE_LEFT/);
await expect(page.getByText("1 vert selected", { exact: true })).toBeVisible();
const gizmo = page.getByLabel("变换 Gizmo");
await expect(gizmo).toHaveAttribute("data-gizmo-space", "HANDLE_LOCAL");
const localAxis = await page.getByRole("button", { name: "X 轴变换手柄" }).getAttribute("data-local-axis");
expect(localAxis).toMatch(/^-?\d+\.\d{6},-?\d+\.\d{6},-?\d+\.\d{6}$/);
const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1");
const axis = page.getByRole("button", { name: "X 轴变换手柄" });
const bounds = await axis.boundingBox();
if (!bounds) throw new Error("Curve gizmo X axis is unavailable");
const screenAxis = (await axis.getAttribute("data-screen-axis"))?.split(",").map(Number) ?? [];
expect(screenAxis).toHaveLength(2);
expect(Math.hypot(screenAxis[0], screenAxis[1])).toBeGreaterThan(0.5);
await page.screenshot({ path: testInfo.outputPath(`curve-handle-local-${offscreen ? "offscreen" : "main"}-1440x900.png`), fullPage: true });
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 + screenAxis[0] * 24, bounds.y + bounds.height / 2 + screenAxis[1] * 24, { steps: 3 });
await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "1");
await page.mouse.up();
await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "0");
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
});
test("validates the VDB conversion boundary and NanoVDB streaming contract", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => {
const result = await page.evaluate(() => new Promise<{
preparedByteLength: number;
conversionTarget: string;
conversionRequestSha256: string;
relocationKeepsContentKey: boolean;
ranges: Array<{ chunkIndex: number; start: number; endExclusive: number }>;
consumed: number[];
progress: number[];
stream: { completedChunks: number; completedBytes: number; totalBytes: number };
httpRangeByteLength: number;
invalidHttpRange: string;
outsideProject: string;
tamperedChunk: string;
incompleteStream: string;
cancelled: boolean;
rawBrowserGate: { status: string; issues: Array<{ code: string }> };
streamGate: { status: string };
renderGate: { status: string; issues: Array<{ code: string }> };
}>((resolve, reject) => {
const worker = new Worker("/src/workers/vdb-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.decodedByteLength).toBe(4);
expect(result.preparedByteLength).toBe(64);
expect(result.conversionTarget).toBe("SERVER");
expect(result.conversionRequestSha256).toMatch(/^[a-f0-9]{64}$/);
expect(result.relocationKeepsContentKey).toBe(true);
expect(result.ranges).toEqual([
{ chunkIndex: 0, start: 0, endExclusive: 32, sha256: expect.any(String) },
{ chunkIndex: 1, start: 32, endExclusive: 64, sha256: expect.any(String) },
]);
expect(result.consumed).toEqual([0, 1]);
expect(result.progress).toEqual([32, 64]);
expect(result.stream).toMatchObject({ completedChunks: 2, completedBytes: 64, totalBytes: 64 });
expect(result.httpRangeByteLength).toBe(32);
expect(result.invalidHttpRange).toContain("NANOVDB_STREAM_INCOMPLETE");
expect(result.outsideProject).toContain("NON_MESH_RESOURCE_OUTSIDE_PROJECT");
expect(result.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH");
expect(result.incompleteStream).toContain("NANOVDB_STREAM_INCOMPLETE");
expect(result.cancelled).toBe(true);
expect(result.rawBrowserGate.status).toBe("BLOCKED");
expect(result.rawBrowserGate.issues[0].code).toBe("VDB_CONVERSION_REQUIRED");
expect(result.streamGate.status).toBe("READY");
expect(result.renderGate.status).toBe("BLOCKED");
expect(result.renderGate.issues[0].code).toBe("VOLUME_SHADER_UNAVAILABLE");
});
test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ page }) => {
await page.goto("/");
const run = (action: "commit" | "reopen", state?: unknown) => page.evaluate(({ action, state }) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ action, state });
}), { action, state });
const committed = await run("commit");
expect(committed.committed).toMatchObject({ chunks: committed.realChunkCount, deduplicated: false });
expect(committed.deduplicated).toBe(true);
expect(committed.cancelled).toBe(true);
expect(committed.recovered.removedIncompleteBundles).toBe(1);
expect(committed.realBundleBytes).toBeGreaterThan(10_000_000);
expect(committed.realChunkCount).toBeGreaterThan(1);
// A new Worker proves discovery does not depend on temporary in-memory state.
const reopened = await run("reopen", committed.state);
expect(reopened.bindingStatus.status).toBe("READY");
expect(reopened.staleStatus).toMatchObject({ status: "BLOCKED", code: "VDB_SOURCE_CHANGED" });
expect(reopened.bundleHash).toBe(reopened.expectedHash);
expect(reopened.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH");
expect(reopened.manifestRollback).toContain("NANOVDB_HASH_MISMATCH");
expect(reopened.pruned).toMatchObject({ removed: [committed.state.bundleSha256], retainedBytes: 0 });
});
test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/vdb-webgpu-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.capability.available).toBe(true);
expect(result.payloadBytes).toBeGreaterThan(1_000_000);
expect(result.cpuSamples).toHaveLength(result.nativeSamples.length);
expect(result.gpuSamples).toHaveLength(result.nativeSamples.length);
result.nativeSamples.forEach((sample: any, index: number) => {
expect(result.cpuSamples[index].active).toBe(sample.active);
expect(result.cpuSamples[index].value).toBeCloseTo(sample.value, 6);
expect(result.gpuSamples[index].valid).toBe(true);
expect(result.gpuSamples[index].active).toBe(sample.active);
expect(result.gpuSamples[index].value).toBeCloseTo(sample.value, 6);
});
expect(result.visiblePixels).toBeGreaterThan(500);
expect(result.alphaSum).toBeGreaterThan(10_000);
expect(result.imageSha256).toBe("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978");
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]);
expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] });
expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual([
"VOLUME_COLOR_GRID_UNSUPPORTED",
"VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED",
"VOLUME_VELOCITY_RENDER_UNSUPPORTED",
]);
});
test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
import("/src/volume/nanovdb-viewport.ts"),
import("/src/three-adapter/viewport.ts"),
import("/src/three-adapter/offscreen-viewport.ts"),
]);
const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
const snapshot: any = {
schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" },
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
nodes: [{
id: "object:Volume", name: "Viewport Volume", type: "VOLUME", dataId: asset.dataId, parentId: null, visible: true,
localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
}],
meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [{ id: "scene:Volume", name: "Volume" }],
nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }],
activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 },
};
const waitFor = async (condition: () => boolean, timeoutMs = 20_000): Promise<void> => {
const deadline = performance.now() + timeoutMs;
while (!condition()) {
if (performance.now() > deadline) throw new Error("viewport volume timed out");
await new Promise((resolve) => setTimeout(resolve, 25));
}
};
const createCanvas = (): HTMLCanvasElement => {
const canvas = document.createElement("canvas");
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000";
document.body.append(canvas);
return canvas;
};
const mainCanvas = createCanvas();
const main = new ViewportRenderer(mainCanvas);
main.setSnapshot(snapshot);
main.setVolumeAssets([asset]);
await waitFor(() => mainCanvas.dataset.volumeStatus === "ready");
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
let mainVolumeObjects = 0;
main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; });
const mainPixels = new Uint8Array(64 * 64 * 4);
const gl = main.renderer.getContext();
gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels);
const mainVisible = Array.from({ length: 64 * 64 }, (_, index) => mainPixels[index * 4 + 3] > 0 && (mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2]) > 40).filter(Boolean).length;
main.dispose();
mainCanvas.remove();
const offscreenCanvas = createCanvas();
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
offscreen.setSnapshot(snapshot);
offscreen.setVolumeAssets([asset]);
await waitFor(() => offscreenCanvas.dataset.volumeStatus === "ready" && Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) };
offscreen.dispose();
offscreenCanvas.remove();
return {
payloadBytes: asset.grids[0].data.byteLength,
main: { status: mainCanvas.dataset.volumeStatus, count: Number(mainCanvas.dataset.volumeCount), volumeObjects: mainVolumeObjects, visible: mainVisible },
offscreen: offscreenResult,
};
});
expect(result.payloadBytes).toBeGreaterThan(1_000_000);
expect(result.main).toMatchObject({ status: "ready", count: 1, volumeObjects: 1 });
expect(result.main.visible).toBeGreaterThan(100);
expect(result.offscreen).toMatchObject({ status: "ready", count: 1 });
expect(result.offscreen.visible).toBeGreaterThan(10);
});
test("recovers NanoVDB paging from network, Worker and WebGPU device faults", async ({ page }) => {
await page.goto("/");
const gpu = await page.evaluate(() => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/vdb-fault-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(gpu.network.attempts).toBe(3);
expect(gpu.network.ifRanges[0]).toBe("");
expect(gpu.network.ifRanges[2]).toMatch(/^"vdb-/);
expect(gpu.network.resumeRanges).toHaveLength(2);
expect(gpu.network.resumeRanges[0]).toMatch(/^bytes=\d+-\d+$/);
const originalStart = Number(gpu.network.resumeRanges[0].match(/^bytes=(\d+)-/)?.[1]);
const resumedStart = Number(gpu.network.resumeRanges[1].match(/^bytes=(\d+)-/)?.[1]);
expect(resumedStart).toBe(originalStart + 4096);
expect(gpu.network.resumeIfRanges[0]).toBe("");
expect(gpu.network.resumeIfRanges[1]).toMatch(/^"vdb-/);
expect(gpu.network.resumedBytes).toBe(gpu.network.firstBytes);
expect(gpu.network.shortResponse).toContain("NANOVDB_STREAM_INCOMPLETE");
expect(gpu.network.changedEtag).toContain("NANOVDB_HASH_MISMATCH");
expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE");
expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] });
expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED");
expect(gpu.paging.pageCount).toBeGreaterThan(1);
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
expect(gpu.samplesStable).toBe(true);
const interrupted = await page.evaluate(() => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
const timeout = setTimeout(() => { worker.terminate(); reject(new Error("OPFS interrupt gate timed out")); }, 20_000);
worker.onmessage = (event) => {
if (!event.data.staged) return;
clearTimeout(timeout);
worker.terminate();
setTimeout(() => {
const recovery = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
recovery.onmessage = (recoveryEvent) => { recovery.terminate(); recoveryEvent.data.error ? reject(new Error(recoveryEvent.data.error)) : resolve(recoveryEvent.data); };
recovery.onerror = (error) => { recovery.terminate(); reject(new Error(error.message)); };
recovery.postMessage({ action: "recoverInterrupted" });
}, 100);
};
worker.onerror = (error) => { clearTimeout(timeout); worker.terminate(); reject(new Error(error.message)); };
worker.postMessage({ action: "interrupt" });
}));
expect(interrupted.removedIncompleteBundles).toBeGreaterThanOrEqual(1);
const runOPFS = (action: "prepareQuota" | "quota" | "verifyQuota", state?: unknown): Promise<any> => page.evaluate(({ action, state }) => new Promise<any>((resolve, reject) => {
const holder = window as unknown as { vdbQuotaWorker?: Worker };
const worker = holder.vdbQuotaWorker ?? new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
holder.vdbQuotaWorker = worker;
worker.onmessage = (event) => { event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
worker.onerror = (error) => { reject(new Error(error.message)); };
worker.postMessage({ action, state });
}), { action, state });
const baseline = await runOPFS("prepareQuota");
const cdp = await page.context().newCDPSession(page);
const origin = new URL(page.url()).origin;
const usage = await cdp.send("Storage.getUsageAndQuota", { origin });
await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 96 * 1024 });
const quota = await runOPFS("quota", baseline.state);
expect(quota.quotaError).toMatch(/QuotaExceededError|quota/i);
if (quota.recoveryWhileQuotaLimited) expect(quota.recoveryWhileQuotaLimited).toMatch(/QuotaExceededError|quota/i);
await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 512 * 1024 * 1024 });
const verified = await runOPFS("verifyQuota", baseline.state);
expect(verified.previousBundleReadable).toBe(true);
expect((quota.recoveredWhileQuotaLimited?.removedIncompleteBundles ?? 0) + verified.recovered.removedIncompleteBundles).toBeGreaterThanOrEqual(1);
await page.evaluate(() => {
const holder = window as unknown as { vdbQuotaWorker?: Worker };
holder.vdbQuotaWorker?.terminate();
delete holder.vdbQuotaWorker;
});
});
test("returns real Blender evaluations for legacy non-mesh geometry", async ({ page }) => {
@@ -865,7 +1444,9 @@ test("keeps N-015 non-mesh previews in the OffscreenCanvas renderer", async ({ p
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker");
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6", { timeout: 20_000 });
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "1");
await expect(canvas).toHaveAttribute("data-volume-status", "blocked");
await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING");
await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
expect(await canvas.getAttribute("data-renderer-error")).toBeNull();
});
@@ -1025,16 +1606,27 @@ test("persists and revalidates content-addressed Simulation caches across Worker
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
const digest = async (data: ArrayBuffer): Promise<string> => {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
};
const transformFrame = (frame: number, x: number): ArrayBuffer => {
const bytes = new ArrayBuffer(88);
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:CacheTarget"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
[x, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
return bytes;
};
const projectId = `simulation-e2e-${Date.now()}`;
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer;
const source = Uint8Array.from([11, 12, 13, 21, 22, 23, 24]);
const frameOne = source.buffer.slice(0, 3);
const frameTwo = source.buffer.slice(3);
const payload = source.buffer.slice(0);
const frameOne = transformFrame(1, 1);
const frameTwo = transformFrame(2, 2);
const payloadBytes = new Uint8Array(frameOne.byteLength + frameTwo.byteLength);
payloadBytes.set(new Uint8Array(frameOne), 0);
payloadBytes.set(new Uint8Array(frameTwo), frameOne.byteLength);
const payload = payloadBytes.buffer;
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
const manifest = {
schemaVersion: 1 as const,
@@ -1048,8 +1640,8 @@ test("persists and revalidates content-addressed Simulation caches across Worker
frameEnd: 2,
byteLength: payload.byteLength,
frames: [
{ frame: 1, byteOffset: 0, byteLength: 3, sha256: await digest(frameOne) },
{ frame: 2, byteOffset: 3, byteLength: 4, sha256: await digest(frameTwo) },
{ frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) },
{ frame: 2, byteOffset: frameOne.byteLength, byteLength: frameTwo.byteLength, sha256: await digest(frameTwo) },
],
};
const first = new StorageClient();
@@ -1061,6 +1653,12 @@ test("persists and revalidates content-addressed Simulation caches across Worker
const listed = await restarted.listSimulationCaches(projectId);
const read = await restarted.readSimulationCache(projectId, stored.cacheKey);
const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2);
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: 1, sceneId: "scene:Cache", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: 2 }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", 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: [] };
let publishedFrame = 0;
let publishedTranslation: number[] = [];
const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; });
await playback.seek(2);
let missingFrameCode = "";
try {
await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3);
@@ -1070,7 +1668,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
}
let corruptCode = "";
try {
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0));
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, frameOne.slice(0));
}
catch (error) {
corruptCode = String((error as Error & { code?: string }).code ?? "");
@@ -1080,10 +1678,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker
cacheKey: stored.cacheKey,
path: stored.path,
listed: listed.caches.map((cache) => cache.cacheKey),
bytes: Array.from(new Uint8Array(read.data)),
bytes: read.data.byteLength,
frame: frameRead.frame,
frameOffset: frameRead.byteOffset,
frameBytes: Array.from(new Uint8Array(frameRead.data)),
frameBytes: frameRead.data.byteLength,
frameMagic: new DataView(frameRead.data).getUint32(0, true),
publishedFrame,
publishedTranslation,
missingFrameCode,
corruptCode,
};
@@ -1091,10 +1692,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker
expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/);
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
expect(result.listed).toContain(result.cacheKey);
expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]);
expect(result.bytes).toBe(176);
expect(result.frame).toBe(2);
expect(result.frameOffset).toBe(3);
expect(result.frameBytes).toEqual([21, 22, 23, 24]);
expect(result.frameOffset).toBe(88);
expect(result.frameBytes).toBe(88);
expect(result.frameMagic).toBe(0x31465442);
expect(result.publishedFrame).toBe(2);
expect(result.publishedTranslation).toEqual([2, 0, 0]);
expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING");
expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
});
@@ -1386,15 +1990,35 @@ test("discovers and reuses a valid LOD cache after an application refresh", asyn
await expect(page.locator("[data-testid=engine-status]")).toContainText("cached LOD mesh", { timeout: 15_000 });
});
test("workspace context routes mode and operator search state", async ({ page }) => {
test("operator search executes context-filtered workspace, mode and Main commands", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Modeling" }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling");
await page.getByRole("button", { name: "Object Mode" }).click();
await page.keyboard.press("F3");
await page.getByRole("textbox", { name: "搜索操作" }).fill("switch to animation");
await page.keyboard.press("Enter");
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Animation");
await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0);
await page.keyboard.press("F3");
await page.getByRole("textbox", { name: "搜索操作" }).fill("enter edit");
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible();
await page.getByRole("button", { name: "操作搜索" }).click();
await page.keyboard.press("F3");
await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
await expect(page.getByRole("button", { name: "Add Cube" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0);
await page.getByRole("button", { name: "Edit Mode" }).click();
await page.keyboard.press("F3");
await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (4 objects)");
});
test("keeps Outliner selection bound to SceneIR activeObjectId", async ({ page }) => {
@@ -1425,6 +2049,42 @@ test("timeline transport controls update the imported frame range", async ({ pag
await expect(page.locator(".frame-number")).toHaveText("1");
});
test("serializes concurrent WebEngine init and blend open before the first Main edit", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ initReady: boolean; frame: number; revision: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
let initReady = false;
worker.onmessage = (event) => {
if (event.data.kind !== "result") return;
if (!event.data.ok) {
worker.terminate();
reject(new Error(event.data.error?.message ?? "WebEngine request failed"));
return;
}
if (event.data.requestId === "concurrent-init") {
initReady = event.data.result?.status?.ready === true;
return;
}
if (event.data.requestId === "concurrent-open") {
worker.postMessage({ requestId: "concurrent-edit", command: { type: "applyCommand", payload: { type: "setFrame", frame: 24 } } });
return;
}
if (event.data.requestId === "concurrent-edit") {
const snapshot = event.data.result?.snapshot;
worker.terminate();
if (!snapshot) { reject(new Error("WebEngine edit returned no SceneIR")); return; }
resolve({ initReady, frame: snapshot.frame.current, revision: snapshot.revision });
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: "concurrent-init", command: { type: "init" } });
worker.postMessage({ requestId: "concurrent-open", command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result).toEqual({ initReady: true, frame: 24, revision: 2 });
});
test("loads the local web_engine WASM worker", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ ok: boolean; ready?: boolean; error?: string }>((resolve) => {

View File

@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
test("decodes, uploads and renders a validated 4K texture in Chromium", async ({ page }) => {
test.setTimeout(45_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts");
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js");
const source = document.createElement("canvas");
source.width = 4096; source.height = 4096;
const context = source.getContext("2d", { alpha: false });
if (!context) throw new Error("2D texture fixture context is unavailable");
context.fillStyle = "#dd3322"; context.fillRect(0, 0, 2048, 4096);
context.fillStyle = "#22bb66"; context.fillRect(2048, 0, 2048, 4096);
const blob = await new Promise<Blob>((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("4K PNG encoding failed")), "image/png"));
const data = await blob.arrayBuffer();
const asset = await createGPUTextureAsset({ assetId: "asset:4k", imageId: "image:4k", mimeType: "image/png", width: 4096, height: 4096, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
const started = performance.now();
const store = new GPUTextureStore();
const status = await store.upload([asset]);
const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; document.body.append(canvas);
const renderer = new WebGLRenderer({ canvas, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const material = new MeshBasicMaterial({ map: store.get("image:4k", "BASE_COLOR") });
scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
const gl = renderer.getContext(); const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60) colored += 1;
const elapsedMs = Math.round(performance.now() - started);
material.dispose(); renderer.dispose(); store.dispose(); canvas.remove();
return { status, byteLength: data.byteLength, colored, elapsedMs };
});
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
expect(result.byteLength).toBeGreaterThan(0);
expect(result.colored).toBeGreaterThan(3_000);
expect(result.elapsedMs).toBeLessThan(30_000);
});

View File

@@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
test("decodes, uploads and renders a validated 8K texture in Chromium", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts");
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js");
const target = document.createElement("canvas"); target.width = 64; target.height = 64; document.body.append(target);
const renderer = new WebGLRenderer({ canvas: target, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
const gl = renderer.getContext(); const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
if (maxTextureSize < 8192) { renderer.dispose(); target.remove(); return { supported: false, maxTextureSize, byteLength: 0, colored: 0, elapsedMs: 0, status: { loaded: 0, rejected: 0, bytes: 0 } }; }
const source = document.createElement("canvas"); source.width = 8192; source.height = 8192;
const context = source.getContext("2d", { alpha: false });
if (!context) throw new Error("2D texture fixture context is unavailable");
context.fillStyle = "#2266dd"; context.fillRect(0, 0, 4096, 8192);
context.fillStyle = "#ddcc22"; context.fillRect(4096, 0, 4096, 8192);
const blob = await new Promise<Blob>((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("8K PNG encoding failed")), "image/png"));
const data = await blob.arrayBuffer();
const asset = await createGPUTextureAsset({ assetId: "asset:8k", imageId: "image:8k", mimeType: "image/png", width: 8192, height: 8192, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
const started = performance.now(); const store = new GPUTextureStore(); const status = await store.upload([asset]);
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const material = new MeshBasicMaterial({ map: store.get("image:8k", "BASE_COLOR") }); scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60 || pixels[index + 2] > 60) colored += 1;
const elapsedMs = Math.round(performance.now() - started);
material.dispose(); renderer.dispose(); store.dispose(); target.remove();
return { supported: true, maxTextureSize, status, byteLength: data.byteLength, colored, elapsedMs };
});
expect(result.supported, `WebGL MAX_TEXTURE_SIZE=${result.maxTextureSize}`).toBe(true);
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
expect(result.byteLength).toBeGreaterThan(0);
expect(result.colored).toBeGreaterThan(3_000);
expect(result.elapsedMs).toBeLessThan(45_000);
});