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