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,
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"test:e2e": "playwright test --config playwright.config.ts",
|
||||
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
|
||||
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
|
||||
"test:simulation-cache-performance": "playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts",
|
||||
"test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts",
|
||||
"test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts",
|
||||
"test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts",
|
||||
"test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts",
|
||||
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
|
||||
"test:browser": "playwright test --config playwright.release.config.ts",
|
||||
"test:cross-browser": "npm run test:browser",
|
||||
@@ -21,7 +26,7 @@
|
||||
"test:topology-collapse": "node ../tools/web/check-topology-collapse.mjs",
|
||||
"test:depsgraph": "node ../tools/web/check-depsgraph.mjs",
|
||||
"test:nonmesh-binary": "playwright test --config playwright.config.ts -g \"one-million-point binary transfer gate\"",
|
||||
"test:vdb": "playwright test --config playwright.config.ts -g \"OpenVDB metadata\"",
|
||||
"test:vdb": "playwright test --config playwright.config.ts -g \"VDB conversion boundary\"",
|
||||
"test:frame-evaluation": "node ../tools/web/check-frame-evaluation.mjs",
|
||||
"test:pose-constraint-goldens": "node ../tools/web/check-pose-constraint-goldens.mjs",
|
||||
"test:main-roundtrip": "node ../tools/web/check-main-roundtrip.mjs",
|
||||
@@ -30,6 +35,12 @@
|
||||
"test:selection-history": "playwright test --config playwright.config.ts -g \"N-015 selection history\"",
|
||||
"test:nonmesh-interaction": "playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"",
|
||||
"test:vdb-availability": "node ../tools/web/check-vdb-availability.mjs",
|
||||
"test:vdb-native": "node ../tools/web/check-vdb-native-pipeline.mjs",
|
||||
"test:vdb-server": "node ../tools/web/check-vdb-server-job.mjs",
|
||||
"test:vdb-opfs": "playwright test --config playwright.config.ts -g \"hash-bound NanoVDB project through OPFS\"",
|
||||
"test:vdb-webgpu": "playwright test --config playwright.config.ts -g \"real NanoVDB Float32 tree with WebGPU\"",
|
||||
"test:vdb-viewport": "playwright test --config playwright.config.ts -g \"both production viewport backends\"",
|
||||
"test:vdb-faults": "playwright test --config playwright.config.ts -g \"NanoVDB paging from network, Worker and WebGPU device faults\"",
|
||||
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
|
||||
"test:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"",
|
||||
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
headless: true,
|
||||
launchOptions: {
|
||||
executablePath: chromePath,
|
||||
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
|
||||
},
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface AssetEntryIR {
|
||||
export interface AssetLibraryIR { id: string; name: string; sourcePath: string; sourceSha256: string; dependencyIds: string[]; readOnly: boolean }
|
||||
export interface AssetLibraryManifestIR { schemaVersion: typeof ASSET_LIBRARY_SCHEMA; revision: number; catalogs: AssetCatalogIR[]; assets: AssetEntryIR[]; libraries: AssetLibraryIR[] }
|
||||
export interface IOArchiveEntryIR { path: string; compressedBytes: number; uncompressedBytes: number }
|
||||
export interface IOArchiveRangeIR extends IOArchiveEntryIR { compressedOffset: number }
|
||||
export interface IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] }
|
||||
|
||||
export class AssetLibraryValidationError extends Error {
|
||||
@@ -101,6 +102,25 @@ export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): vo
|
||||
if (!SHA256.test(actualSha256) || actualSha256 !== asset.sourceSha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Source hash does not match ${asset.id}`);
|
||||
}
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifyAssetPreview(preview: AssetPreviewIR, data: ArrayBuffer): Promise<void> {
|
||||
if (!(data instanceof ArrayBuffer) || data.byteLength !== preview.byteLength || await sha256(data) !== preview.sha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Preview hash or byte length does not match ${preview.assetId}`);
|
||||
const bytes = new Uint8Array(data);
|
||||
if (preview.mimeType === "image/png") {
|
||||
if (bytes.length < 24 || ![137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not PNG data`);
|
||||
const view = new DataView(data);
|
||||
if (view.getUint32(16, false) !== preview.width || view.getUint32(20, false) !== preview.height) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} PNG dimensions do not match the manifest`);
|
||||
}
|
||||
else {
|
||||
const riff = bytes.length >= 30 && String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP";
|
||||
if (!riff) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not WebP data`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseIORequest(value: unknown): IORequestIR {
|
||||
if (!record(value) || !FORMATS.has(value.format as IOFormat) || !["IMPORT", "EXPORT", "ANALYZE"].includes(value.operation as string) || !Array.isArray(value.externalUris) || !Array.isArray(value.archiveEntries)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "IO request is invalid");
|
||||
if (value.externalUris.length > ASSET_LIBRARY_BUDGET.maxExternalUris || value.archiveEntries.length > ASSET_LIBRARY_BUDGET.maxArchiveEntries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "IO request exceeds the resource budget");
|
||||
@@ -108,16 +128,32 @@ export function parseIORequest(value: unknown): IORequestIR {
|
||||
if (value.sourcePath !== undefined) request.sourcePath = projectPath(value.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED");
|
||||
if (value.sourceSha256 !== undefined) request.sourceSha256 = digest(value.sourceSha256, "sourceSha256");
|
||||
if (value.byteLength !== undefined) request.byteLength = integer(value.byteLength, "byteLength", 0, ASSET_LIBRARY_BUDGET.maxArchiveBytes);
|
||||
let totalUncompressed = 0;
|
||||
let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set<string>();
|
||||
request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => {
|
||||
if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`);
|
||||
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = integer(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = integer(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
|
||||
totalUncompressed += uncompressedBytes; if (!Number.isSafeInteger(totalUncompressed) || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget");
|
||||
if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`);
|
||||
archivePaths.add(path);
|
||||
totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes;
|
||||
if (!Number.isSafeInteger(totalCompressed) || !Number.isSafeInteger(totalUncompressed) || totalCompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget");
|
||||
return { path, compressedBytes, uncompressedBytes };
|
||||
});
|
||||
if (request.byteLength !== undefined && totalCompressed > request.byteLength) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive compressed entries exceed the declared source byte length");
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Builds a deterministic bounded range plan; it does not decode or trust an archive container. */
|
||||
export function planIOArchiveRanges(value: unknown): IOArchiveRangeIR[] {
|
||||
const request = parseIORequest(value);
|
||||
let compressedOffset = 0;
|
||||
return [...request.archiveEntries].sort((left, right) => left.path.localeCompare(right.path)).map((entry) => {
|
||||
const range = { ...entry, compressedOffset };
|
||||
compressedOffset += entry.compressedBytes;
|
||||
if (!Number.isSafeInteger(compressedOffset) || compressedOffset > ASSET_LIBRARY_BUDGET.maxArchiveBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive range offset exceeds the byte budget");
|
||||
return range;
|
||||
});
|
||||
}
|
||||
|
||||
export function gateIORequest(value: unknown): CapabilityGateResult {
|
||||
const request = parseIORequest(value); const capability = `${request.format}_${request.operation}`;
|
||||
if ((request.format === "GLB" && (request.operation === "ANALYZE" || request.operation === "EXPORT")) || (request.format === "USD" && request.operation === "ANALYZE")) return readyGate("N-023", capability);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const COMPOSITOR_BUDGET = {
|
||||
maxImageBytes: 256 * 1024 * 1024,
|
||||
maxBlurRadius: 32,
|
||||
maxOperations: 100_000_000,
|
||||
maxFrameCacheBytes: 256 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export const COMPOSITOR_NODE_TYPES = [
|
||||
@@ -77,6 +78,69 @@ export interface CompositorExecutionResult {
|
||||
evaluatedNodeIds: string[];
|
||||
}
|
||||
|
||||
export interface CompositorCachedExecutionResult extends CompositorExecutionResult {
|
||||
cacheKey: string;
|
||||
cacheHit: boolean;
|
||||
}
|
||||
|
||||
interface CompositorFrameCacheEntry {
|
||||
result: CompositorExecutionResult;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
function cloneImage(image: CompositorImageBuffer): CompositorImageBuffer {
|
||||
return { ...image, data: image.data.slice() };
|
||||
}
|
||||
|
||||
function cloneExecution(result: CompositorExecutionResult): CompositorExecutionResult {
|
||||
return { composite: cloneImage(result.composite), viewers: new Map([...result.viewers].map(([id, image]) => [id, cloneImage(image)])), evaluatedNodeIds: [...result.evaluatedNodeIds] };
|
||||
}
|
||||
|
||||
function executionBytes(result: CompositorExecutionResult): number {
|
||||
const unique = new Set<ArrayBuffer>();
|
||||
unique.add(result.composite.data.buffer as ArrayBuffer);
|
||||
for (const image of result.viewers.values()) unique.add(image.data.buffer as ArrayBuffer);
|
||||
return [...unique].reduce((total, buffer) => total + buffer.byteLength, 0);
|
||||
}
|
||||
|
||||
export class CompositorFrameCache {
|
||||
readonly maxBytes: number;
|
||||
private readonly entries = new Map<string, CompositorFrameCacheEntry>();
|
||||
private currentBytes = 0;
|
||||
|
||||
constructor(maxBytes = COMPOSITOR_BUDGET.maxFrameCacheBytes) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > COMPOSITOR_BUDGET.maxFrameCacheBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame cache byte budget is invalid");
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
get byteLength(): number { return this.currentBytes; }
|
||||
get size(): number { return this.entries.size; }
|
||||
|
||||
get(key: string): CompositorExecutionResult | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, entry);
|
||||
return cloneExecution(entry.result);
|
||||
}
|
||||
|
||||
set(key: string, result: CompositorExecutionResult): void {
|
||||
const clone = cloneExecution(result);
|
||||
const byteLength = executionBytes(clone);
|
||||
if (byteLength > this.maxBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame exceeds the cache byte budget");
|
||||
const previous = this.entries.get(key);
|
||||
if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); }
|
||||
while (this.currentBytes + byteLength > this.maxBytes) {
|
||||
const oldest = this.entries.entries().next().value as [string, CompositorFrameCacheEntry] | undefined;
|
||||
if (!oldest) break;
|
||||
this.entries.delete(oldest[0]);
|
||||
this.currentBytes -= oldest[1].byteLength;
|
||||
}
|
||||
this.entries.set(key, { result: clone, byteLength });
|
||||
this.currentBytes += byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
export class CompositorValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
@@ -267,6 +331,11 @@ export function executeCompositorGraph(
|
||||
const outputs = new Map<string, CompositorImageBuffer>();
|
||||
const viewers = new Map<string, CompositorImageBuffer>();
|
||||
const evaluatedNodeIds: string[] = [];
|
||||
let operationCounter = 0;
|
||||
const checkCancelled = (operations = 1): void => {
|
||||
operationCounter += operations;
|
||||
if ((operationCounter === operations || operationCounter % 16_384 < operations) && options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||
};
|
||||
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
|
||||
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
|
||||
const image = source ? outputs.get(source) : undefined;
|
||||
@@ -279,7 +348,7 @@ export function executeCompositorGraph(
|
||||
const evaluate = (id: string): CompositorImageBuffer => {
|
||||
const existing = outputs.get(id);
|
||||
if (existing) return existing;
|
||||
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||
checkCancelled();
|
||||
const node = byId.get(id);
|
||||
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`);
|
||||
for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId);
|
||||
@@ -297,7 +366,7 @@ export function executeCompositorGraph(
|
||||
const height = options.height ?? 1;
|
||||
output = allocate(width, height);
|
||||
const color = node.properties.color as number[];
|
||||
for (let offset = 0; offset < output.data.length; offset += 4) output.data.set(color, offset);
|
||||
for (let offset = 0; offset < output.data.length; offset += 4) { checkCancelled(); output.data.set(color, offset); }
|
||||
}
|
||||
else if (node.type === "TRANSFORM") {
|
||||
const input = requireInput(id, "Image");
|
||||
@@ -305,6 +374,7 @@ export function executeCompositorGraph(
|
||||
const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0);
|
||||
const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1);
|
||||
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
|
||||
checkCancelled();
|
||||
const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy);
|
||||
if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue;
|
||||
output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4);
|
||||
@@ -315,6 +385,7 @@ export function executeCompositorGraph(
|
||||
output = allocate(input.width, input.height);
|
||||
const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1;
|
||||
for (let offset = 0; offset < input.data.length; offset += 4) {
|
||||
checkCancelled();
|
||||
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier;
|
||||
output.data[offset + 3] = input.data[offset + 3];
|
||||
}
|
||||
@@ -325,6 +396,7 @@ export function executeCompositorGraph(
|
||||
sameSize(left, right, id);
|
||||
output = allocate(left.width, left.height);
|
||||
for (let offset = 0; offset < left.data.length; offset += 4) {
|
||||
checkCancelled();
|
||||
if (node.type === "MIX") {
|
||||
const factor = Number(node.properties.factor ?? 0.5);
|
||||
for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor;
|
||||
@@ -344,6 +416,7 @@ export function executeCompositorGraph(
|
||||
if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`);
|
||||
output = allocate(input.width, input.height);
|
||||
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
|
||||
checkCancelled((radius * 2 + 1) ** 2);
|
||||
const target = (y * input.width + x) * 4;
|
||||
let samples = 0;
|
||||
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
|
||||
@@ -366,3 +439,52 @@ export function executeCompositorGraph(
|
||||
};
|
||||
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
|
||||
}
|
||||
|
||||
async function sha256Bytes(data: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export async function compositorFrameCacheKey(
|
||||
value: unknown,
|
||||
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
|
||||
frame: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): Promise<string> {
|
||||
const graph = parseCompositorGraph(value);
|
||||
if (!Number.isSafeInteger(frame) || frame < -1_000_000 || frame > 1_000_000) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "Compositor cache frame is invalid");
|
||||
const sources: Array<{ id: string; width: number; height: number; sha256: string }> = [];
|
||||
for (const resource of [...graph.resources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))) {
|
||||
const image = sourceImages.get(resource.sourceId);
|
||||
if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource.sourceId}`);
|
||||
validateImage(image, resource.sourceId);
|
||||
const sha256 = await sha256Bytes(image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer);
|
||||
if (resource.sha256 && resource.sha256 !== sha256) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Compositor resource ${resource.sourceId} failed SHA-256 verification`);
|
||||
sources.push({ id: resource.sourceId, width: image.width, height: image.height, sha256 });
|
||||
}
|
||||
const descriptor = new TextEncoder().encode(stableJSON({ graph, sources, frame, width: width ?? null, height: height ?? null }));
|
||||
return sha256Bytes(descriptor.buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
export async function executeCompositorGraphCached(
|
||||
value: unknown,
|
||||
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
|
||||
cache: CompositorFrameCache,
|
||||
options: { frame: number; width?: number; height?: number; cancelled?: () => boolean },
|
||||
): Promise<CompositorCachedExecutionResult> {
|
||||
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||
const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height);
|
||||
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return { ...cached, cacheKey, cacheHit: true };
|
||||
const result = executeCompositorGraph(value, sourceImages, options);
|
||||
cache.set(cacheKey, result);
|
||||
return { ...result, cacheKey, cacheHit: false };
|
||||
}
|
||||
|
||||
@@ -12,8 +12,18 @@ export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: b
|
||||
export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean }
|
||||
export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number }
|
||||
export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number }
|
||||
export interface KeymapBindingIR { id: string; key: string; modifiers: string[]; command: string; enabled: boolean }
|
||||
export interface KeymapBindingIR {
|
||||
id: string;
|
||||
key: string;
|
||||
modifiers: string[];
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
workspaceIds?: string[];
|
||||
editors?: EditorTypeIR[];
|
||||
modes?: EditorMode[];
|
||||
}
|
||||
export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] }
|
||||
export interface KeyChordIR { key: string; modifiers: Array<"ALT" | "CTRL" | "META" | "SHIFT"> }
|
||||
export type EditorWorkflowEditIR =
|
||||
| { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string }
|
||||
| { type: "SET_ACTIVE_AREA"; revision: number; areaId: string }
|
||||
@@ -38,6 +48,8 @@ function rect(value: unknown, name: string): EditorAreaIR["rect"] {
|
||||
return next;
|
||||
}
|
||||
function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }
|
||||
function scopeOverlaps<T>(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean { return !left?.length || !right?.length || left.some((value) => right.includes(value)); }
|
||||
function keymapScopesOverlap(left: KeymapBindingIR, right: KeymapBindingIR): boolean { return scopeOverlaps(left.workspaceIds, right.workspaceIds) && scopeOverlaps(left.editors, right.editors) && scopeOverlaps(left.modes, right.modes); }
|
||||
|
||||
export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
|
||||
if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema");
|
||||
@@ -64,10 +76,47 @@ export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
|
||||
if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid");
|
||||
if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget");
|
||||
if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid");
|
||||
const keymapIds = new Set<string>(); const keymaps = value.keymaps.map((bindingValue, index): KeymapBindingIR => { const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16)); if (new Set(modifiers).size !== modifiers.length) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers duplicate`); return { id, key: text(bindingValue.key, `${name}.key`, 32), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false }; });
|
||||
const keymapIds = new Set<string>(); const keymaps: KeymapBindingIR[] = [];
|
||||
value.keymaps.forEach((bindingValue, index) => {
|
||||
const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`);
|
||||
const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id);
|
||||
const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16).toUpperCase()); if (new Set(modifiers).size !== modifiers.length || modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers are invalid or duplicate`); modifiers.sort();
|
||||
const parseScope = <T extends string>(field: "workspaceIds" | "editors" | "modes", allowed?: readonly T[]): T[] | undefined => {
|
||||
const source = bindingValue[field]; if (source === undefined) return undefined;
|
||||
if (!Array.isArray(source) || source.length === 0 || source.length > 64) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid`);
|
||||
const parsed = source.map((item, scopeIndex) => text(item, `${name}.${field}[${scopeIndex}]`, 256) as T);
|
||||
if (new Set(parsed).size !== parsed.length || (allowed && parsed.some((item) => !allowed.includes(item)))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid or duplicated`);
|
||||
return parsed;
|
||||
};
|
||||
const binding: KeymapBindingIR = { id, key: text(bindingValue.key, `${name}.key`, 32).toUpperCase(), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false, workspaceIds: parseScope("workspaceIds"), editors: parseScope("editors", EDITOR_TYPES), modes: parseScope("modes", ["OBJECT", "EDIT", "POSE"] as const) };
|
||||
if (binding.workspaceIds?.some((workspace) => !workspaceIds.has(workspace))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.workspaceIds references a missing workspace`);
|
||||
if (binding.enabled && keymaps.some((candidate) => candidate.enabled && candidate.key === binding.key && candidate.modifiers.join("+") === binding.modifiers.join("+") && keymapScopesOverlap(candidate, binding))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} conflicts with another enabled keymap in the same context`);
|
||||
keymaps.push(binding);
|
||||
});
|
||||
return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps };
|
||||
}
|
||||
|
||||
export function keyChordFromKeyboardEvent(event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">): KeyChordIR {
|
||||
const key = text(event.key, "event.key", 32).toUpperCase();
|
||||
const modifiers: KeyChordIR["modifiers"] = [];
|
||||
if (event.altKey) modifiers.push("ALT");
|
||||
if (event.ctrlKey) modifiers.push("CTRL");
|
||||
if (event.metaKey) modifiers.push("META");
|
||||
if (event.shiftKey) modifiers.push("SHIFT");
|
||||
return { key, modifiers };
|
||||
}
|
||||
|
||||
export function resolveKeymapCommand(value: unknown, chordValue: unknown): string | null {
|
||||
const workflow = parseEditorWorkflow(value);
|
||||
if (!record(chordValue) || !Array.isArray(chordValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord is invalid");
|
||||
const chord = { key: text(chordValue.key, "keyChord.key", 32).toUpperCase(), modifiers: chordValue.modifiers.map((modifier, index) => text(modifier, `keyChord.modifiers[${index}]`, 16).toUpperCase()).sort() };
|
||||
if (new Set(chord.modifiers).size !== chord.modifiers.length || chord.modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord modifiers are invalid");
|
||||
return workflow.keymaps.find((binding) => binding.enabled && binding.key === chord.key && binding.modifiers.join("+") === chord.modifiers.join("+") &&
|
||||
(!binding.workspaceIds || binding.workspaceIds.includes(workflow.context.workspaceId)) &&
|
||||
(!binding.editors || binding.editors.includes(workflow.context.activeEditor)) &&
|
||||
(!binding.modes || binding.modes.includes(workflow.context.mode)))?.command ?? null;
|
||||
}
|
||||
|
||||
export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR {
|
||||
const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow);
|
||||
if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; }
|
||||
|
||||
@@ -58,6 +58,14 @@ export type ErrorCode =
|
||||
| "NON_MESH_RESOURCE_OUTSIDE_PROJECT"
|
||||
| "NON_MESH_VDB_BUDGET_EXCEEDED"
|
||||
| "NON_MESH_BINARY_INVALID"
|
||||
| "VDB_CONVERSION_REQUIRED"
|
||||
| "VDB_CONVERTER_UNAVAILABLE"
|
||||
| "VDB_CONVERSION_INVALID"
|
||||
| "NANOVDB_MANIFEST_INVALID"
|
||||
| "NANOVDB_HASH_MISMATCH"
|
||||
| "NANOVDB_STREAM_INCOMPLETE"
|
||||
| "NANOVDB_GRID_UNSUPPORTED"
|
||||
| "NANOVDB_GPU_BUDGET_EXCEEDED"
|
||||
| "NON_MESH_DATA_SHARED"
|
||||
| "NON_MESH_PROPERTY_INVALID"
|
||||
| "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED"
|
||||
|
||||
@@ -21,14 +21,26 @@ export interface CurveGizmoDragIR {
|
||||
baseRevision: number;
|
||||
phase: CurveGizmoPhase;
|
||||
axis: 0 | 1 | 2;
|
||||
axisVector?: [number, number, number];
|
||||
delta: [number, number, number];
|
||||
handles: CurveGizmoHandleIR[];
|
||||
}
|
||||
|
||||
export interface CurveGizmoFrameIR {
|
||||
origin: [number, number, number];
|
||||
axes: [[number, number, number], [number, number, number], [number, number, number]];
|
||||
}
|
||||
|
||||
export interface CurveGizmoScreenFrameIR {
|
||||
origin: [number, number];
|
||||
axes: [[number, number], [number, number], [number, number]];
|
||||
}
|
||||
|
||||
export interface AppliedCurveGizmoDragIR {
|
||||
dataId: string;
|
||||
phase: CurveGizmoPhase;
|
||||
axis: 0 | 1 | 2;
|
||||
axisVector?: [number, number, number];
|
||||
revision: number;
|
||||
handles: CurveGizmoHandleIR[];
|
||||
}
|
||||
@@ -57,6 +69,64 @@ function vector(value: unknown, path: string): [number, number, number] {
|
||||
return [finite(value[0], `${path}[0]`), finite(value[1], `${path}[1]`), finite(value[2], `${path}[2]` )];
|
||||
}
|
||||
|
||||
function length(value: readonly number[]): number {
|
||||
return Math.hypot(value[0], value[1], value[2]);
|
||||
}
|
||||
|
||||
function normalize(value: readonly number[], path: string): [number, number, number] {
|
||||
const magnitude = length(value);
|
||||
if (!Number.isFinite(magnitude) || magnitude < 1e-8) fail(path, "must have a finite non-zero direction");
|
||||
return [value[0] / magnitude, value[1] / magnitude, value[2] / magnitude];
|
||||
}
|
||||
|
||||
function dot(left: readonly number[], right: readonly number[]): number {
|
||||
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
||||
}
|
||||
|
||||
function cross(left: readonly number[], right: readonly number[]): [number, number, number] {
|
||||
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
|
||||
}
|
||||
|
||||
export function deriveCurveHandleGizmoFrame(controlPoints: ArrayLike<number>, handles: readonly CurveGizmoHandleIR[]): CurveGizmoFrameIR {
|
||||
if (controlPoints.length === 0 || controlPoints.length % 3 !== 0) fail("controlPoints", "must contain finite XYZ coordinates");
|
||||
for (let index = 0; index < controlPoints.length; index += 1) {
|
||||
if (!Number.isFinite(controlPoints[index])) fail("controlPoints", "must contain finite XYZ coordinates");
|
||||
}
|
||||
if (handles.length === 0 || handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
|
||||
const ordered = [...handles].sort((left, right) => left.pointIndex - right.pointIndex || left.side.localeCompare(right.side));
|
||||
const origin: [number, number, number] = [0, 0, 0];
|
||||
const directions: Array<[number, number, number]> = [];
|
||||
for (const [index, handle] of ordered.entries()) {
|
||||
if (!Number.isSafeInteger(handle.pointIndex) || handle.pointIndex < 0 || handle.pointIndex * 3 + 2 >= controlPoints.length || handle.side === "CONTROL") fail(`handles[${index}]`, "must identify a Curve handle with an existing control point");
|
||||
vector(handle.position, `handles[${index}].position`);
|
||||
origin[0] += handle.position[0];
|
||||
origin[1] += handle.position[1];
|
||||
origin[2] += handle.position[2];
|
||||
const point = handle.pointIndex * 3;
|
||||
directions.push(normalize([
|
||||
handle.position[0] - controlPoints[point],
|
||||
handle.position[1] - controlPoints[point + 1],
|
||||
handle.position[2] - controlPoints[point + 2],
|
||||
], `handles[${index}].direction`));
|
||||
}
|
||||
origin[0] /= ordered.length;
|
||||
origin[1] /= ordered.length;
|
||||
origin[2] /= ordered.length;
|
||||
const reference = directions[0];
|
||||
const aligned = directions.map((direction) => dot(direction, reference) < 0 ? direction.map((value) => -value) as [number, number, number] : direction);
|
||||
const axisX = normalize(aligned.reduce<[number, number, number]>((sum, direction) => [sum[0] + direction[0], sum[1] + direction[1], sum[2] + direction[2]], [0, 0, 0]), "handles.directionAverage");
|
||||
const up: [number, number, number] = Math.abs(axisX[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0];
|
||||
const axisY = normalize(cross(up, axisX), "gizmo.axisY");
|
||||
const axisZ = normalize(cross(axisX, axisY), "gizmo.axisZ");
|
||||
return { origin, axes: [axisX, axisY, axisZ] };
|
||||
}
|
||||
|
||||
export function curveGizmoAxisDelta(frame: CurveGizmoFrameIR, axis: 0 | 1 | 2, amount: number): [number, number, number] {
|
||||
if (!Number.isFinite(amount) || Math.abs(amount) > CURVE_GIZMO_BUDGET.maxCoordinate) fail("amount", "is outside the finite coordinate budget");
|
||||
const direction = normalize(frame.axes[axis], `frame.axes[${axis}]`);
|
||||
return [direction[0] * amount, direction[1] * amount, direction[2] * amount];
|
||||
}
|
||||
|
||||
export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): CurveGizmoDragIR {
|
||||
const drag = record(value, "drag");
|
||||
if (drag.schemaVersion !== CURVE_GIZMO_SCHEMA) fail("schemaVersion", "is unsupported");
|
||||
@@ -66,7 +136,12 @@ export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number):
|
||||
if (drag.phase !== "PREVIEW" && drag.phase !== "COMMIT") fail("phase", "is invalid");
|
||||
if (drag.axis !== 0 && drag.axis !== 1 && drag.axis !== 2) fail("axis", "must be X, Y or Z");
|
||||
const delta = vector(drag.delta, "delta");
|
||||
if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
|
||||
const axisVector = drag.axisVector === undefined ? undefined : normalize(vector(drag.axisVector, "axisVector"), "axisVector");
|
||||
if (axisVector) {
|
||||
const deltaLength = length(delta);
|
||||
if (deltaLength > 0 && length(cross(delta, axisVector)) > Math.max(1e-7, deltaLength * 1e-6)) fail("delta", "must be parallel to the selected local axis");
|
||||
}
|
||||
else if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
|
||||
if (!Array.isArray(drag.handles) || drag.handles.length === 0 || drag.handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
|
||||
const seen = new Set<string>();
|
||||
const handles = drag.handles.map((item, index) => {
|
||||
@@ -79,7 +154,7 @@ export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number):
|
||||
seen.add(key);
|
||||
return { pointIndex, side, position: vector(handle.position, `handles[${index}].position`) };
|
||||
});
|
||||
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, delta, handles };
|
||||
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, axisVector, delta, handles };
|
||||
}
|
||||
|
||||
export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): AppliedCurveGizmoDragIR {
|
||||
@@ -89,5 +164,5 @@ export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number):
|
||||
position: [handle.position[0] + drag.delta[0], handle.position[1] + drag.delta[1], handle.position[2] + drag.delta[2]] as [number, number, number],
|
||||
}));
|
||||
handles.forEach((handle, index) => vector(handle.position, `handles[${index}].position`));
|
||||
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
|
||||
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, axisVector: drag.axisVector, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const PAINT_BUDGET = {
|
||||
maxWeightEntries: 1_000_000,
|
||||
maxTextureTileBytes: 256 * 1024 * 1024,
|
||||
maxStrokeBytes: 64 * 1024 * 1024,
|
||||
maxSpatialCells: 1_000_000,
|
||||
} as const;
|
||||
|
||||
export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
|
||||
@@ -51,6 +52,112 @@ export interface PaintBrushVertexIR {
|
||||
|
||||
export interface PaintBrushWeightIR { index: number; weight: number }
|
||||
|
||||
export interface PaintBrushQueryOptionsIR {
|
||||
ignoreOccluded?: boolean;
|
||||
frontFaceOnly?: boolean;
|
||||
viewDirection?: [number, number, number];
|
||||
visibleVertexIndices?: readonly number[];
|
||||
requireVisibility?: boolean;
|
||||
selectedVertexIndices?: readonly number[];
|
||||
requireSelection?: boolean;
|
||||
maskWeights?: readonly PaintBrushWeightIR[];
|
||||
}
|
||||
|
||||
function validateBrushGateIdentities(vertices: readonly PaintBrushVertexIR[], options: PaintBrushQueryOptionsIR): void {
|
||||
const known = new Set(vertices.map((vertex) => vertex.index));
|
||||
for (const [path, values] of [["visibleVertexIndices", options.visibleVertexIndices], ["selectedVertexIndices", options.selectedVertexIndices]] as const) {
|
||||
values?.forEach((value, index) => {
|
||||
const vertexIndex = integer(value, `${path}[${index}]`);
|
||||
if (!known.has(vertexIndex)) fail(`${path}[${index}]`, "references an unknown vertex identity");
|
||||
});
|
||||
}
|
||||
options.maskWeights?.forEach((value, index) => {
|
||||
const entry = record(value, `maskWeights[${index}]`);
|
||||
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
|
||||
if (!known.has(vertexIndex)) fail(`maskWeights[${index}].index`, "references an unknown vertex identity");
|
||||
});
|
||||
}
|
||||
|
||||
export interface PaintBrushSpatialIndex {
|
||||
readonly schemaVersion: 1;
|
||||
readonly cellSize: number;
|
||||
readonly vertices: readonly PaintBrushVertexIR[];
|
||||
readonly cells: ReadonlyMap<string, readonly number[]>;
|
||||
}
|
||||
|
||||
export interface PaintBrushSpatialQueryIR {
|
||||
weights: PaintBrushWeightIR[];
|
||||
candidateCount: number;
|
||||
visitedCellCount: number;
|
||||
}
|
||||
|
||||
export interface PaintColorPatchIR { indices: number[]; colors: number[] }
|
||||
|
||||
function parseBrushWeights(value: unknown, path = "brushWeights"): PaintBrushWeightIR[] {
|
||||
if (!Array.isArray(value) || value.length > PAINT_BUDGET.maxWeightEntries) fail(path, "exceeds the brush patch budget", true);
|
||||
if (value.length === 0) fail(path, "must contain at least one brush hit");
|
||||
const seen = new Set<number>();
|
||||
return value.map((item, index) => {
|
||||
const entry = record(item, `${path}[${index}]`);
|
||||
const vertexIndex = integer(entry.index, `${path}[${index}].index`);
|
||||
const weight = finite(entry.weight, `${path}[${index}].weight`);
|
||||
if (weight < 0 || weight > 1) fail(`${path}[${index}].weight`, "must be in [0,1]");
|
||||
if (seen.has(vertexIndex)) fail(`${path}[${index}].index`, "contains a duplicate vertex");
|
||||
seen.add(vertexIndex);
|
||||
return { index: vertexIndex, weight };
|
||||
}).sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
export function composePaintWeightPatch(
|
||||
objectId: string,
|
||||
vertexGroup: string,
|
||||
revision: number,
|
||||
currentRevision: number,
|
||||
currentWeightsValue: unknown,
|
||||
brushWeightsValue: unknown,
|
||||
targetValue: unknown,
|
||||
): WeightPatchIR {
|
||||
const parsedRevision = integer(revision, "revision");
|
||||
if (parsedRevision !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint weight stroke is stale");
|
||||
if (!Array.isArray(currentWeightsValue)) fail("currentWeights", "must be an array");
|
||||
const currentWeights = currentWeightsValue.map((value, index) => {
|
||||
const weight = finite(value, `currentWeights[${index}]`);
|
||||
if (weight < 0 || weight > 1) fail(`currentWeights[${index}]`, "must be in [0,1]");
|
||||
return weight;
|
||||
});
|
||||
const target = finite(targetValue, "targetWeight");
|
||||
if (target < 0 || target > 1) fail("targetWeight", "must be in [0,1]");
|
||||
const weights = parseBrushWeights(brushWeightsValue);
|
||||
if (weights.some((entry) => entry.index >= currentWeights.length)) fail("brushWeights", "references an unknown current weight");
|
||||
return parseWeightPatch({ schemaVersion: 1, objectId, revision: parsedRevision, vertexGroup, indices: weights.map((entry) => entry.index), values: weights.map((entry) => currentWeights[entry.index] + (target - currentWeights[entry.index]) * entry.weight), normalize: false });
|
||||
}
|
||||
|
||||
export function composePaintColorPatch(
|
||||
revision: number,
|
||||
currentRevision: number,
|
||||
currentColorsValue: unknown,
|
||||
brushWeightsValue: unknown,
|
||||
targetColorValue: unknown,
|
||||
): PaintColorPatchIR {
|
||||
if (integer(revision, "revision") !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint color stroke is stale");
|
||||
if (!Array.isArray(currentColorsValue) || currentColorsValue.length % 4 !== 0) fail("currentColors", "must contain RGBA values");
|
||||
const currentColors = currentColorsValue.map((value, index) => {
|
||||
const component = finite(value, `currentColors[${index}]`);
|
||||
if (component < 0 || component > 1) fail(`currentColors[${index}]`, "must be in [0,1]");
|
||||
return component;
|
||||
});
|
||||
const target = tuple(targetColorValue, 4, "targetColor");
|
||||
if (target.some((component) => component < 0 || component > 1)) fail("targetColor", "must be in [0,1]");
|
||||
const weights = parseBrushWeights(brushWeightsValue);
|
||||
if (weights.some((entry) => entry.index >= currentColors.length / 4)) fail("brushWeights", "references an unknown current color");
|
||||
return {
|
||||
indices: weights.map((entry) => entry.index),
|
||||
colors: weights.flatMap((entry) => Array.from({ length: 4 }, (_, component) => currentColors[entry.index * 4 + component] + (target[component] - currentColors[entry.index * 4 + component]) * entry.weight)),
|
||||
};
|
||||
}
|
||||
|
||||
const paintBrushSpatialIndexes = new WeakMap<object, { cellSize: number; vertices: PaintBrushVertexIR[]; cells: Map<string, number[]> }>();
|
||||
|
||||
export interface UdimTilePatchIR {
|
||||
schemaVersion: 1;
|
||||
textureAssetId: string;
|
||||
@@ -178,14 +285,33 @@ export function parseWeightPatch(value: unknown): WeightPatchIR {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function computePaintBrushWeights(
|
||||
verticesValue: unknown,
|
||||
function parsePaintBrushVertices(verticesValue: unknown): PaintBrushVertexIR[] {
|
||||
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
|
||||
const seen = new Set<number>();
|
||||
return verticesValue.map((item, vertexIndex) => {
|
||||
const vertex = record(item, `vertices[${vertexIndex}]`);
|
||||
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
|
||||
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
|
||||
seen.add(index);
|
||||
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
|
||||
if (position.some((component) => Math.abs(component) > 1_000_000_000)) fail(`vertices[${vertexIndex}].position`, "is outside the spatial index range");
|
||||
const result: PaintBrushVertexIR = { index, position };
|
||||
if (vertex.occluded !== undefined) {
|
||||
if (typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
|
||||
result.occluded = vertex.occluded;
|
||||
}
|
||||
if (vertex.normal !== undefined) result.normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function brushWeights(
|
||||
vertices: readonly PaintBrushVertexIR[],
|
||||
centerValue: unknown,
|
||||
radiusValue: unknown,
|
||||
strengthValue: unknown,
|
||||
options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {},
|
||||
options: PaintBrushQueryOptionsIR = {},
|
||||
): PaintBrushWeightIR[] {
|
||||
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
|
||||
const center = tuple(centerValue, 3, "center") as [number, number, number];
|
||||
const radius = finite(radiusValue, "radius");
|
||||
const strength = finite(strengthValue, "strength");
|
||||
@@ -193,30 +319,112 @@ export function computePaintBrushWeights(
|
||||
if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]");
|
||||
const viewDirection = options.viewDirection ?? [0, 0, -1];
|
||||
tuple(viewDirection, 3, "viewDirection");
|
||||
if (options.requireVisibility && options.visibleVertexIndices === undefined) fail("visibleVertexIndices", "is required for depth-gated brush queries");
|
||||
if (options.visibleVertexIndices !== undefined && (!Array.isArray(options.visibleVertexIndices) || options.visibleVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("visibleVertexIndices", "exceeds the visibility budget", true);
|
||||
const visible = options.visibleVertexIndices === undefined ? undefined : new Set(options.visibleVertexIndices.map((value, index) => integer(value, `visibleVertexIndices[${index}]`)));
|
||||
if (visible && visible.size !== options.visibleVertexIndices?.length) fail("visibleVertexIndices", "contains duplicates");
|
||||
if (options.requireSelection && options.selectedVertexIndices === undefined) fail("selectedVertexIndices", "is required for selection-gated brush queries");
|
||||
if (options.selectedVertexIndices !== undefined && (!Array.isArray(options.selectedVertexIndices) || options.selectedVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("selectedVertexIndices", "exceeds the selection budget", true);
|
||||
const selected = options.selectedVertexIndices === undefined ? undefined : new Set(options.selectedVertexIndices.map((value, index) => integer(value, `selectedVertexIndices[${index}]`)));
|
||||
if (selected && selected.size !== options.selectedVertexIndices?.length) fail("selectedVertexIndices", "contains duplicates");
|
||||
if (options.maskWeights !== undefined && (!Array.isArray(options.maskWeights) || options.maskWeights.length > PAINT_BUDGET.maxWeightEntries)) fail("maskWeights", "exceeds the mask budget", true);
|
||||
const mask = options.maskWeights === undefined ? undefined : new Map<number, number>();
|
||||
options.maskWeights?.forEach((value, index) => {
|
||||
const entry = record(value, `maskWeights[${index}]`);
|
||||
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
|
||||
const weight = finite(entry.weight, `maskWeights[${index}].weight`);
|
||||
if (weight < 0 || weight > 1) fail(`maskWeights[${index}].weight`, "must be in [0,1]");
|
||||
if (mask!.has(vertexIndex)) fail(`maskWeights[${index}].index`, "contains a duplicate vertex");
|
||||
mask!.set(vertexIndex, weight);
|
||||
});
|
||||
const result: PaintBrushWeightIR[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const [vertexIndex, item] of verticesValue.entries()) {
|
||||
const vertex = record(item, `vertices[${vertexIndex}]`);
|
||||
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
|
||||
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
|
||||
seen.add(index);
|
||||
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
|
||||
if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
|
||||
for (const vertex of vertices) {
|
||||
const { index, position } = vertex;
|
||||
if (visible && !visible.has(index)) continue;
|
||||
if (selected && !selected.has(index)) continue;
|
||||
const maskWeight = mask?.get(index) ?? (mask ? 0 : 1);
|
||||
if (maskWeight === 0) continue;
|
||||
if (options.ignoreOccluded !== false && vertex.occluded === true) continue;
|
||||
if (vertex.normal !== undefined) {
|
||||
const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
|
||||
const normal = tuple(vertex.normal, 3, `vertices[${index}].normal`) as [number, number, number];
|
||||
if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue;
|
||||
}
|
||||
const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]);
|
||||
if (distance > radius) continue;
|
||||
const normalized = distance / radius;
|
||||
const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized);
|
||||
const weight = Math.max(0, Math.min(1, strength * smoothstep));
|
||||
const weight = Math.max(0, Math.min(1, strength * smoothstep * maskWeight));
|
||||
if (weight > 0) result.push({ index, weight });
|
||||
}
|
||||
return result.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
export function computePaintBrushWeights(
|
||||
verticesValue: unknown,
|
||||
centerValue: unknown,
|
||||
radiusValue: unknown,
|
||||
strengthValue: unknown,
|
||||
options: PaintBrushQueryOptionsIR = {},
|
||||
): PaintBrushWeightIR[] {
|
||||
const vertices = parsePaintBrushVertices(verticesValue);
|
||||
validateBrushGateIdentities(vertices, options);
|
||||
return brushWeights(vertices, centerValue, radiusValue, strengthValue, options);
|
||||
}
|
||||
|
||||
function spatialCell(position: readonly number[], cellSize: number): [number, number, number] {
|
||||
return [Math.floor(position[0] / cellSize), Math.floor(position[1] / cellSize), Math.floor(position[2] / cellSize)];
|
||||
}
|
||||
|
||||
function spatialKey(x: number, y: number, z: number): string { return `${x}:${y}:${z}`; }
|
||||
|
||||
export function buildPaintBrushSpatialIndex(verticesValue: unknown, cellSizeValue: unknown): PaintBrushSpatialIndex {
|
||||
const vertices = parsePaintBrushVertices(verticesValue);
|
||||
const cellSize = finite(cellSizeValue, "cellSize");
|
||||
if (cellSize < 1e-6 || cellSize > 100_000) fail("cellSize", "is outside the bounded range");
|
||||
const cells = new Map<string, number[]>();
|
||||
vertices.forEach((vertex, offset) => {
|
||||
const cell = spatialCell(vertex.position, cellSize);
|
||||
if (cell.some((component) => !Number.isSafeInteger(component))) fail("vertices", "produces an unsafe spatial cell");
|
||||
const key = spatialKey(...cell);
|
||||
const offsets = cells.get(key) ?? [];
|
||||
offsets.push(offset);
|
||||
cells.set(key, offsets);
|
||||
});
|
||||
if (cells.size > PAINT_BUDGET.maxSpatialCells) fail("vertices", "exceeds the spatial cell budget", true);
|
||||
const publicVertices = vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], ...(vertex.normal ? { normal: [...vertex.normal] as [number, number, number] } : {}) }));
|
||||
const publicCells = new Map([...cells].map(([key, offsets]) => [key, [...offsets]]));
|
||||
const index: PaintBrushSpatialIndex = Object.freeze({ schemaVersion: 1, cellSize, vertices: publicVertices, cells: publicCells });
|
||||
paintBrushSpatialIndexes.set(index, { cellSize, vertices, cells });
|
||||
return index;
|
||||
}
|
||||
|
||||
export function queryPaintBrushSpatialIndex(
|
||||
index: PaintBrushSpatialIndex,
|
||||
centerValue: unknown,
|
||||
radiusValue: unknown,
|
||||
strengthValue: unknown,
|
||||
options: PaintBrushQueryOptionsIR = {},
|
||||
): PaintBrushSpatialQueryIR {
|
||||
const source = paintBrushSpatialIndexes.get(index);
|
||||
if (!source) fail("spatialIndex", "is invalid");
|
||||
validateBrushGateIdentities(source.vertices, options);
|
||||
const center = tuple(centerValue, 3, "center") as [number, number, number];
|
||||
const radius = finite(radiusValue, "radius");
|
||||
if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range");
|
||||
const minimum = spatialCell(center.map((component) => component - radius), source.cellSize);
|
||||
const maximum = spatialCell(center.map((component) => component + radius), source.cellSize);
|
||||
const spans = maximum.map((component, axis) => component - minimum[axis] + 1);
|
||||
if (spans.some((span) => !Number.isSafeInteger(span) || span <= 0) || spans[0] > PAINT_BUDGET.maxSpatialCells / spans[1] / spans[2]) fail("spatialQuery", "exceeds the visited cell budget", true);
|
||||
const offsets = new Set<number>();
|
||||
let visitedCellCount = 0;
|
||||
for (let x = minimum[0]; x <= maximum[0]; x++) for (let y = minimum[1]; y <= maximum[1]; y++) for (let z = minimum[2]; z <= maximum[2]; z++) {
|
||||
visitedCellCount++;
|
||||
for (const offset of source.cells.get(spatialKey(x, y, z)) ?? []) offsets.add(offset);
|
||||
}
|
||||
const candidates = [...offsets].sort((left, right) => left - right).map((offset) => source.vertices[offset]);
|
||||
return { weights: brushWeights(candidates, center, radius, strengthValue, options), candidateCount: candidates.length, visitedCellCount };
|
||||
}
|
||||
|
||||
export function parseUdimTilePatch(value: unknown): UdimTilePatchIR {
|
||||
const patch = record(value, "udimPatch");
|
||||
if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format");
|
||||
|
||||
182
web/protocol/physics-cache-playback.ts
Normal file
182
web/protocol/physics-cache-playback.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { decodeBrowserTransformCacheFrame, PhysicsSimulationValidationError, type BrowserTransformCacheObjectIR } from "./physics-simulation";
|
||||
import type { SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
|
||||
|
||||
export interface BrowserTransformCacheFrameSource {
|
||||
readonly frameStart: number;
|
||||
readonly frameEnd: number;
|
||||
readFrame(frame: number, signal: AbortSignal): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export interface BrowserTransformCachePlaybackResult {
|
||||
status: "COMPLETED" | "CANCELLED";
|
||||
appliedFrames: number;
|
||||
lastFrame: number | null;
|
||||
}
|
||||
|
||||
function quaternionFromEuler([x, y, z]: readonly number[]): [number, number, number, number] {
|
||||
const cx = Math.cos(x / 2); const sx = Math.sin(x / 2);
|
||||
const cy = Math.cos(y / 2); const sy = Math.sin(y / 2);
|
||||
const cz = Math.cos(z / 2); const sz = Math.sin(z / 2);
|
||||
return [sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz];
|
||||
}
|
||||
|
||||
function eulerFromQuaternion([x, y, z, w]: readonly number[]): [number, number, number] {
|
||||
return [
|
||||
Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
|
||||
Math.asin(Math.max(-1, Math.min(1, 2 * (w * y - z * x)))),
|
||||
Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)),
|
||||
];
|
||||
}
|
||||
|
||||
function composeMatrix(translation: readonly number[], quaternion: readonly number[], scale: readonly number[]): number[] {
|
||||
const [x, y, z, w] = quaternion;
|
||||
const x2 = x + x; const y2 = y + y; const z2 = z + z;
|
||||
const xx = x * x2; const xy = x * y2; const xz = x * z2;
|
||||
const yy = y * y2; const yz = y * z2; const zz = z * z2;
|
||||
const wx = w * x2; const wy = w * y2; const wz = w * z2;
|
||||
return [
|
||||
(1 - (yy + zz)) * scale[0], (xy + wz) * scale[0], (xz - wy) * scale[0], 0,
|
||||
(xy - wz) * scale[1], (1 - (xx + zz)) * scale[1], (yz + wx) * scale[1], 0,
|
||||
(xz + wy) * scale[2], (yz - wx) * scale[2], (1 - (xx + yy)) * scale[2], 0,
|
||||
translation[0], translation[1], translation[2], 1,
|
||||
];
|
||||
}
|
||||
|
||||
function multiplyMatrix(left: readonly number[], right: readonly number[]): number[] {
|
||||
const output = new Array<number>(16);
|
||||
for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) {
|
||||
output[column * 4 + row] = left[row] * right[column * 4] + left[4 + row] * right[column * 4 + 1] + left[8 + row] * right[column * 4 + 2] + left[12 + row] * right[column * 4 + 3];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function cacheTransform(node: SceneNodeIR, cached: BrowserTransformCacheObjectIR | undefined): { node: SceneNodeIR; quaternion: [number, number, number, number] } {
|
||||
if (!cached) {
|
||||
return { node: { ...node, transform: { ...node.transform }, localMatrix: [...node.localMatrix], worldMatrix: [...node.worldMatrix] }, quaternion: quaternionFromEuler(node.transform.rotationEuler) };
|
||||
}
|
||||
const transform = {
|
||||
...node.transform,
|
||||
translation: [...cached.translation] as [number, number, number],
|
||||
rotationEuler: eulerFromQuaternion(cached.rotationQuaternion),
|
||||
scale: [...cached.scale] as [number, number, number],
|
||||
};
|
||||
return { node: { ...node, transform, localMatrix: composeMatrix(transform.translation, cached.rotationQuaternion, transform.scale), worldMatrix: [] }, quaternion: cached.rotationQuaternion };
|
||||
}
|
||||
|
||||
/** Applies the browser-owned BTF1 transform cache as an immutable SceneIR preview. */
|
||||
export function applyBrowserTransformCachePreview(snapshot: SceneSnapshotIR, value: ArrayBuffer, expectedFrame: number): SceneSnapshotIR {
|
||||
if (!Number.isSafeInteger(expectedFrame) || expectedFrame < snapshot.frame.start || expectedFrame > snapshot.frame.end) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${expectedFrame} is outside the scene range`);
|
||||
}
|
||||
const frame = decodeBrowserTransformCacheFrame(value);
|
||||
if (frame.frame !== expectedFrame) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${frame.frame} does not match requested frame ${expectedFrame}`);
|
||||
const sourceById = new Map(snapshot.nodes.map((node) => [node.id, node]));
|
||||
for (const item of frame.objects) if (!sourceById.has(item.objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache references missing ${item.objectId}`);
|
||||
const cachedById = new Map(frame.objects.map((item) => [item.objectId, item]));
|
||||
const states = new Map(snapshot.nodes.map((node) => [node.id, cacheTransform(node, cachedById.get(node.id))]));
|
||||
const resolving = new Set<string>();
|
||||
const resolved = new Set<string>();
|
||||
const updateWorld = (id: string): number[] => {
|
||||
const state = states.get(id);
|
||||
if (!state) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy references missing ${id}`);
|
||||
if (resolved.has(id)) return state.node.worldMatrix;
|
||||
if (resolving.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy contains a cycle at ${id}`);
|
||||
resolving.add(id);
|
||||
if (state.node.localMatrix.length !== 16) state.node.localMatrix = composeMatrix(state.node.transform.translation, state.quaternion, state.node.transform.scale);
|
||||
state.node.worldMatrix = state.node.parentId ? multiplyMatrix(updateWorld(state.node.parentId), state.node.localMatrix) : [...state.node.localMatrix];
|
||||
resolving.delete(id);
|
||||
resolved.add(id);
|
||||
return state.node.worldMatrix;
|
||||
};
|
||||
for (const node of snapshot.nodes) updateWorld(node.id);
|
||||
return { ...snapshot, frame: { ...snapshot.frame, current: frame.frame }, nodes: snapshot.nodes.map((node) => states.get(node.id)!.node) };
|
||||
}
|
||||
|
||||
/** Coordinates exact-frame BTF1 reads while preventing cancelled or superseded reads from publishing. */
|
||||
export class BrowserTransformCachePlaybackSession {
|
||||
private generation = 0;
|
||||
private controller: AbortController | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly baseSnapshot: SceneSnapshotIR,
|
||||
private readonly source: BrowserTransformCacheFrameSource,
|
||||
private readonly publish: (preview: SceneSnapshotIR) => void,
|
||||
) {
|
||||
if (!Number.isSafeInteger(source.frameStart) || !Number.isSafeInteger(source.frameEnd) ||
|
||||
source.frameEnd < source.frameStart || source.frameStart < baseSnapshot.frame.start || source.frameEnd > baseSnapshot.frame.end) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache source range is outside the scene range");
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.generation += 1;
|
||||
this.controller?.abort();
|
||||
this.controller = null;
|
||||
}
|
||||
|
||||
async seek(frame: number): Promise<SceneSnapshotIR | null> {
|
||||
this.validateRange(frame, frame);
|
||||
const generation = this.begin();
|
||||
const controller = this.controller!;
|
||||
try {
|
||||
const data = await this.source.readFrame(frame, controller.signal);
|
||||
if (!this.isCurrent(generation, controller)) return null;
|
||||
const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame);
|
||||
if (!this.isCurrent(generation, controller)) return null;
|
||||
this.publish(preview);
|
||||
return preview;
|
||||
}
|
||||
catch (error) {
|
||||
if (!this.isCurrent(generation, controller)) return null;
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
if (this.generation === generation) this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
async play(frameStart = this.source.frameStart, frameEnd = this.source.frameEnd): Promise<BrowserTransformCachePlaybackResult> {
|
||||
this.validateRange(frameStart, frameEnd);
|
||||
const generation = this.begin();
|
||||
const controller = this.controller!;
|
||||
let appliedFrames = 0;
|
||||
let lastFrame: number | null = null;
|
||||
try {
|
||||
for (let frame = frameStart; frame <= frameEnd; frame += 1) {
|
||||
const data = await this.source.readFrame(frame, controller.signal);
|
||||
if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame };
|
||||
const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame);
|
||||
if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame };
|
||||
this.publish(preview);
|
||||
appliedFrames += 1;
|
||||
lastFrame = frame;
|
||||
}
|
||||
return { status: "COMPLETED", appliedFrames, lastFrame };
|
||||
}
|
||||
catch (error) {
|
||||
if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame };
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
if (this.generation === generation) this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
private begin(): number {
|
||||
this.controller?.abort();
|
||||
this.controller = new AbortController();
|
||||
this.generation += 1;
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
private isCurrent(generation: number, controller: AbortController): boolean {
|
||||
return generation === this.generation && this.controller === controller && !controller.signal.aborted;
|
||||
}
|
||||
|
||||
private validateRange(frameStart: number, frameEnd: number): void {
|
||||
if (!Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd) || frameStart < this.source.frameStart ||
|
||||
frameEnd > this.source.frameEnd || frameEnd < frameStart) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache has no verified range ${frameStart}-${frameEnd}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ErrorCode } from "./error";
|
||||
|
||||
export const RELEASE_GATE_SCHEMA = 3 as const;
|
||||
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] }
|
||||
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; excludedSlices: string[]; acceptance: string[]; dependencies: string[] }
|
||||
export interface ReleaseEvidenceIR {
|
||||
browser: { chromium: boolean };
|
||||
runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean };
|
||||
@@ -26,6 +26,7 @@ function record(value: unknown): value is Record<string, unknown> { return typeo
|
||||
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return value; }
|
||||
function bool(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be boolean`); return value; }
|
||||
function strings(value: unknown, name: string, maximum = 100_000): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return [...value] as string[]; }
|
||||
function utcTimestamp(value: unknown, name: string): string { const result = text(value, name, 128); const date = new Date(result); if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || !Number.isFinite(date.getTime()) || date.toISOString() !== result) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be a canonical UTC timestamp`); return result; }
|
||||
|
||||
function parseEvidence(value: unknown): ReleaseEvidenceIR {
|
||||
if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing");
|
||||
@@ -37,10 +38,13 @@ function parseEvidence(value: unknown): ReleaseEvidenceIR {
|
||||
const id = text(item.id, `${name}.id`); if (recordIds.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate evidence record ${id}`); recordIds.add(id);
|
||||
const fields = strings(item.fields, `${name}.fields`, 64); if (new Set(fields).size !== fields.length || fields.some((field) => !/^(browser|runtime|performance|faults|provenance)\.[A-Za-z0-9]+$/.test(field))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.fields is invalid`);
|
||||
if (item.exitCode !== 0 || typeof item.durationMs !== "number" || !Number.isSafeInteger(item.durationMs) || item.durationMs < 0 || item.durationMs > 86_400_000) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} did not complete successfully`);
|
||||
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if (artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is invalid`);
|
||||
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if ((fields.length > 0 && artifactSha256.length === 0) || new Set(artifactSha256).size !== artifactSha256.length || artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is missing, duplicate or invalid`);
|
||||
return { id, fields, command: text(item.command, `${name}.command`, 2048), exitCode: 0, durationMs: item.durationMs, output: text(item.output, `${name}.output`, 4096), artifactSha256 };
|
||||
});
|
||||
const parsed = { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"], records };
|
||||
const knownFields = new Map<string, boolean>();
|
||||
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) for (const [key, enabled] of Object.entries(groupValues)) knownFields.set(`${groupName}.${key}`, enabled);
|
||||
for (const evidenceRecord of records) for (const field of evidenceRecord.fields) if (knownFields.get(field) !== true) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Evidence record ${evidenceRecord.id} binds unknown or disabled field ${field}`);
|
||||
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) {
|
||||
for (const [key, enabled] of Object.entries(groupValues)) if (enabled && !records.some((item) => item.fields.includes(`${groupName}.${key}`))) throw new ReleaseGateValidationError("RELEASE_EVIDENCE_MISSING", `Enabled evidence ${groupName}.${key} has no successful record`);
|
||||
}
|
||||
@@ -55,10 +59,10 @@ function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
|
||||
|
||||
export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
|
||||
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
|
||||
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one state`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, excludedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
|
||||
assertDependencies(families);
|
||||
const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: utcTimestamp(value.generatedAt, "generatedAt"), families, evidence: parseEvidence(value.evidence) };
|
||||
}
|
||||
|
||||
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
|
||||
import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneIR, SceneNodeIR, SceneSnapshotIR, WorldIR } from "./scene-ir";
|
||||
|
||||
export interface SceneCollectionDelta<T extends { id: string }> {
|
||||
updated: Array<Pick<T, "id"> & Partial<T>>;
|
||||
@@ -20,11 +20,13 @@ export interface SceneDelta {
|
||||
animations?: SceneCollectionDelta<AnimationIR>;
|
||||
cameras?: SceneCollectionDelta<CameraIR>;
|
||||
lights?: SceneCollectionDelta<LightIR>;
|
||||
worlds?: SceneCollectionDelta<WorldIR>;
|
||||
scenes?: SceneCollectionDelta<SceneIR>;
|
||||
activeObjectId?: string | null;
|
||||
frame?: SceneSnapshotIR["frame"];
|
||||
}
|
||||
|
||||
const collectionFields = ["meshes", "materials", "animations", "cameras", "lights"] as const;
|
||||
const collectionFields = ["meshes", "materials", "animations", "cameras", "lights", "worlds", "scenes"] as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -115,6 +117,8 @@ export function diffSceneSnapshots(before: SceneSnapshotIR, after: SceneSnapshot
|
||||
delta.animations = diffCollection(before.animations, after.animations);
|
||||
delta.cameras = diffCollection(before.cameras, after.cameras);
|
||||
delta.lights = diffCollection(before.lights, after.lights);
|
||||
delta.worlds = diffCollection(before.worlds, after.worlds);
|
||||
delta.scenes = diffCollection(before.scenes, after.scenes);
|
||||
if (before.activeObjectId !== after.activeObjectId) delta.activeObjectId = after.activeObjectId;
|
||||
if (JSON.stringify(before.frame) !== JSON.stringify(after.frame)) delta.frame = after.frame;
|
||||
return delta;
|
||||
@@ -153,7 +157,14 @@ export function applySceneDelta(snapshot: SceneSnapshotIR, delta: SceneDelta): S
|
||||
animations: applyCollectionDelta(snapshot.animations, delta.animations),
|
||||
cameras: applyCollectionDelta(snapshot.cameras, delta.cameras),
|
||||
lights: applyCollectionDelta(snapshot.lights, delta.lights),
|
||||
worlds: applyCollectionDelta(snapshot.worlds, delta.worlds),
|
||||
scenes: applyCollectionDelta(snapshot.scenes, delta.scenes),
|
||||
activeObjectId: delta.activeObjectId === undefined ? snapshot.activeObjectId : delta.activeObjectId,
|
||||
frame: delta.frame ?? snapshot.frame,
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneDeltaRequiresRendererRebuild(delta: SceneDelta): boolean {
|
||||
return Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length || delta.meshes || delta.materials ||
|
||||
delta.cameras || delta.lights || delta.worlds || delta.scenes || delta.animations);
|
||||
}
|
||||
|
||||
@@ -371,6 +371,14 @@ export interface VFontResourceIR {
|
||||
packed: boolean;
|
||||
}
|
||||
|
||||
export interface NonMeshVolumePropertiesIR {
|
||||
displayDensity: number;
|
||||
interpolation: "NEAREST" | "LINEAR";
|
||||
stepSize: number;
|
||||
velocityGrid: string;
|
||||
velocityScale: number;
|
||||
}
|
||||
|
||||
export interface NonMeshDataIR {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -405,6 +413,7 @@ export interface NonMeshDataIR {
|
||||
resourceKind?: "OPENVDB";
|
||||
resourceByteLength?: number;
|
||||
volumeGrids?: VolumeGridMetadataIR[];
|
||||
volumeProperties?: NonMeshVolumePropertiesIR;
|
||||
errorCode?: "NON_MESH_DATA_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_RESOURCE_MISSING" | "NON_MESH_BINARY_INVALID" | "NON_MESH_RESOURCE_OUTSIDE_PROJECT" | "NON_MESH_VDB_BUDGET_EXCEEDED";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { ErrorCode } from "./error";
|
||||
|
||||
export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
|
||||
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const;
|
||||
export const SCRIPT_EXECUTION_AUDIT_SCHEMA = 1 as const;
|
||||
export const SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536, maxAuditEntries: 65_536 } as const;
|
||||
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
|
||||
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
|
||||
|
||||
@@ -44,6 +46,30 @@ export interface ScriptSourceIR {
|
||||
}
|
||||
export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] }
|
||||
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
|
||||
export interface ScriptExecutionAuditIR {
|
||||
schemaVersion: typeof SCRIPT_EXECUTION_AUDIT_SCHEMA;
|
||||
requestId: string;
|
||||
requestedAt: string;
|
||||
scriptId: string;
|
||||
sourceSha256: string;
|
||||
manifestSha256: string;
|
||||
permissions: ScriptPermission[];
|
||||
budget: { cpuMs: number; memoryBytes: number; wallMs: number };
|
||||
approvedKey: boolean;
|
||||
decision: "DENY";
|
||||
reason: "SCRIPT_SIGNATURE_INVALID" | "SCRIPT_SANDBOX_UNAVAILABLE";
|
||||
requestSha256: string;
|
||||
}
|
||||
export interface ScriptExecutionAuditLogEntryIR {
|
||||
sequence: number;
|
||||
previousEntrySha256: string | null;
|
||||
audit: ScriptExecutionAuditIR;
|
||||
entrySha256: string;
|
||||
}
|
||||
export interface ScriptExecutionAuditLogIR {
|
||||
schemaVersion: typeof SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA;
|
||||
entries: ScriptExecutionAuditLogEntryIR[];
|
||||
}
|
||||
|
||||
export class ScriptingPlatformValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
@@ -56,6 +82,122 @@ function text(value: unknown, name: string, maximum = 256): string { if (typeof
|
||||
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
|
||||
function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } }
|
||||
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; }
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
async function sha256(value: string): Promise<string> {
|
||||
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function canonicalAuditRequest(audit: Omit<ScriptExecutionAuditIR, "schemaVersion" | "requestSha256">): Omit<ScriptExecutionAuditIR, "schemaVersion" | "requestSha256"> {
|
||||
return { ...audit, permissions: [...audit.permissions].sort(), budget: { ...audit.budget } };
|
||||
}
|
||||
|
||||
function isoDate(value: unknown, name: string): string {
|
||||
const result = text(value, name, 64); const date = new Date(result);
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || !Number.isFinite(date.getTime()) || date.toISOString() !== result) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
||||
return result;
|
||||
}
|
||||
function auditRequestId(value: unknown, name: string): string {
|
||||
const result = text(value, name);
|
||||
if (!/^[-A-Za-z0-9:_./]{1,256}$/.test(result)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function canonicalManifest(manifest: ScriptingManifestIR): ScriptingManifestIR {
|
||||
return {
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
scripts: manifest.scripts
|
||||
.map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id.localeCompare(b.id)) }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createScriptExecutionAudit(
|
||||
manifest: unknown,
|
||||
scriptId: string,
|
||||
approvedKeyIds: ReadonlySet<string>,
|
||||
options: { requestId?: string; requestedAt?: string } = {},
|
||||
): Promise<ScriptExecutionAuditIR> {
|
||||
const parsed = parseScriptingManifest(manifest);
|
||||
const script = parsed.scripts.find((item) => item.id === scriptId);
|
||||
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
|
||||
const requestId = auditRequestId(options.requestId ?? `script-audit:${scriptId}:${Date.now()}`, "requestId");
|
||||
const requestedAt = isoDate(options.requestedAt ?? new Date().toISOString(), "requestedAt");
|
||||
const approvedKey = approvedKeyIds.has(script.keyId);
|
||||
const reason = approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID";
|
||||
const manifestSha256 = await sha256(stableJSON(canonicalManifest(parsed)));
|
||||
const request = canonicalAuditRequest({ requestId, requestedAt, scriptId, sourceSha256: script.sourceSha256, manifestSha256, permissions: [...script.permissions], budget: { cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }, approvedKey, decision: "DENY", reason });
|
||||
const requestSha256 = await sha256(stableJSON(request));
|
||||
return Object.freeze({
|
||||
schemaVersion: SCRIPT_EXECUTION_AUDIT_SCHEMA,
|
||||
requestId,
|
||||
requestedAt,
|
||||
scriptId,
|
||||
sourceSha256: script.sourceSha256,
|
||||
manifestSha256,
|
||||
permissions: Object.freeze([...script.permissions].sort()) as unknown as ScriptPermission[],
|
||||
budget: Object.freeze({ cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }),
|
||||
approvedKey,
|
||||
decision: "DENY",
|
||||
reason,
|
||||
requestSha256,
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseScriptExecutionAudit(value: unknown): Promise<ScriptExecutionAuditIR> {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_EXECUTION_AUDIT_SCHEMA || !Array.isArray(value.permissions) || !record(value.budget)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Script execution audit is invalid");
|
||||
const permissions = value.permissions.map((permission, index) => text(permission, `audit.permissions[${index}]`, 64) as ScriptPermission);
|
||||
if (permissions.length > SCRIPTING_BUDGET.maxPermissions || new Set(permissions).size !== permissions.length || permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission)) || permissions.some((permission, index) => index > 0 && permissions[index - 1] > permission)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit permissions are invalid or not canonical");
|
||||
if (typeof value.approvedKey !== "boolean" || value.decision !== "DENY" || !["SCRIPT_SIGNATURE_INVALID", "SCRIPT_SANDBOX_UNAVAILABLE"].includes(value.reason as string) || value.reason !== (value.approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Audit decision is inconsistent with the default-deny policy");
|
||||
const request = canonicalAuditRequest({
|
||||
requestId: auditRequestId(value.requestId, "audit.requestId"),
|
||||
requestedAt: isoDate(value.requestedAt, "audit.requestedAt"),
|
||||
scriptId: text(value.scriptId, "audit.scriptId"),
|
||||
sourceSha256: digest(value.sourceSha256, "audit.sourceSha256"),
|
||||
manifestSha256: digest(value.manifestSha256, "audit.manifestSha256"),
|
||||
permissions,
|
||||
budget: { cpuMs: integer(value.budget.cpuMs, "audit.budget.cpuMs", 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(value.budget.memoryBytes, "audit.budget.memoryBytes", 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(value.budget.wallMs, "audit.budget.wallMs", 1, SCRIPTING_BUDGET.maxWallMs) },
|
||||
approvedKey: value.approvedKey,
|
||||
decision: "DENY",
|
||||
reason: value.reason as ScriptExecutionAuditIR["reason"],
|
||||
});
|
||||
const requestSha256 = digest(value.requestSha256, "audit.requestSha256");
|
||||
if (await sha256(stableJSON(request)) !== requestSha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit request digest does not match its canonical content");
|
||||
return { schemaVersion: SCRIPT_EXECUTION_AUDIT_SCHEMA, ...request, requestSha256 };
|
||||
}
|
||||
|
||||
export async function parseScriptExecutionAuditLog(value: unknown): Promise<ScriptExecutionAuditLogIR> {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA || !Array.isArray(value.entries)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script execution audit log schema");
|
||||
if (value.entries.length > SCRIPTING_BUDGET.maxAuditEntries) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script audit log exceeds the entry budget");
|
||||
const entries: ScriptExecutionAuditLogEntryIR[] = []; const requestIds = new Set<string>();
|
||||
for (const [index, entryValue] of value.entries.entries()) {
|
||||
if (!record(entryValue) || !record(entryValue.audit) || entryValue.sequence !== index + 1) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} has an invalid sequence`);
|
||||
const audit = await parseScriptExecutionAudit(entryValue.audit);
|
||||
if (requestIds.has(audit.requestId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit request ${audit.requestId} is replayed`);
|
||||
if (entries.length > 0 && audit.requestedAt <= entries[entries.length - 1].audit.requestedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit log timestamps are not strictly increasing");
|
||||
const previousEntrySha256 = index === 0 ? null : entries[index - 1].entrySha256;
|
||||
if (entryValue.previousEntrySha256 !== previousEntrySha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} breaks the hash chain`);
|
||||
const entrySha256 = digest(entryValue.entrySha256, `entries[${index}].entrySha256`);
|
||||
if (await sha256(stableJSON({ sequence: index + 1, previousEntrySha256, audit })) !== entrySha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} digest does not match`);
|
||||
requestIds.add(audit.requestId); entries.push({ sequence: index + 1, previousEntrySha256, audit, entrySha256 });
|
||||
}
|
||||
return { schemaVersion: SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA, entries };
|
||||
}
|
||||
|
||||
export async function appendScriptExecutionAudit(logValue: unknown, auditValue: unknown): Promise<ScriptExecutionAuditLogIR> {
|
||||
const log = await parseScriptExecutionAuditLog(logValue); const audit = await parseScriptExecutionAudit(auditValue);
|
||||
if (log.entries.length >= SCRIPTING_BUDGET.maxAuditEntries) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script audit log exceeds the entry budget");
|
||||
if (log.entries.some((entry) => entry.audit.requestId === audit.requestId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit request ${audit.requestId} is replayed`);
|
||||
const previous = log.entries.at(-1);
|
||||
if (previous && audit.requestedAt <= previous.audit.requestedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit log timestamps must be strictly increasing");
|
||||
const sequence = log.entries.length + 1; const previousEntrySha256 = previous?.entrySha256 ?? null;
|
||||
const entrySha256 = await sha256(stableJSON({ sequence, previousEntrySha256, audit }));
|
||||
return parseScriptExecutionAuditLog({ schemaVersion: SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA, entries: [...log.entries, { sequence, previousEntrySha256, audit, entrySha256 }] });
|
||||
}
|
||||
|
||||
export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import { readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SELECTION_HISTORY_SCHEMA = 2 as const;
|
||||
@@ -291,6 +291,5 @@ export function parseRaycastSelectionHit(value: unknown, expectedRevision: numbe
|
||||
}
|
||||
|
||||
export function gateSelectionInteraction(operation: "RAYCAST" | "HISTORY" | "GIZMO"): CapabilityGateResult {
|
||||
if (operation !== "GIZMO") return readyGate("N-015", operation);
|
||||
return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve gizmo preview remains unavailable; bounded multi-handle commit is supported")]);
|
||||
return readyGate("N-015", operation);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,21 @@ export interface SequencerRuntimeCapabilityIR {
|
||||
localEncoding: "BLOCKED";
|
||||
}
|
||||
|
||||
export interface SequencerFrameStripIR {
|
||||
stripId: string;
|
||||
channel: number;
|
||||
sourceFrame: number;
|
||||
dependencyStripIds: string[];
|
||||
}
|
||||
|
||||
export interface SequencerTransitionFrameIR {
|
||||
effectStripId: string;
|
||||
effectType: "CROSS" | "GAMMA_CROSS";
|
||||
factor: number;
|
||||
from: { stripId: string; sourceFrame: number };
|
||||
to: { stripId: string; sourceFrame: number };
|
||||
}
|
||||
|
||||
export class SequencerValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
@@ -236,6 +251,54 @@ export function sequencerSourceFrame(strip: SequencerStripIR, timelineFrame: num
|
||||
return Math.min(strip.sourceEnd, Math.max(strip.sourceStart, strip.sourceStart + (timelineFrame - strip.frameStart) * strip.speed));
|
||||
}
|
||||
|
||||
export function resolveSequencerFrame(value: unknown, timelineFrame: number): SequencerFrameStripIR[] {
|
||||
const timeline = parseSequencerTimeline(value);
|
||||
if (!Number.isFinite(timelineFrame) || timelineFrame < timeline.frameStart || timelineFrame > timeline.frameEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Timeline frame ${timelineFrame} is outside the scene range`);
|
||||
const active = timeline.strips.filter((strip) => !strip.muted && timelineFrame >= strip.frameStart && timelineFrame < strip.frameEnd);
|
||||
const activeIds = new Set(active.map((strip) => strip.id));
|
||||
const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? []));
|
||||
const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => {
|
||||
const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])];
|
||||
if (dependencies.some((id) => !activeIds.has(id))) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive frame dependency`);
|
||||
return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, timelineFrame), dependencyStripIds: [...dependencies] };
|
||||
});
|
||||
result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolves the ordered inputs and bounded progress for the verified cross-transition subset. */
|
||||
export function resolveSequencerTransitionFrame(
|
||||
value: unknown,
|
||||
effectStripId: string,
|
||||
timelineFrame: number,
|
||||
): SequencerTransitionFrameIR {
|
||||
const timeline = parseSequencerTimeline(value);
|
||||
const effect = timeline.strips.find((strip) => strip.id === effectStripId);
|
||||
if (!effect || effect.type !== "EFFECT" ||
|
||||
(effect.effectType !== "CROSS" && effect.effectType !== "GAMMA_CROSS") ||
|
||||
effect.inputStripIds?.length !== 2) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is not a supported two-input cross transition`);
|
||||
}
|
||||
if (!Number.isFinite(timelineFrame) || timelineFrame < effect.frameStart || timelineFrame >= effect.frameEnd) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is inactive at frame ${timelineFrame}`);
|
||||
}
|
||||
const [fromId, toId] = effect.inputStripIds;
|
||||
const from = timeline.strips.find((strip) => strip.id === fromId);
|
||||
const to = timeline.strips.find((strip) => strip.id === toId);
|
||||
if (!from || !to || from.muted || to.muted || timelineFrame < from.frameStart || timelineFrame >= from.frameEnd ||
|
||||
timelineFrame < to.frameStart || timelineFrame >= to.frameEnd) {
|
||||
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${effectStripId} has an inactive transition input`);
|
||||
}
|
||||
const factor = (timelineFrame - effect.frameStart) / (effect.frameEnd - effect.frameStart);
|
||||
return {
|
||||
effectStripId,
|
||||
effectType: effect.effectType,
|
||||
factor,
|
||||
from: { stripId: from.id, sourceFrame: sequencerSourceFrame(from, timelineFrame) },
|
||||
to: { stripId: to.id, sourceFrame: sequencerSourceFrame(to, timelineFrame) },
|
||||
};
|
||||
}
|
||||
|
||||
export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalThis): SequencerRuntimeCapabilityIR {
|
||||
return {
|
||||
webCodecsVideo: "VideoDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
|
||||
|
||||
@@ -118,6 +118,24 @@ export interface TrackingMaskProjectIR {
|
||||
bindings: TrackingMaskBindingIR[];
|
||||
}
|
||||
|
||||
export interface MaskRaycastHitIR {
|
||||
maskId: string;
|
||||
layerId: string;
|
||||
splineId: string;
|
||||
kind: "POINT" | "SEGMENT";
|
||||
pointId: string;
|
||||
nextPointId?: string;
|
||||
distance: number;
|
||||
parameter?: number;
|
||||
}
|
||||
|
||||
export interface MaskPointSelectionIR {
|
||||
maskId: string;
|
||||
layerId: string;
|
||||
splineId: string;
|
||||
pointId: string;
|
||||
}
|
||||
|
||||
export type TrackingMaskEditIR =
|
||||
| { type: "SET_MARKER"; revision: number; clipId: string; trackId: string; marker: TrackingMarkerIR }
|
||||
| { type: "DELETE_MARKER"; revision: number; clipId: string; trackId: string; frame: number }
|
||||
@@ -327,3 +345,86 @@ export function gateTrackingOperation(operation: "MARKER_EDIT" | "MASK_EDIT" | "
|
||||
if (operation === "BROWSER_TRACKING" && browserProbe === "VERIFIED") return readyGate("N-022", operation);
|
||||
return blockedGate("N-022", operation, [capabilityIssue("TRACKING_SOLVE_UNAVAILABLE", operation === "CAMERA_SOLVE" ? "Camera solve requires a verified server Blender implementation" : "Browser tracking requires an explicit feature probe")]);
|
||||
}
|
||||
|
||||
function bezierPoint(a: Vec2, b: Vec2, c: Vec2, d: Vec2, t: number): Vec2 {
|
||||
const inverse = 1 - t;
|
||||
return [inverse ** 3 * a[0] + 3 * inverse ** 2 * t * b[0] + 3 * inverse * t ** 2 * c[0] + t ** 3 * d[0], inverse ** 3 * a[1] + 3 * inverse ** 2 * t * b[1] + 3 * inverse * t ** 2 * c[1] + t ** 3 * d[1]];
|
||||
}
|
||||
|
||||
export function raycastMaskProject(value: unknown, positionValue: unknown, thresholdValue = 0.02, segmentSamples = 24): MaskRaycastHitIR | null {
|
||||
const project = parseTrackingMaskProject(value);
|
||||
const position = vec2(positionValue, "position", -4, 4);
|
||||
const threshold = finite(thresholdValue, "threshold", 0.000001, 1);
|
||||
const samples = integer(segmentSamples, "segmentSamples", 2, 128);
|
||||
let best: MaskRaycastHitIR | null = null;
|
||||
const consider = (hit: MaskRaycastHitIR): void => { if (hit.distance <= threshold && (!best || hit.distance < best.distance || (hit.distance === best.distance && hit.kind === "POINT" && best.kind === "SEGMENT"))) best = hit; };
|
||||
for (const mask of project.masks) for (const layer of mask.layers) {
|
||||
if (!layer.visible || layer.locked || layer.opacity <= 0) continue;
|
||||
for (const spline of layer.splines) {
|
||||
for (const point of spline.points) consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "POINT", pointId: point.id, distance: Math.hypot(point.co[0] - position[0], point.co[1] - position[1]) });
|
||||
const segmentCount = spline.cyclic ? spline.points.length : spline.points.length - 1;
|
||||
for (let segment = 0; segment < segmentCount; segment++) {
|
||||
const first = spline.points[segment]; const next = spline.points[(segment + 1) % spline.points.length];
|
||||
for (let sample = 0; sample <= samples; sample++) {
|
||||
const parameter = sample / samples;
|
||||
const point = bezierPoint(first.co, first.handleRight, next.handleLeft, next.co, parameter);
|
||||
consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "SEGMENT", pointId: first.id, nextPointId: next.id, distance: Math.hypot(point[0] - position[0], point[1] - position[1]), parameter });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function maskSelectionKey(selection: MaskPointSelectionIR): string {
|
||||
return `${selection.maskId}\0${selection.layerId}\0${selection.splineId}\0${selection.pointId}`;
|
||||
}
|
||||
|
||||
/** Applies deterministic replace/add/toggle marquee selection to editable Mask control points. */
|
||||
export function selectMaskPointsInBounds(
|
||||
value: unknown,
|
||||
minimumValue: unknown,
|
||||
maximumValue: unknown,
|
||||
currentValue: unknown = [],
|
||||
mode: "REPLACE" | "ADD" | "TOGGLE" = "REPLACE",
|
||||
): MaskPointSelectionIR[] {
|
||||
const project = parseTrackingMaskProject(value);
|
||||
if (!(["REPLACE", "ADD", "TOGGLE"] as const).includes(mode)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection mode is invalid");
|
||||
const minimum = vec2(minimumValue, "minimum", -4, 4);
|
||||
const maximum = vec2(maximumValue, "maximum", -4, 4);
|
||||
if (minimum[0] > maximum[0] || minimum[1] > maximum[1]) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection bounds are inverted");
|
||||
if (!Array.isArray(currentValue) || currentValue.length > TRACKING_MASK_BUDGET.maxMaskPoints) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask selection exceeds the point budget");
|
||||
const all: MaskPointSelectionIR[] = [];
|
||||
const editable = new Set<string>();
|
||||
for (const mask of project.masks) for (const layer of mask.layers) for (const spline of layer.splines) for (const point of spline.points) {
|
||||
const selection = { maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id };
|
||||
all.push(selection);
|
||||
if (layer.visible && !layer.locked && layer.opacity > 0) editable.add(maskSelectionKey(selection));
|
||||
}
|
||||
const allKeys = new Set(all.map(maskSelectionKey));
|
||||
const current = new Set<string>();
|
||||
currentValue.forEach((item, index) => {
|
||||
if (!record(item)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is invalid`);
|
||||
const selection = { maskId: text(item.maskId, `current[${index}].maskId`), layerId: text(item.layerId, `current[${index}].layerId`), splineId: text(item.splineId, `current[${index}].splineId`), pointId: text(item.pointId, `current[${index}].pointId`) };
|
||||
const key = maskSelectionKey(selection);
|
||||
if (!allKeys.has(key)) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `current[${index}] references a missing Mask point`);
|
||||
if (current.has(key)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is duplicated`);
|
||||
current.add(key);
|
||||
});
|
||||
const hits = new Set<string>();
|
||||
for (const mask of project.masks) for (const layer of mask.layers) {
|
||||
if (!layer.visible || layer.locked || layer.opacity <= 0) continue;
|
||||
for (const spline of layer.splines) for (const point of spline.points) {
|
||||
if (point.co[0] >= minimum[0] && point.co[0] <= maximum[0] && point.co[1] >= minimum[1] && point.co[1] <= maximum[1]) {
|
||||
hits.add(maskSelectionKey({ maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id }));
|
||||
}
|
||||
}
|
||||
}
|
||||
const selected = mode === "REPLACE" ? new Set<string>() : new Set(current);
|
||||
for (const key of hits) {
|
||||
if (!editable.has(key)) continue;
|
||||
if (mode === "TOGGLE" && selected.has(key)) selected.delete(key);
|
||||
else selected.add(key);
|
||||
}
|
||||
return all.filter((selection) => selected.has(maskSelectionKey(selection)));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
import type { VolumeGridMetadataIR } from "./scene-ir";
|
||||
|
||||
export const VDB_PIPELINE_SCHEMA = 1;
|
||||
export const VDB_MAX_RESOURCE_BYTES = 512 * 1024 * 1024;
|
||||
export const VDB_MAX_ACTIVE_VOXELS = 64_000_000;
|
||||
export const VDB_MAX_GRIDS = 64;
|
||||
export const NANOVDB_MAX_BUNDLE_BYTES = 1024 * 1024 * 1024;
|
||||
export const NANOVDB_MAX_CHUNKS = 8192;
|
||||
export const NANOVDB_MAX_CHUNK_BYTES = 16 * 1024 * 1024;
|
||||
export const NANOVDB_MAX_GPU_RESIDENT_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
const ID_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const SUPPORTED_GRID_TYPES = new Set<NanoVDBGridValueType>(["FLOAT32", "FLOAT16", "VEC3F32", "VEC4F32"]);
|
||||
|
||||
export type VDBExecutionTarget = "DESKTOP" | "SERVER";
|
||||
export type NanoVDBGridValueType = "FLOAT32" | "FLOAT16" | "VEC3F32" | "VEC4F32";
|
||||
export type NanoVDBGridClass = "FOG_VOLUME" | "LEVEL_SET" | "STAGGERED" | "UNKNOWN";
|
||||
export type NanoVDBGridSemantic = "DENSITY" | "TEMPERATURE" | "COLOR" | "EMISSION" | "VELOCITY" | "CUSTOM";
|
||||
export type NanoVDBPipelineStage =
|
||||
| "RAW_VDB_BROWSER_DECODE"
|
||||
| "DESKTOP_CONVERSION"
|
||||
| "SERVER_CONVERSION"
|
||||
| "NANOVDB_STREAM"
|
||||
| "WEBGPU_VOLUME_RENDER";
|
||||
|
||||
export class VDBPipelineError extends Error {
|
||||
constructor(public readonly code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "VDBPipelineError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface VDBResourceManifest {
|
||||
projectId: string;
|
||||
@@ -13,67 +42,496 @@ export interface VDBResourceManifest {
|
||||
grids: VolumeGridMetadataIR[];
|
||||
}
|
||||
|
||||
export interface VDBDecodeRequest extends VDBResourceManifest {
|
||||
export interface VDBConversionInput extends VDBResourceManifest {
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface VDBDecodeResult {
|
||||
export interface PreparedVDBConversionInput {
|
||||
metadata: VDBResourceManifest;
|
||||
decodedByteLength: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type VDBDecoder = (request: VDBDecodeRequest, signal: AbortSignal) => Promise<VDBDecodeResult>;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new Error(`NON_MESH_BINARY_INVALID: ${message}`);
|
||||
export interface VDBConverterIdentityIR {
|
||||
target: VDBExecutionTarget;
|
||||
blenderVersion: string;
|
||||
openVDBVersion: string;
|
||||
nanoVDBVersion: string;
|
||||
executableSha256: string;
|
||||
}
|
||||
|
||||
export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest {
|
||||
if (!manifest.projectId || !/^[a-zA-Z0-9._-]+$/.test(manifest.projectId)) invalid("VDB projectId is invalid");
|
||||
let sourcePath: string;
|
||||
export interface VDBConversionRequestIR {
|
||||
schemaVersion: typeof VDB_PIPELINE_SCHEMA;
|
||||
jobId: string;
|
||||
source: VDBResourceManifest;
|
||||
sourceBlendSha256?: string;
|
||||
outputPath: string;
|
||||
selectedGrids: string[];
|
||||
quantization: "LOSSLESS" | "FP16" | "FP8";
|
||||
chunkByteLength: number;
|
||||
converter: VDBConverterIdentityIR;
|
||||
}
|
||||
|
||||
export interface NanoVDBChunkIR {
|
||||
index: number;
|
||||
byteOffset: number;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface NanoVDBGridIR {
|
||||
name: string;
|
||||
valueType: NanoVDBGridValueType;
|
||||
gridClass: NanoVDBGridClass;
|
||||
semantic: NanoVDBGridSemantic;
|
||||
activeVoxelCount: number;
|
||||
segmentByteOffset: number;
|
||||
segmentByteLength: number;
|
||||
byteOffset: number;
|
||||
byteLength: number;
|
||||
indexBounds: { min: [number, number, number]; max: [number, number, number] };
|
||||
worldBounds: { min: [number, number, number]; max: [number, number, number] };
|
||||
voxelSize: [number, number, number];
|
||||
indexToWorld: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number];
|
||||
}
|
||||
|
||||
export interface NanoVDBMaterialIR {
|
||||
densityGrid: string;
|
||||
temperatureGrid?: string;
|
||||
colorGrid?: string;
|
||||
emissionGrid?: string;
|
||||
velocityGrid?: string;
|
||||
densityScale: number;
|
||||
emissionScale: number;
|
||||
temperatureScale: number;
|
||||
anisotropy: number;
|
||||
interpolation: "NEAREST" | "LINEAR";
|
||||
color?: [number, number, number];
|
||||
emissionColor?: [number, number, number];
|
||||
}
|
||||
|
||||
export interface NanoVDBGpuLayoutIR {
|
||||
representation: "NANOVDB_STORAGE_BUFFER";
|
||||
byteAlignment: 32;
|
||||
pageByteLength: number;
|
||||
maxResidentBytes: number;
|
||||
shaderSemanticVersion: "volume-wgsl-v1";
|
||||
float32TreeLayout?: NanoVDBFloat32TreeLayoutIR;
|
||||
vec3fTreeLayout?: NanoVDBFloat32TreeLayoutIR;
|
||||
}
|
||||
|
||||
export interface NanoVDBFloat32TreeLayoutIR {
|
||||
gridDataBytes: number;
|
||||
treeDataBytes: number;
|
||||
treeRootOffsetOffset: number;
|
||||
rootDataBytes: number;
|
||||
rootTableSizeOffset: number;
|
||||
rootTileBytes: number;
|
||||
rootTileKeyOffset: number;
|
||||
rootTileChildOffset: number;
|
||||
rootTileStateOffset: number;
|
||||
rootTileValueOffset: number;
|
||||
upperNodeBytes: number;
|
||||
upperValueMaskOffset: number;
|
||||
upperChildMaskOffset: number;
|
||||
upperTableOffset: number;
|
||||
lowerNodeBytes: number;
|
||||
lowerValueMaskOffset: number;
|
||||
lowerChildMaskOffset: number;
|
||||
lowerTableOffset: number;
|
||||
leafNodeBytes: number;
|
||||
leafValueMaskOffset: number;
|
||||
leafValuesOffset: number;
|
||||
}
|
||||
|
||||
export interface NanoVDBBundleManifestIR {
|
||||
schemaVersion: typeof VDB_PIPELINE_SCHEMA;
|
||||
projectId: string;
|
||||
sourcePath: string;
|
||||
sourceSha256: string;
|
||||
conversionRequestSha256: string;
|
||||
bundlePath: string;
|
||||
bundleByteLength: number;
|
||||
bundleSha256: string;
|
||||
converter: VDBConverterIdentityIR;
|
||||
grids: NanoVDBGridIR[];
|
||||
chunks: NanoVDBChunkIR[];
|
||||
material: NanoVDBMaterialIR;
|
||||
gpu: NanoVDBGpuLayoutIR;
|
||||
}
|
||||
|
||||
export interface VDBProjectBindingIR {
|
||||
schemaVersion: typeof VDB_PIPELINE_SCHEMA;
|
||||
projectId: string;
|
||||
sourceBlendSha256: string;
|
||||
sourcePath: string;
|
||||
sourceSha256: string;
|
||||
conversionRequestSha256: string;
|
||||
bundleSha256: string;
|
||||
bundleByteLength: number;
|
||||
manifestSha256: string;
|
||||
converter: VDBConverterIdentityIR;
|
||||
shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"];
|
||||
material: NanoVDBMaterialIR;
|
||||
committedAt: string;
|
||||
}
|
||||
|
||||
export interface VDBProjectReopenContextIR {
|
||||
projectId: string;
|
||||
sourceBlendSha256: string;
|
||||
sourcePath: string;
|
||||
sourceSha256: string;
|
||||
converter: VDBConverterIdentityIR;
|
||||
shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"];
|
||||
}
|
||||
|
||||
export interface VDBProjectBindingStatusIR {
|
||||
status: "READY" | "BLOCKED";
|
||||
code?: "VDB_BINDING_MISSING" | "VDB_SOURCE_CHANGED" | "VDB_CONVERTER_CHANGED" | "NANOVDB_HASH_MISMATCH" | "VOLUME_SHADER_UNAVAILABLE";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NanoVDBRangeIR {
|
||||
chunkIndex: number;
|
||||
start: number;
|
||||
endExclusive: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface NanoVDBPipelineContext {
|
||||
desktopConverterConfigured?: boolean;
|
||||
serverConverterConfigured?: boolean;
|
||||
manifestValidated?: boolean;
|
||||
rangeReaderAvailable?: boolean;
|
||||
webgpuAvailable?: boolean;
|
||||
volumeRendererAvailable?: boolean;
|
||||
}
|
||||
|
||||
function fail(code: ErrorCode, message: string): never {
|
||||
throw new VDBPipelineError(code, message);
|
||||
}
|
||||
|
||||
function safeInteger(value: number, name: string, min: number, max: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < min || value > max) fail("NANOVDB_MANIFEST_INVALID", `${name} is outside the bounded integer range`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: number, name: string): number {
|
||||
if (!Number.isFinite(value)) fail("NANOVDB_MANIFEST_INVALID", `${name} must be finite`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectPath(sourcePath: string, extension: string, label: string): string {
|
||||
let normalized: string;
|
||||
try {
|
||||
sourcePath = normalizeProjectAssetPath(manifest.sourcePath);
|
||||
normalized = normalizeProjectAssetPath(sourcePath);
|
||||
}
|
||||
catch {
|
||||
throw new Error("NON_MESH_RESOURCE_OUTSIDE_PROJECT: VDB path is outside the project asset root");
|
||||
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", `${label} path is outside the project asset root`);
|
||||
}
|
||||
if (!sourcePath.toLowerCase().endsWith(".vdb")) invalid("Volume resources must use the .vdb extension");
|
||||
if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 || manifest.byteLength > VDB_MAX_RESOURCE_BYTES) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: VDB resource size is outside the bounded range");
|
||||
if (!/^[a-f0-9]{64}$/.test(manifest.sha256)) invalid("VDB SHA-256 is invalid");
|
||||
if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: VDB grid count is outside the bounded range");
|
||||
const names = new Set<string>();
|
||||
let activeVoxels = 0;
|
||||
for (const grid of manifest.grids) {
|
||||
if (!grid.name || names.has(grid.name) || !grid.valueType) invalid("VDB grid identity is missing or duplicated");
|
||||
names.add(grid.name);
|
||||
const count = grid.activeVoxelCount ?? grid.voxelCount;
|
||||
if (!Number.isSafeInteger(count) || count < 0) invalid(`VDB grid ${grid.name} has an invalid active voxel count`);
|
||||
activeVoxels += count;
|
||||
if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: VDB active voxel budget exceeded");
|
||||
if (grid.bounds && grid.bounds.min.some((value, index) => !Number.isFinite(value) || value > grid.bounds!.max[index])) invalid(`VDB grid ${grid.name} bounds are invalid`);
|
||||
if (!normalized.toLowerCase().endsWith(extension)) fail("NON_MESH_BINARY_INVALID", `${label} must use the ${extension} extension`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateIdentity(value: VDBConverterIdentityIR): VDBConverterIdentityIR {
|
||||
if (value.target !== "DESKTOP" && value.target !== "SERVER") fail("VDB_CONVERSION_INVALID", "Converter target is invalid");
|
||||
for (const [name, version] of Object.entries({ blenderVersion: value.blenderVersion, openVDBVersion: value.openVDBVersion, nanoVDBVersion: value.nanoVDBVersion })) {
|
||||
if (typeof version !== "string" || version.length === 0 || version.length > 128) fail("VDB_CONVERSION_INVALID", `${name} is invalid`);
|
||||
}
|
||||
if (!SHA256_PATTERN.test(value.executableSha256)) fail("VDB_CONVERSION_INVALID", "Converter executable SHA-256 is invalid");
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function validateBounds(
|
||||
bounds: { min: [number, number, number]; max: [number, number, number] },
|
||||
name: string,
|
||||
integer: boolean,
|
||||
): void {
|
||||
if (!bounds || bounds.min.length !== 3 || bounds.max.length !== 3) fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`);
|
||||
bounds.min.forEach((value, index) => {
|
||||
if (!Number.isFinite(value) || value > bounds.max[index] || (integer && (!Number.isSafeInteger(value) || !Number.isSafeInteger(bounds.max[index])))) {
|
||||
fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateColor(value: [number, number, number] | undefined, name: string): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1000000)) {
|
||||
fail("NANOVDB_MANIFEST_INVALID", `${name} must contain three finite non-negative channels`);
|
||||
}
|
||||
return { ...manifest, sourcePath };
|
||||
}
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function decodeVDBResource(
|
||||
request: VDBDecodeRequest,
|
||||
decoder: VDBDecoder | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<VDBDecodeResult> {
|
||||
const metadata = validateVDBManifest(request);
|
||||
if (signal.aborted) throw new DOMException("VDB decode cancelled", "AbortError");
|
||||
if (request.data.byteLength !== metadata.byteLength) invalid("VDB byte length does not match its manifest");
|
||||
if (!globalThis.crypto?.subtle) invalid("SHA-256 is unavailable");
|
||||
const digest = hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", request.data)));
|
||||
if (digest !== metadata.sha256) invalid("VDB bytes do not match the manifest SHA-256");
|
||||
if (signal.aborted) throw new DOMException("VDB decode cancelled", "AbortError");
|
||||
if (!decoder) throw new Error("VOLUME_SHADER_UNAVAILABLE: no bounded OpenVDB decoder is installed");
|
||||
const result = await decoder({ ...request, ...metadata }, signal);
|
||||
if (signal.aborted) throw new DOMException("VDB decode cancelled", "AbortError");
|
||||
if (!Number.isSafeInteger(result.decodedByteLength) || result.decodedByteLength < 0 || result.decodedByteLength > VDB_MAX_RESOURCE_BYTES * 2) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: decoded VDB memory budget exceeded");
|
||||
return { ...result, metadata };
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
if (!globalThis.crypto?.subtle) fail("NON_MESH_BINARY_INVALID", "SHA-256 is unavailable");
|
||||
return hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data)));
|
||||
}
|
||||
|
||||
export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest {
|
||||
if (!manifest.projectId || !ID_PATTERN.test(manifest.projectId)) fail("NON_MESH_BINARY_INVALID", "VDB projectId is invalid");
|
||||
const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB resource");
|
||||
if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 || manifest.byteLength > VDB_MAX_RESOURCE_BYTES) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB resource size is outside the bounded range");
|
||||
if (!SHA256_PATTERN.test(manifest.sha256)) fail("NON_MESH_BINARY_INVALID", "VDB SHA-256 is invalid");
|
||||
if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB grid count is outside the bounded range");
|
||||
const names = new Set<string>();
|
||||
let activeVoxels = 0;
|
||||
for (const grid of manifest.grids) {
|
||||
if (!grid.name || names.has(grid.name) || !grid.valueType) fail("NON_MESH_BINARY_INVALID", "VDB grid identity is missing or duplicated");
|
||||
names.add(grid.name);
|
||||
const count = grid.activeVoxelCount ?? grid.voxelCount;
|
||||
if (!Number.isSafeInteger(count) || count < 0) fail("NON_MESH_BINARY_INVALID", `VDB grid ${grid.name} has an invalid active voxel count`);
|
||||
activeVoxels += count;
|
||||
if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB active voxel budget exceeded");
|
||||
if (grid.bounds) validateBounds(grid.bounds, `VDB grid ${grid.name}`, false);
|
||||
}
|
||||
return { ...manifest, sourcePath, grids: manifest.grids.map((grid) => ({ ...grid })) };
|
||||
}
|
||||
|
||||
export async function prepareVDBConversionInput(request: VDBConversionInput, signal: AbortSignal): Promise<PreparedVDBConversionInput> {
|
||||
const metadata = validateVDBManifest(request);
|
||||
if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError");
|
||||
if (!(request.data instanceof ArrayBuffer) || request.data.byteLength !== metadata.byteLength) fail("NON_MESH_BINARY_INVALID", "VDB byte length does not match its manifest");
|
||||
if (await sha256(request.data) !== metadata.sha256) fail("NANOVDB_HASH_MISMATCH", "VDB bytes do not match the source manifest SHA-256");
|
||||
if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError");
|
||||
return { metadata, data: request.data };
|
||||
}
|
||||
|
||||
export function validateVDBConversionRequest(request: VDBConversionRequestIR): VDBConversionRequestIR {
|
||||
if (request.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(request.jobId)) fail("VDB_CONVERSION_INVALID", "Conversion request schema or job ID is invalid");
|
||||
const source = validateVDBManifest(request.source);
|
||||
const outputPath = projectPath(request.outputPath, ".nvdb", "NanoVDB output");
|
||||
if (request.sourceBlendSha256 !== undefined && !SHA256_PATTERN.test(request.sourceBlendSha256)) fail("VDB_CONVERSION_INVALID", "Source blend SHA-256 is invalid");
|
||||
if (!Array.isArray(request.selectedGrids) || request.selectedGrids.length === 0 || request.selectedGrids.length > VDB_MAX_GRIDS) fail("VDB_CONVERSION_INVALID", "Selected grid list is invalid");
|
||||
const available = new Set(source.grids.map((grid) => grid.name));
|
||||
const selected = new Set<string>();
|
||||
request.selectedGrids.forEach((name) => {
|
||||
if (!available.has(name) || selected.has(name)) fail("VDB_CONVERSION_INVALID", `Selected grid ${name} is missing or duplicated`);
|
||||
selected.add(name);
|
||||
});
|
||||
if (!["LOSSLESS", "FP16", "FP8"].includes(request.quantization)) fail("VDB_CONVERSION_INVALID", "NanoVDB quantization is invalid");
|
||||
if (!Number.isSafeInteger(request.chunkByteLength) || request.chunkByteLength < 64 * 1024 || request.chunkByteLength > NANOVDB_MAX_CHUNK_BYTES || request.chunkByteLength % 32 !== 0) fail("VDB_CONVERSION_INVALID", "Chunk size must be 32-byte aligned and within 64 KiB to 16 MiB");
|
||||
return { ...request, source, outputPath, selectedGrids: [...request.selectedGrids], converter: validateIdentity(request.converter) };
|
||||
}
|
||||
|
||||
export function serializeVDBConversionRequest(value: VDBConversionRequestIR): string {
|
||||
const request = validateVDBConversionRequest(value);
|
||||
return JSON.stringify({
|
||||
schemaVersion: request.schemaVersion,
|
||||
source: {
|
||||
byteLength: request.source.byteLength,
|
||||
sha256: request.source.sha256,
|
||||
grids: request.source.grids.map((grid) => ({
|
||||
name: grid.name,
|
||||
valueType: grid.valueType,
|
||||
voxelCount: grid.voxelCount,
|
||||
...(grid.activeVoxelCount === undefined ? {} : { activeVoxelCount: grid.activeVoxelCount }),
|
||||
...(grid.bounds === undefined ? {} : { bounds: grid.bounds }),
|
||||
})),
|
||||
},
|
||||
...(request.sourceBlendSha256 === undefined ? {} : { sourceBlendSha256: request.sourceBlendSha256 }),
|
||||
selectedGrids: request.selectedGrids,
|
||||
quantization: request.quantization,
|
||||
chunkByteLength: request.chunkByteLength,
|
||||
converter: request.converter,
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashVDBConversionRequest(value: VDBConversionRequestIR): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(serializeVDBConversionRequest(value));
|
||||
return sha256(encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength) as ArrayBuffer);
|
||||
}
|
||||
|
||||
export function validateNanoVDBBundleManifest(manifest: NanoVDBBundleManifestIR): NanoVDBBundleManifestIR {
|
||||
if (manifest.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(manifest.projectId)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB schema or project ID is invalid");
|
||||
const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB source");
|
||||
const bundlePath = projectPath(manifest.bundlePath, ".nvdb", "NanoVDB bundle");
|
||||
if (!SHA256_PATTERN.test(manifest.sourceSha256) || !SHA256_PATTERN.test(manifest.conversionRequestSha256) || !SHA256_PATTERN.test(manifest.bundleSha256)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB source, conversion request, or bundle SHA-256 is invalid");
|
||||
safeInteger(manifest.bundleByteLength, "bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES);
|
||||
const converter = validateIdentity(manifest.converter);
|
||||
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0 || manifest.chunks.length > NANOVDB_MAX_CHUNKS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB chunk count is outside the bounded range");
|
||||
let nextOffset = 0;
|
||||
const chunks = manifest.chunks.map((chunk, position) => {
|
||||
if (chunk.index !== position || chunk.byteOffset !== nextOffset || chunk.byteOffset % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} is not contiguous or aligned`);
|
||||
safeInteger(chunk.byteLength, `chunks[${position}].byteLength`, 1, NANOVDB_MAX_CHUNK_BYTES);
|
||||
if (position < manifest.chunks.length - 1 && chunk.byteLength % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} length is not aligned`);
|
||||
if (!SHA256_PATTERN.test(chunk.sha256)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB chunk ${position} SHA-256 is invalid`);
|
||||
nextOffset += chunk.byteLength;
|
||||
if (!Number.isSafeInteger(nextOffset) || nextOffset > manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunk ranges exceed the bundle");
|
||||
return { ...chunk };
|
||||
});
|
||||
if (nextOffset !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunks do not cover the complete bundle");
|
||||
|
||||
if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid count is outside the bounded range");
|
||||
const names = new Set<string>();
|
||||
let activeVoxels = 0;
|
||||
const grids = manifest.grids.map((grid) => {
|
||||
if (!grid.name || names.has(grid.name)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid identity is missing or duplicated");
|
||||
names.add(grid.name);
|
||||
if (!SUPPORTED_GRID_TYPES.has(grid.valueType)) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB grid ${grid.name} uses unsupported value type ${grid.valueType}`);
|
||||
if (!["FOG_VOLUME", "LEVEL_SET", "STAGGERED", "UNKNOWN"].includes(grid.gridClass) || !["DENSITY", "TEMPERATURE", "COLOR", "EMISSION", "VELOCITY", "CUSTOM"].includes(grid.semantic)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} class or semantic is invalid`);
|
||||
safeInteger(grid.activeVoxelCount, `${grid.name}.activeVoxelCount`, 0, VDB_MAX_ACTIVE_VOXELS);
|
||||
activeVoxels += grid.activeVoxelCount;
|
||||
if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "NanoVDB active voxel budget exceeded");
|
||||
safeInteger(grid.segmentByteOffset, `${grid.name}.segmentByteOffset`, 0, manifest.bundleByteLength - 1);
|
||||
safeInteger(grid.segmentByteLength, `${grid.name}.segmentByteLength`, 1, manifest.bundleByteLength);
|
||||
safeInteger(grid.byteOffset, `${grid.name}.byteOffset`, 0, manifest.bundleByteLength - 1);
|
||||
safeInteger(grid.byteLength, `${grid.name}.byteLength`, 1, manifest.bundleByteLength);
|
||||
if (grid.segmentByteOffset + grid.segmentByteLength > manifest.bundleByteLength || grid.byteOffset < grid.segmentByteOffset || grid.byteOffset + grid.byteLength > grid.segmentByteOffset + grid.segmentByteLength) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} segment or payload range is invalid`);
|
||||
validateBounds(grid.indexBounds, `NanoVDB grid ${grid.name} index`, true);
|
||||
validateBounds(grid.worldBounds, `NanoVDB grid ${grid.name} world`, false);
|
||||
if (grid.voxelSize.length !== 3 || grid.voxelSize.some((value) => !Number.isFinite(value) || value <= 0)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} voxel size is invalid`);
|
||||
if (grid.indexToWorld.length !== 16 || grid.indexToWorld.some((value) => !Number.isFinite(value))) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} transform is invalid`);
|
||||
return { ...grid, indexBounds: { min: [...grid.indexBounds.min], max: [...grid.indexBounds.max] }, worldBounds: { min: [...grid.worldBounds.min], max: [...grid.worldBounds.max] }, voxelSize: [...grid.voxelSize], indexToWorld: [...grid.indexToWorld] } as NanoVDBGridIR;
|
||||
});
|
||||
const orderedRanges = [...grids].sort((left, right) => left.segmentByteOffset - right.segmentByteOffset);
|
||||
for (let index = 1; index < orderedRanges.length; index += 1) {
|
||||
if (orderedRanges[index - 1].segmentByteOffset + orderedRanges[index - 1].segmentByteLength > orderedRanges[index].segmentByteOffset) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid segments overlap");
|
||||
}
|
||||
|
||||
const material = { ...manifest.material };
|
||||
const references: Array<[keyof NanoVDBMaterialIR, NanoVDBGridSemantic]> = [
|
||||
["densityGrid", "DENSITY"], ["temperatureGrid", "TEMPERATURE"], ["colorGrid", "COLOR"],
|
||||
["emissionGrid", "EMISSION"], ["velocityGrid", "VELOCITY"],
|
||||
];
|
||||
for (const [field, semantic] of references) {
|
||||
const gridName = material[field];
|
||||
if (typeof gridName !== "string") continue;
|
||||
const grid = grids.find((candidate) => candidate.name === gridName);
|
||||
if (!grid || grid.semantic !== semantic) fail("NANOVDB_MANIFEST_INVALID", `Material ${field} does not reference a ${semantic} grid`);
|
||||
}
|
||||
finite(material.densityScale, "material.densityScale");
|
||||
finite(material.emissionScale, "material.emissionScale");
|
||||
finite(material.temperatureScale, "material.temperatureScale");
|
||||
validateColor(material.color, "material.color");
|
||||
validateColor(material.emissionColor, "material.emissionColor");
|
||||
if (material.densityScale < 0 || material.emissionScale < 0 || material.temperatureScale < 0 || !Number.isFinite(material.anisotropy) || material.anisotropy < -0.99 || material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB material parameters are invalid");
|
||||
|
||||
const gpu = { ...manifest.gpu };
|
||||
if (gpu.representation !== "NANOVDB_STORAGE_BUFFER" || gpu.byteAlignment !== 32 || gpu.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU representation is unsupported");
|
||||
if (!Number.isSafeInteger(gpu.pageByteLength) || gpu.pageByteLength < 64 * 1024 || gpu.pageByteLength > NANOVDB_MAX_CHUNK_BYTES || gpu.pageByteLength % 32 !== 0) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU page size is invalid");
|
||||
if (!Number.isSafeInteger(gpu.maxResidentBytes) || gpu.maxResidentBytes < gpu.pageByteLength || gpu.maxResidentBytes > NANOVDB_MAX_GPU_RESIDENT_BYTES) fail("NANOVDB_GPU_BUDGET_EXCEEDED", "NanoVDB GPU resident budget is invalid");
|
||||
if (gpu.float32TreeLayout !== undefined) {
|
||||
const layout = gpu.float32TreeLayout;
|
||||
const expected: NanoVDBFloat32TreeLayoutIR = {
|
||||
gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24,
|
||||
rootDataBytes: 64, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20,
|
||||
upperNodeBytes: 270400, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256,
|
||||
lowerNodeBytes: 33856, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088,
|
||||
leafNodeBytes: 2144, leafValueMaskOffset: 16, leafValuesOffset: 96,
|
||||
};
|
||||
for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Float32 layout ${name} is unsupported`);
|
||||
}
|
||||
if (gpu.vec3fTreeLayout !== undefined) {
|
||||
const layout = gpu.vec3fTreeLayout;
|
||||
const expected: NanoVDBFloat32TreeLayoutIR = {
|
||||
gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24,
|
||||
rootDataBytes: 96, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20,
|
||||
upperNodeBytes: 532544, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256,
|
||||
lowerNodeBytes: 66624, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088,
|
||||
leafNodeBytes: 6272, leafValueMaskOffset: 16, leafValuesOffset: 128,
|
||||
};
|
||||
for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Vec3f layout ${name} is unsupported`);
|
||||
}
|
||||
|
||||
return { ...manifest, sourcePath, bundlePath, converter, chunks, grids, material, gpu };
|
||||
}
|
||||
|
||||
export function validateVDBProjectBinding(value: VDBProjectBindingIR): VDBProjectBindingIR {
|
||||
if (value.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(value.projectId)) fail("NANOVDB_MANIFEST_INVALID", "VDB project binding schema or project id is invalid");
|
||||
const sourcePath = projectPath(value.sourcePath, ".vdb", "VDB binding source");
|
||||
for (const [name, digest] of Object.entries({
|
||||
sourceBlendSha256: value.sourceBlendSha256,
|
||||
sourceSha256: value.sourceSha256,
|
||||
conversionRequestSha256: value.conversionRequestSha256,
|
||||
bundleSha256: value.bundleSha256,
|
||||
manifestSha256: value.manifestSha256,
|
||||
})) if (!SHA256_PATTERN.test(digest)) fail("NANOVDB_MANIFEST_INVALID", `VDB binding ${name} is invalid`);
|
||||
safeInteger(value.bundleByteLength, "binding.bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES);
|
||||
if (value.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "VDB binding shader semantic version is unsupported");
|
||||
if (typeof value.committedAt !== "string" || !Number.isFinite(Date.parse(value.committedAt))) fail("NANOVDB_MANIFEST_INVALID", "VDB binding commit timestamp is invalid");
|
||||
const converter = validateIdentity(value.converter);
|
||||
const synthetic: NanoVDBBundleManifestIR = {
|
||||
schemaVersion: VDB_PIPELINE_SCHEMA,
|
||||
projectId: value.projectId,
|
||||
sourcePath,
|
||||
sourceSha256: value.sourceSha256,
|
||||
conversionRequestSha256: value.conversionRequestSha256,
|
||||
bundlePath: "//cache/binding.nvdb",
|
||||
bundleByteLength: value.bundleByteLength,
|
||||
bundleSha256: value.bundleSha256,
|
||||
converter,
|
||||
grids: [{ name: value.material.densityGrid, valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0, segmentByteOffset: 0, segmentByteLength: 1, byteOffset: 0, byteLength: 1, indexBounds: { min: [0, 0, 0], max: [0, 0, 0] }, worldBounds: { min: [0, 0, 0], max: [0, 0, 0] }, voxelSize: [1, 1, 1], indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }],
|
||||
chunks: [{ index: 0, byteOffset: 0, byteLength: value.bundleByteLength, sha256: value.bundleSha256 }],
|
||||
material: { ...value.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, velocityGrid: undefined },
|
||||
gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: Math.min(NANOVDB_MAX_CHUNK_BYTES, Math.max(64 * 1024, Math.ceil(Math.min(value.bundleByteLength, NANOVDB_MAX_CHUNK_BYTES) / 32) * 32)), maxResidentBytes: NANOVDB_MAX_GPU_RESIDENT_BYTES, shaderSemanticVersion: value.shaderSemanticVersion },
|
||||
};
|
||||
// Reuse bounded scalar material checks without requiring all referenced grids in this binding record.
|
||||
finite(synthetic.material.densityScale, "binding.material.densityScale");
|
||||
finite(synthetic.material.emissionScale, "binding.material.emissionScale");
|
||||
finite(synthetic.material.temperatureScale, "binding.material.temperatureScale");
|
||||
validateColor(synthetic.material.color, "binding.material.color");
|
||||
validateColor(synthetic.material.emissionColor, "binding.material.emissionColor");
|
||||
if (synthetic.material.densityScale < 0 || synthetic.material.emissionScale < 0 || synthetic.material.temperatureScale < 0 || !Number.isFinite(synthetic.material.anisotropy) || synthetic.material.anisotropy < -0.99 || synthetic.material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(synthetic.material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "VDB binding material is invalid");
|
||||
return { ...value, sourcePath, converter, material: { ...value.material } };
|
||||
}
|
||||
|
||||
export function evaluateVDBProjectBinding(value: VDBProjectBindingIR | undefined, context: VDBProjectReopenContextIR): VDBProjectBindingStatusIR {
|
||||
if (!value) return { status: "BLOCKED", code: "VDB_BINDING_MISSING", message: "The project has no committed NanoVDB binding" };
|
||||
const binding = validateVDBProjectBinding(value);
|
||||
if (binding.projectId !== context.projectId || binding.sourcePath !== projectPath(context.sourcePath, ".vdb", "VDB reopen source") || binding.sourceBlendSha256 !== context.sourceBlendSha256 || binding.sourceSha256 !== context.sourceSha256) {
|
||||
return { status: "BLOCKED", code: "VDB_SOURCE_CHANGED", message: "The blend or VDB source changed after conversion" };
|
||||
}
|
||||
const converter = validateIdentity(context.converter);
|
||||
if (serializeIdentity(binding.converter) !== serializeIdentity(converter)) return { status: "BLOCKED", code: "VDB_CONVERTER_CHANGED", message: "The VDB converter identity changed" };
|
||||
if (binding.shaderSemanticVersion !== context.shaderSemanticVersion) return { status: "BLOCKED", code: "VOLUME_SHADER_UNAVAILABLE", message: "The volume shader semantic version changed" };
|
||||
return { status: "READY" };
|
||||
}
|
||||
|
||||
function serializeIdentity(value: VDBConverterIdentityIR): string {
|
||||
return `${value.target}\n${value.blenderVersion}\n${value.openVDBVersion}\n${value.nanoVDBVersion}\n${value.executableSha256}`;
|
||||
}
|
||||
|
||||
export function planNanoVDBRanges(value: NanoVDBBundleManifestIR): NanoVDBRangeIR[] {
|
||||
const manifest = validateNanoVDBBundleManifest(value);
|
||||
return manifest.chunks.map((chunk) => ({ chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 }));
|
||||
}
|
||||
|
||||
export async function verifyNanoVDBChunk(chunk: NanoVDBChunkIR, data: ArrayBuffer): Promise<void> {
|
||||
if (!(data instanceof ArrayBuffer) || data.byteLength !== chunk.byteLength) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${chunk.index} byte length is incomplete`);
|
||||
if (await sha256(data) !== chunk.sha256) fail("NANOVDB_HASH_MISMATCH", `NanoVDB chunk ${chunk.index} SHA-256 mismatch`);
|
||||
}
|
||||
|
||||
export async function verifyNanoVDBBundle(manifestValue: NanoVDBBundleManifestIR, data: ArrayBuffer): Promise<void> {
|
||||
const manifest = validateNanoVDBBundleManifest(manifestValue);
|
||||
if (!(data instanceof ArrayBuffer) || data.byteLength !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB bundle byte length is incomplete");
|
||||
if (await sha256(data) !== manifest.bundleSha256) fail("NANOVDB_HASH_MISMATCH", "NanoVDB bundle SHA-256 mismatch");
|
||||
}
|
||||
|
||||
export function gateNanoVDBPipeline(stage: NanoVDBPipelineStage, context: NanoVDBPipelineContext = {}): CapabilityGateResult {
|
||||
if (stage === "RAW_VDB_BROWSER_DECODE") {
|
||||
return blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERSION_REQUIRED", "Raw OpenVDB must be converted by the desktop or server OpenVDB toolchain; browser decoding is intentionally unavailable")]);
|
||||
}
|
||||
if (stage === "DESKTOP_CONVERSION") {
|
||||
return context.desktopConverterConfigured
|
||||
? readyGate("N-015", stage)
|
||||
: blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The desktop OpenVDB to NanoVDB converter is not configured")]);
|
||||
}
|
||||
if (stage === "SERVER_CONVERSION") {
|
||||
return context.serverConverterConfigured
|
||||
? readyGate("N-015", stage)
|
||||
: blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The server OpenVDB to NanoVDB job endpoint is not configured")]);
|
||||
}
|
||||
if (stage === "NANOVDB_STREAM") {
|
||||
return context.manifestValidated && context.rangeReaderAvailable
|
||||
? readyGate("N-015", stage)
|
||||
: blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "A validated NanoVDB manifest and bounded range reader are required")]);
|
||||
}
|
||||
if (!context.webgpuAvailable) return blockedGate("N-015", stage, [capabilityIssue("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable in this browser or device")]);
|
||||
if (!context.manifestValidated || !context.rangeReaderAvailable) return blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "Volume rendering requires a validated and readable NanoVDB stream")]);
|
||||
return context.volumeRendererAvailable
|
||||
? readyGate("N-015", stage)
|
||||
: blockedGate("N-015", stage, [capabilityIssue("VOLUME_SHADER_UNAVAILABLE", "The NanoVDB WGSL traversal and volume material renderer have not been installed")]);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "setFontProperties"; dataId: string; properties: Partial<NonMeshFontPropertiesIR> }
|
||||
| { type: "setFontAdvanced"; dataId: string; characters: NonMeshFontCharacterIR[]; textBoxes: NonMeshFontTextBoxIR[]; activeTextBox: number }
|
||||
| { type: "setFontLinks"; dataId: string; links: NonMeshFontLinksIR }
|
||||
| { type: "setVolumeProperties"; dataId: string; sourcePath: string; displayDensity: number; interpolation: "NEAREST" | "LINEAR"; stepSize: number; velocityGrid?: string; velocityScale?: number }
|
||||
| { type: "createGreasePencilLayer"; dataId: string; name: string }
|
||||
| { type: "removeGreasePencilLayer"; dataId: string; layerId: string }
|
||||
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" }
|
||||
|
||||
37
web/tests/e2e/device-loss.spec.ts
Normal file
37
web/tests/e2e/device-loss.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
test("recovers the main-thread Chromium viewport after a real WebGL context loss", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-device-status", "ready");
|
||||
const result = await canvas.evaluate(async (element) => {
|
||||
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
|
||||
const extension = gl?.getExtension("WEBGL_lose_context");
|
||||
if (!gl || !extension) return { supported: false, pixels: 0 };
|
||||
const waitFor = (status: string): Promise<void> => new Promise((resolve, reject) => {
|
||||
const started = performance.now();
|
||||
const poll = (): void => {
|
||||
if (element.dataset.deviceStatus === status) { resolve(); return; }
|
||||
if (performance.now() - started > 10_000) { reject(new Error(`Timed out waiting for device status ${status}`)); return; }
|
||||
requestAnimationFrame(poll);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
extension.loseContext();
|
||||
await waitFor("lost");
|
||||
extension.restoreContext();
|
||||
await waitFor("ready");
|
||||
const pixels = new Uint8Array(16 * 16 * 4);
|
||||
gl.readPixels(0, 0, 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
return { supported: true, pixels: pixels.reduce((total, value) => total + (value > 0 ? 1 : 0), 0) };
|
||||
});
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.pixels).toBeGreaterThan(0);
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)");
|
||||
});
|
||||
29
web/tests/e2e/network-interruption.spec.ts
Normal file
29
web/tests/e2e/network-interruption.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
test("keeps Main edit and save-reopen available during a Chromium network interruption", async ({ context, page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 20_000 });
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
||||
|
||||
await context.setOffline(true);
|
||||
try {
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)");
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("blender-web.blend");
|
||||
const savedPath = await download.path();
|
||||
expect(savedPath).not.toBeNull();
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", savedPath!);
|
||||
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r3 (4 objects)");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
}
|
||||
finally {
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
110
web/tests/e2e/simulation-cache-performance.spec.ts
Normal file
110
web/tests/e2e/simulation-cache-performance.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("meets the Chromium OPFS Simulation cache playback performance gate", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
|
||||
const frameCount = 600;
|
||||
const frameBytes = 88;
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => {
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const payload = new ArrayBuffer(frameCount * frameBytes);
|
||||
const payloadBytes = new Uint8Array(payload);
|
||||
const objectId = new TextEncoder().encode("object:CacheTarget");
|
||||
for (let frame = 1; frame <= frameCount; frame += 1) {
|
||||
const offset = (frame - 1) * frameBytes;
|
||||
const view = new DataView(payload, offset, frameBytes);
|
||||
view.setUint32(0, 0x31465442, true);
|
||||
view.setUint16(4, 1, true);
|
||||
view.setUint16(6, 16, true);
|
||||
view.setInt32(8, frame, true);
|
||||
view.setUint32(12, 1, true);
|
||||
view.setUint8(16, objectId.length);
|
||||
payloadBytes.set(objectId, offset + 17);
|
||||
[frame / 10, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
|
||||
}
|
||||
const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({
|
||||
frame: index + 1,
|
||||
byteOffset: index * frameBytes,
|
||||
byteLength: frameBytes,
|
||||
sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)),
|
||||
})));
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer;
|
||||
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
graphId: "geometry-node-tree:simulation-performance",
|
||||
graphHash: fixedHash,
|
||||
sourceBlendSha256: await digest(sourceBlend),
|
||||
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
};
|
||||
const projectId = `simulation-performance-${Date.now()}`;
|
||||
const started = performance.now();
|
||||
const writer = new StorageClient();
|
||||
const saved = await writer.saveProject(projectId, 1, sourceBlend.slice(0));
|
||||
const stored = await writer.putSimulationCache(projectId, manifest, payload);
|
||||
writer.terminate();
|
||||
const storedAt = performance.now();
|
||||
|
||||
const reader = new StorageClient();
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const scene = {
|
||||
schemaVersion: 1 as const,
|
||||
revision: 1,
|
||||
sceneId: "scene:CachePerformance",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
activeObjectId: "object:CacheTarget",
|
||||
frame: { current: 1, start: 1, end: frameCount },
|
||||
nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }],
|
||||
meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
|
||||
};
|
||||
let publishedFrames = 0;
|
||||
let lastTranslation = 0;
|
||||
const playback = new BrowserTransformCachePlaybackSession(scene, {
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
readFrame: async (frame, signal) => {
|
||||
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
|
||||
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame);
|
||||
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
|
||||
return read.data;
|
||||
},
|
||||
}, (preview) => {
|
||||
publishedFrames += 1;
|
||||
lastTranslation = preview.nodes[0].transform.translation[0];
|
||||
});
|
||||
const playbackResult = await playback.play();
|
||||
const finished = performance.now();
|
||||
reader.terminate();
|
||||
return {
|
||||
backend: saved.backend,
|
||||
frameCount,
|
||||
byteLength: manifest.byteLength,
|
||||
status: playbackResult.status,
|
||||
appliedFrames: playbackResult.appliedFrames,
|
||||
lastFrame: playbackResult.lastFrame,
|
||||
publishedFrames,
|
||||
lastTranslation,
|
||||
storeMs: Math.round(storedAt - started),
|
||||
playbackMs: Math.round(finished - storedAt),
|
||||
elapsedMs: Math.round(finished - started),
|
||||
};
|
||||
});
|
||||
expect(result.backend).toBe("opfs");
|
||||
expect(result).toMatchObject({ frameCount: 600, byteLength: 52_800, status: "COMPLETED", appliedFrames: 600, lastFrame: 600, publishedFrames: 600 });
|
||||
expect(result.lastTranslation).toBeCloseTo(60, 5);
|
||||
expect(result.storeMs).toBeLessThan(30_000);
|
||||
expect(result.playbackMs).toBeLessThan(30_000);
|
||||
expect(result.elapsedMs).toBeLessThan(30_000);
|
||||
});
|
||||
@@ -7,6 +7,9 @@ const animationBlend = path.resolve(import.meta.dirname, "../../../tests/files/w
|
||||
const riggedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/rigged_shape_scene.blend");
|
||||
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
const greasePencilBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const compositorBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/compositor_scene.blend");
|
||||
const sequencerBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/sequencer_scene.blend");
|
||||
const maskBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/mask_scene.blend");
|
||||
const localTexturePng = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png");
|
||||
const deformationGolden = path.resolve(import.meta.dirname, "../../../tests/golden/W-079/blender-deformation.json");
|
||||
|
||||
@@ -298,7 +301,9 @@ test("reads bounded non-mesh data blocks and previews supported geometry", async
|
||||
await expect(page.getByText("WebVolumeObject", { exact: true })).toBeVisible();
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6");
|
||||
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
|
||||
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "1");
|
||||
await expect(canvas).toHaveAttribute("data-volume-status", "blocked");
|
||||
await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING");
|
||||
const renderedPixels = await canvas.evaluate((element) => {
|
||||
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
|
||||
if (!gl) return 0;
|
||||
@@ -518,6 +523,45 @@ test("validates the N-016 Grease Pencil editor context transaction boundary", as
|
||||
expect(result.missingPoint).toContain("GREASE_PENCIL_EDITOR_INVALID");
|
||||
});
|
||||
|
||||
test("raycasts and highlights N-016 Grease Pencil points with stable drawing identity", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/grease-pencil-viewport-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, strokeIndex: 0, pointIndex: 1 });
|
||||
expect(result.selectedColor).toEqual([expect.closeTo(1), expect.closeTo(0.38), expect.closeTo(0.08)]);
|
||||
expect(result.preview).toEqual([[0.5, 2, -1], [0.5, 2, -1]]);
|
||||
expect(result.restored).toEqual([[0, 0, -0], [0, 0, -0]]);
|
||||
expect(result.proxyCount).toBe(1);
|
||||
});
|
||||
|
||||
for (const offscreen of [false, true]) test(`previews N-016 Grease Pencil points and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
|
||||
await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
await page.getByRole("button", { name: "Select All" }).click();
|
||||
await expect(page.getByText(/\d+ vert selected/)).toBeVisible();
|
||||
const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1");
|
||||
const axis = page.getByRole("button", { name: "X 轴变换手柄" });
|
||||
const bounds = await axis.boundingBox();
|
||||
if (!bounds) throw new Error("Grease Pencil gizmo X axis is unavailable");
|
||||
const x = bounds.x + bounds.width / 2;
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + 24, y, { steps: 3 });
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-preview", /[1-9]\d*/);
|
||||
await page.mouse.up();
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-preview", "0");
|
||||
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
|
||||
});
|
||||
|
||||
test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
|
||||
@@ -533,6 +577,21 @@ test("edits N-016 Grease Pencil layers and frames from the bounded editor panel"
|
||||
await expect(editor).toContainText("2 layers / 2 frames / 1 strokes");
|
||||
});
|
||||
|
||||
test("navigates real N-016 Grease Pencil drawing frames in the bounded Dope Sheet", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
const dopeSheet = page.getByLabel("Dope Sheet");
|
||||
await expect(dopeSheet).toContainText("GreasePencilData");
|
||||
await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true })).toBeVisible();
|
||||
await page.getByLabel("当前帧").fill("12");
|
||||
await expect(page.locator("output.frame-number")).toHaveText("12");
|
||||
await page.getByTestId("grease-pencil-editor").getByRole("button", { name: "Add Frame" }).click();
|
||||
await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 12" })).toBeVisible({ timeout: 20_000 });
|
||||
await dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true }).click();
|
||||
await expect(page.locator("output.frame-number")).toHaveText("1");
|
||||
});
|
||||
|
||||
test("moves an N-016 Grease Pencil point through one revision-bound Main transaction", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
|
||||
@@ -544,6 +603,48 @@ test("moves an N-016 Grease Pencil point through one revision-bound Main transac
|
||||
await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1, 0, 0", { timeout: 20_000 });
|
||||
});
|
||||
|
||||
test("reopens an edited N-016 Grease Pencil drawing after WebEngine Worker restart", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(greasePencilBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const first = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
const opened = await first.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const data = opened.snapshot.greasePencils?.[0];
|
||||
const layer = data?.layers[0];
|
||||
const drawingFrame = layer?.frames[0];
|
||||
if (!data || !layer || !drawingFrame) throw new Error("Grease Pencil fixture is incomplete");
|
||||
const strokes = drawingFrame.drawing.strokes.map((stroke, strokeIndex) => ({
|
||||
cyclic: stroke.cyclic,
|
||||
materialIndex: stroke.materialIndex,
|
||||
points: (stroke.points ?? []).map((point, pointIndex) => ({
|
||||
...point,
|
||||
position: strokeIndex === 0 && pointIndex === 0 ? [point.position[0] + 0.25, point.position[1], point.position[2]] as [number, number, number] : [...point.position] as [number, number, number],
|
||||
})),
|
||||
}));
|
||||
const edited = await first.applyCommand({ type: "setGreasePencilStrokes", dataId: data.id, layerId: layer.id, frame: drawingFrame.frame, baseRevision: opened.snapshot.revision, strokes });
|
||||
const saved = await first.saveBlend();
|
||||
first.terminate();
|
||||
const restarted = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
const reopened = await restarted.openBlend(saved);
|
||||
restarted.terminate();
|
||||
const reopenedData = reopened.snapshot.greasePencils?.find((candidate) => candidate.id === data.id);
|
||||
const point = reopenedData?.layers.find((candidate) => candidate.id === layer.id)?.frames.find((candidate) => candidate.frame === drawingFrame.frame)?.drawing.strokes[0]?.points?.[0];
|
||||
return {
|
||||
revision: edited.snapshot.revision,
|
||||
identity: [reopenedData?.id, reopenedData?.layers[0]?.id, reopenedData?.layers[0]?.frames[0]?.drawing.id],
|
||||
position: point?.position,
|
||||
radius: point?.radius,
|
||||
opacity: point?.opacity,
|
||||
};
|
||||
}, new Uint8Array(bytes));
|
||||
expect(result.revision).toBeGreaterThan(0);
|
||||
expect(result.identity).toEqual(["grease-pencil:GreasePencilData", "grease-pencil-layer:GreasePencilData:Lines", "grease-pencil-drawing:GreasePencilData:0"]);
|
||||
expect(result.position).toEqual([-1.25, 0, 0]);
|
||||
expect(result.radius).toBeGreaterThan(0);
|
||||
expect(result.opacity).toBeCloseTo(0.9);
|
||||
});
|
||||
|
||||
test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
@@ -557,6 +658,19 @@ test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", as
|
||||
expect(result.hit).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED");
|
||||
expect(result.brush).toEqual([{ index: 1, weight: 0.4 }, { index: 2, weight: 0.8 }]);
|
||||
expect(result.spatialBrush).toEqual([16, 64, [450, 549, 550, 551, 650]]);
|
||||
expect(result.selectedMasked).toEqual([{ index: 450, weight: expect.closeTo(0.005823, 5) }, { index: 550, weight: 0.5 }]);
|
||||
expect(result.spatialVisibility).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.spatialSelection).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.selectionDuplicate).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.maskInvalid).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.selectionUnknown).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.spatialForgery).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect(result.spatialMutation).toBe(16);
|
||||
expect(result.weightPatch).toMatchObject({ indices: [0, 2], values: [1, 0.7], normalize: false });
|
||||
expect(result.colorPatch).toEqual({ indices: [0, 2], colors: [0, 1, 0, 0.5, 0.75, 0.25, 0, 0.875] });
|
||||
expect(result.patchRevision).toContain("REVISION_CONFLICT");
|
||||
expect(result.patchIdentity).toContain("PAINT_SCHEMA_INVALID");
|
||||
expect((result.udim as number[]).slice(4, 8)).toEqual([10, 20, 30, 255]);
|
||||
expect(result.udimRevision).toContain("REVISION_CONFLICT");
|
||||
expect(result.udimStale).toContain("PAINT_TILE_HASH_MISMATCH");
|
||||
@@ -607,6 +721,28 @@ test("commits N-017 vertex color and weight patches from the bounded paint panel
|
||||
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 });
|
||||
});
|
||||
|
||||
test("blends N-017 selection-masked color and weight patches through one Main transaction each", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
|
||||
await page.getByText("AttributeMeshObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: /Object Mode/ }).click();
|
||||
await page.getByRole("button", { name: "1 Vertex" }).click();
|
||||
await page.getByRole("button", { name: "Select All" }).click();
|
||||
const editor = page.getByTestId("paint-editor");
|
||||
await editor.getByLabel("Paint selection mask").fill("0.5");
|
||||
await editor.getByLabel("Paint vertex color").fill("#0080ff");
|
||||
let revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1");
|
||||
await editor.getByRole("button", { name: "Blend Color" }).click();
|
||||
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
|
||||
await expect(editor.getByTestId("paint-color-attribute")).toHaveText("WebPaintColor POINT");
|
||||
revision += 1;
|
||||
await editor.getByLabel("Paint vertex group").fill("SelectionMaskPaint");
|
||||
await editor.getByLabel("Paint vertex weight").fill("0.8");
|
||||
await editor.getByRole("button", { name: "Blend Weight" }).click();
|
||||
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
|
||||
await expect(editor.getByTestId("paint-vertex-group")).toHaveText("SelectionMaskPaint");
|
||||
});
|
||||
|
||||
test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
@@ -625,7 +761,12 @@ test("validates the N-018 physics capability and cache manifests without claimin
|
||||
expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE");
|
||||
expect(result.manifest).toBe("READY");
|
||||
expect(result.browserPlayback).toEqual([7, "object:Cloth", [1, 2, 3]]);
|
||||
expect(result.browserPreview).toEqual([7, [1, 2, 3], [1, 2, 3]]);
|
||||
expect(result.browserFrameMismatch).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
|
||||
expect(result.browserRotation).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
|
||||
expect(result.browserSession).toEqual(["COMPLETED", 2, 8, [7, 8]]);
|
||||
expect(result.browserSupersede).toEqual([true, 8, [8]]);
|
||||
expect(result.browserCancel).toEqual([true, []]);
|
||||
});
|
||||
|
||||
test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => {
|
||||
@@ -637,7 +778,7 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W
|
||||
await expect(canvas).toHaveAttribute("data-view-look", "None");
|
||||
await expect(canvas).toHaveAttribute("data-mist", "disabled");
|
||||
const mapping = await page.evaluate(async () => {
|
||||
const { blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
|
||||
const { blenderLightColor, blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
|
||||
const { Object3D } = await import("/src/vendor/three/three.module.js");
|
||||
const definition = {
|
||||
id: "light:test", name: "Test", lightType: 0, color: [1, 1, 1], energy: 100, exposure: 2,
|
||||
@@ -650,9 +791,44 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W
|
||||
selectable: true, localMatrix: new Array(16).fill(0), worldMatrix: new Array(16).fill(0),
|
||||
transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 },
|
||||
}, new Object3D());
|
||||
return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow };
|
||||
const warmDefinition = { ...definition, color: [1, 1, 1] as [number, number, number], useTemperature: true, temperature: 5000 };
|
||||
const neutralDefinition = { ...warmDefinition, temperature: 6500 };
|
||||
const disabledDefinition = { ...warmDefinition, color: [0.25, 0.5, 0.75] as [number, number, number], useTemperature: false };
|
||||
return {
|
||||
intensity: blenderLightIntensity(definition),
|
||||
castShadow: light.castShadow,
|
||||
sourceShadow: light.userData.blenderCastsShadow,
|
||||
warm: blenderLightColor(warmDefinition),
|
||||
neutral: blenderLightColor(neutralDefinition),
|
||||
disabled: blenderLightColor(disabledDefinition),
|
||||
appliedWarm: createPBRLight(warmDefinition).color.toArray(),
|
||||
};
|
||||
});
|
||||
expect(mapping).toEqual({ intensity: 40, castShadow: false, sourceShadow: false });
|
||||
expect(mapping.intensity).toBe(40);
|
||||
expect(mapping.castShadow).toBe(false);
|
||||
expect(mapping.sourceShadow).toBe(false);
|
||||
expect(mapping.neutral).toEqual([1, 1, 1]);
|
||||
expect(mapping.disabled).toEqual([0.25, 0.5, 0.75]);
|
||||
expect(mapping.warm[0]).toBe(1);
|
||||
expect(mapping.warm[1]).toBeGreaterThan(0.7);
|
||||
expect(mapping.warm[1]).toBeLessThan(0.9);
|
||||
expect(mapping.warm[2]).toBeGreaterThan(0.5);
|
||||
expect(mapping.warm[2]).toBeLessThan(0.75);
|
||||
expect(mapping.appliedWarm).toEqual(mapping.warm);
|
||||
});
|
||||
|
||||
test("preserves N-019 World and Scene color management in renderer-bound SceneDelta", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/scene-delta-render-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.collections).toEqual([1, 1]);
|
||||
expect(result.applied).toEqual([[0.8, 0.4, 0.2], 2, "Standard", 1]);
|
||||
expect(result.rebuild).toBe(true);
|
||||
expect(result.invalid).toContain("SceneDelta.worlds is invalid");
|
||||
});
|
||||
|
||||
test("executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates", async ({ page }) => {
|
||||
@@ -670,6 +846,34 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes
|
||||
expect(result.unsupported).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
expect(result.budget).toContain("COMPOSITOR_BUDGET_EXCEEDED");
|
||||
expect(result.cancelled).toContain("COMPOSITOR_CANCELLED");
|
||||
expect(result.cache).toEqual([false, true, false, true, true, 0.25, 2, 256]);
|
||||
});
|
||||
|
||||
test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 graph", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { executeCompositorGraph, gateCompositorGraph } = await import("/src/compositor/CompositorExecutor.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "CompositorScene");
|
||||
if (!scene?.compositorGraph) throw new Error("Real compositor graph is missing");
|
||||
const execution = executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 });
|
||||
return {
|
||||
status: scene.compositorStatus,
|
||||
pixel: Array.from(execution.composite.data),
|
||||
evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name),
|
||||
gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code,
|
||||
};
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, new Uint8Array(bytes));
|
||||
expect(result.status).toBe("AVAILABLE");
|
||||
expect(result.pixel).toEqual([0.75, 0.5, 0, 0.75]);
|
||||
expect(result.evaluated).toEqual(["WebConstantColor", "WebExposure", "WebInvert", "WebComposite"]);
|
||||
expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
});
|
||||
|
||||
test("validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates", async ({ page }) => {
|
||||
@@ -682,14 +886,39 @@ test("validates N-021 sequencer strips, deterministic edits, sandbox paths and c
|
||||
}));
|
||||
expect(result.valid).toBe(105);
|
||||
expect(result.edit).toEqual([5, [["strip:Movie", 15, 20, 100, 105], ["strip:MovieRight", 20, 25, 105, 110]]]);
|
||||
expect(result.frame).toEqual([["strip:MovieRight", 3, 105]]);
|
||||
expect(result.revision).toContain("REVISION_CONFLICT");
|
||||
expect(result.path).toContain("SEQUENCER_RESOURCE_OUTSIDE_PROJECT");
|
||||
expect(result.cycle).toContain("SEQUENCER_DEPENDENCY_CYCLE");
|
||||
expect(result.budget).toContain("SEQUENCER_BUDGET_EXCEEDED");
|
||||
expect(result.codec).toBe("SEQUENCER_CODEC_UNSUPPORTED");
|
||||
expect(result.transition).toEqual(["CROSS", 0.5, "strip:From", 105, "strip:To", 205]);
|
||||
expect(result.transitionBoundary).toContain("SEQUENCER_SCHEMA_INVALID");
|
||||
expect((result.runtime as { localEncoding: string }).localEncoding).toBe("BLOCKED");
|
||||
});
|
||||
|
||||
test("resolves the N-021 transition frame from a real Blender 5.2 sequencer", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(sequencerBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { resolveSequencerTransitionFrame } = await import("/src/sequencer/SequencerTimeline.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "SequencerScene");
|
||||
const timeline = scene?.sequencerTimeline;
|
||||
const effect = timeline?.strips.find((strip) => strip.name === "WebCross");
|
||||
if (!timeline || !effect) throw new Error("Real sequencer transition is missing");
|
||||
const transition = resolveSequencerTransitionFrame(timeline, effect.id, 22);
|
||||
const names = new Map(timeline.strips.map((strip) => [strip.id, strip.name]));
|
||||
return { status: scene.sequencerStatus, type: transition.effectType, factor: transition.factor, from: [names.get(transition.from.stripId), transition.from.sourceFrame], to: [names.get(transition.to.stripId), transition.to.sourceFrame] };
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, new Uint8Array(bytes));
|
||||
expect(result).toEqual({ status: "AVAILABLE", type: "CROSS", factor: 0.5, from: ["WebImage", 1], to: ["WebImageB", 1] });
|
||||
});
|
||||
|
||||
test("validates N-022 tracking markers, masks, resource bindings and solve gates", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
@@ -699,6 +928,7 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.edit).toEqual([6, [1, 10], [0.4, 0.6]]);
|
||||
expect(result.raycast).toMatchObject({ maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", kind: "POINT", pointId: "point:1", distance: 0 });
|
||||
expect(result.revision).toContain("REVISION_CONFLICT");
|
||||
expect(result.path).toContain("TRACKING_RESOURCE_OUTSIDE_PROJECT");
|
||||
expect(result.binding).toContain("TRACKING_BINDING_MISSING");
|
||||
@@ -708,6 +938,34 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates
|
||||
expect(result.solveGate).toBe("BLOCKED");
|
||||
});
|
||||
|
||||
test("raycasts and marquee-selects editable N-022 points from a real Blender 5.2 Mask", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(maskBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { raycastMaskProject, selectMaskPointsInBounds } = await import("/src/tracking/MaskSelection.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const project = opened.snapshot.trackingMasks;
|
||||
if (!project) throw new Error("Real Mask project is missing");
|
||||
const locked = raycastMaskProject(project, [0.1, 0.2], 0.001);
|
||||
const editable = raycastMaskProject(project, [0.2, 0.2], 0.001);
|
||||
const selected = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25]);
|
||||
const toggled = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25], selected, "TOGGLE");
|
||||
const layerNames = new Map(project.masks[0].layers.map((layer) => [layer.id, layer.name]));
|
||||
return { status: opened.snapshot.trackingMaskStatus, locked, editable: editable && { ...editable, layerName: layerNames.get(editable.layerId) }, selected: selected.map((item) => [layerNames.get(item.layerId), item.pointId]), toggled };
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, new Uint8Array(bytes));
|
||||
expect(result.status).toBe("AVAILABLE");
|
||||
expect(result.locked).toBeNull();
|
||||
expect(result.editable).toMatchObject({ kind: "POINT", layerName: "WebEditableLayer", pointId: "mask-point:1:0:0" });
|
||||
expect(result.editable?.distance).toBeLessThan(1e-7);
|
||||
expect(result.selected).toEqual([["WebEditableLayer", "mask-point:1:0:0"]]);
|
||||
expect(result.toggled).toEqual([]);
|
||||
});
|
||||
|
||||
test("validates N-023 asset catalogs, library graphs, archive budgets and IO gates", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
@@ -721,11 +979,19 @@ test("validates N-023 asset catalogs, library graphs, archive budgets and IO gat
|
||||
expect(result.license).toContain("ASSET_LICENSE_MISSING");
|
||||
expect(result.cycle).toContain("LIBRARY_DEPENDENCY_CYCLE");
|
||||
expect(result.archive).toContain("IO_ARCHIVE_UNSAFE");
|
||||
expect(result.archivePath).toContain("IO_ARCHIVE_UNSAFE");
|
||||
expect(result.archiveLength).toContain("IO_ARCHIVE_UNSAFE");
|
||||
expect(result.archivePlan).toEqual([
|
||||
{ path: "a.bin", compressedBytes: 2, uncompressedBytes: 2, compressedOffset: 0 },
|
||||
{ path: "z.bin", compressedBytes: 3, uncompressedBytes: 4, compressedOffset: 2 },
|
||||
]);
|
||||
expect(result.uri).toContain("IO_EXTERNAL_URI_BLOCKED");
|
||||
expect(result.glb).toBe("READY");
|
||||
expect(result.obj).toBe("IO_FORMAT_UNSUPPORTED");
|
||||
expect(result.library).toBe("BLOCKED");
|
||||
expect((result.storage as { contentAddressedIndex: string }).contentAddressedIndex).toBe("LOCAL_BOUNDED");
|
||||
expect(result.preview).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.previewSize).toContain("ASSET_MANIFEST_INVALID");
|
||||
});
|
||||
|
||||
test("validates N-024 editor context, selection sync, layout budgets and workflow gates", async ({ page }) => {
|
||||
@@ -743,6 +1009,9 @@ test("validates N-024 editor context, selection sync, layout budgets and workflo
|
||||
expect(result.view).toBe("READY");
|
||||
expect(result.writer).toBe("EDITOR_WRITER_UNAVAILABLE");
|
||||
expect(result.gizmo).toBe("EDITOR_GIZMO_UNAVAILABLE");
|
||||
expect(result.keymap).toBe("object.delete");
|
||||
expect(result.keymapConflict).toContain("EDITOR_KEYMAP_INVALID");
|
||||
expect(result.scopedKeymap).toEqual(["view.command", "timeline.command"]);
|
||||
});
|
||||
|
||||
test("validates N-025 script policy, signatures, budgets and platform gates", async ({ page }) => {
|
||||
@@ -755,6 +1024,11 @@ test("validates N-025 script policy, signatures, budgets and platform gates", as
|
||||
}));
|
||||
expect(result.valid).toBe("scripts/clean.py");
|
||||
expect(result.exec).toBe("SCRIPT_SANDBOX_UNAVAILABLE");
|
||||
expect(result.audit).toEqual(["DENY", "SCRIPT_SANDBOX_UNAVAILABLE", true, ["READ_MAIN"], 1000, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]);
|
||||
expect(result.auditLog).toEqual([2, null, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]);
|
||||
expect(result.auditReplay).toContain("SCRIPT_MANIFEST_INVALID");
|
||||
expect(result.auditTamper).toContain("SCRIPT_MANIFEST_INVALID");
|
||||
expect(result.auditDate).toContain("SCRIPT_MANIFEST_INVALID");
|
||||
expect(result.server).toBe("SERVER_JOB_UNAVAILABLE");
|
||||
expect(result.path).toContain("SCRIPT_MANIFEST_INVALID");
|
||||
expect(result.policy).toContain("SCRIPT_POLICY_DENIED");
|
||||
@@ -777,6 +1051,10 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a
|
||||
expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE");
|
||||
expect(result.status).toContain("RELEASE_MANIFEST_INVALID");
|
||||
expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING");
|
||||
expect(result.excludedOverlap).toContain("RELEASE_MANIFEST_INVALID");
|
||||
expect(result.disabledEvidence).toContain("RELEASE_MANIFEST_INVALID");
|
||||
expect(result.emptyArtifact).toContain("RELEASE_MANIFEST_INVALID");
|
||||
expect(result.generatedAt).toContain("RELEASE_MANIFEST_INVALID");
|
||||
});
|
||||
|
||||
test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => {
|
||||
@@ -797,7 +1075,7 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
|
||||
expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]);
|
||||
expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]);
|
||||
expect(result.migrated).toEqual([2, "mesh:1"]);
|
||||
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
|
||||
expect(result.gates).toEqual(["READY", "READY", "READY"]);
|
||||
});
|
||||
|
||||
test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => {
|
||||
@@ -808,23 +1086,324 @@ test("validates the N-015 curve gizmo interaction transaction boundary", async (
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.preview).toEqual([3, [1.25, 2, 3]]);
|
||||
expect(result.commit).toEqual([4, [1.25, 2, 3]]);
|
||||
expect(result.rendererPreview).toEqual([[ -0.25, -0.25 ], -0.25, -0.5]);
|
||||
expect(result.localFrame).toEqual([[0, 1, 0], [[0, 1, 0], [-1, 0, 0], [0, -0, 1]], [0, 1.25, 0]]);
|
||||
expect(result.stale).toContain("REVISION_CONFLICT");
|
||||
expect(result.duplicate).toContain("CURVE_GIZMO_INVALID");
|
||||
expect(result.axis).toContain("CURVE_GIZMO_INVALID");
|
||||
});
|
||||
|
||||
test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => {
|
||||
for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend);
|
||||
await expect(page.getByText("WebCurveObject", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByText("WebCurveObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend));
|
||||
const handle = await page.evaluate((input) => new Promise<[number, number, number]>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.kind !== "result" || event.data.requestId !== "curve-handle-open") return;
|
||||
worker.terminate();
|
||||
if (!event.data.ok) { reject(new Error(event.data.error?.message ?? "Curve fixture open failed")); return; }
|
||||
const curve = event.data.result?.snapshot?.nonMeshData?.find((item: { id: string }) => item.id === "curve:WebCurveData");
|
||||
if (!curve?.handlePoints?.length) { reject(new Error("Curve handle metadata is missing")); return; }
|
||||
resolve(curve.handlePoints.slice(0, 3));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
|
||||
worker.postMessage({ requestId: "curve-handle-open", command: { type: "openBlend", buffer } }, [buffer]);
|
||||
}), new Uint8Array(bytes));
|
||||
const hit = await page.locator("canvas.viewport-canvas").evaluate(async (canvas, input) => {
|
||||
const { PerspectiveCamera, Vector3 } = await import("/src/vendor/three/three.module.js");
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const camera = new PerspectiveCamera(45, bounds.width / bounds.height, 0.01, 1000);
|
||||
if (input.offscreen) camera.position.set(7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), 7 * Math.sin(0.55));
|
||||
else camera.position.set(4.5, -4.5, 3.5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
const projected = new Vector3(input.position[0], input.position[2], -input.position[1]).project(camera);
|
||||
return { x: bounds.left + (projected.x + 1) * bounds.width / 2, y: bounds.top + (1 - projected.y) * bounds.height / 2 };
|
||||
}, { position: handle, offscreen });
|
||||
await page.mouse.click(hit.x, hit.y);
|
||||
if (!offscreen) await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-non-mesh-last-pick", /HANDLE_LEFT/);
|
||||
await expect(page.getByText("1 vert selected", { exact: true })).toBeVisible();
|
||||
const gizmo = page.getByLabel("变换 Gizmo");
|
||||
await expect(gizmo).toHaveAttribute("data-gizmo-space", "HANDLE_LOCAL");
|
||||
const localAxis = await page.getByRole("button", { name: "X 轴变换手柄" }).getAttribute("data-local-axis");
|
||||
expect(localAxis).toMatch(/^-?\d+\.\d{6},-?\d+\.\d{6},-?\d+\.\d{6}$/);
|
||||
const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1");
|
||||
const axis = page.getByRole("button", { name: "X 轴变换手柄" });
|
||||
const bounds = await axis.boundingBox();
|
||||
if (!bounds) throw new Error("Curve gizmo X axis is unavailable");
|
||||
const screenAxis = (await axis.getAttribute("data-screen-axis"))?.split(",").map(Number) ?? [];
|
||||
expect(screenAxis).toHaveLength(2);
|
||||
expect(Math.hypot(screenAxis[0], screenAxis[1])).toBeGreaterThan(0.5);
|
||||
await page.screenshot({ path: testInfo.outputPath(`curve-handle-local-${offscreen ? "offscreen" : "main"}-1440x900.png`), fullPage: true });
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 + screenAxis[0] * 24, bounds.y + bounds.height / 2 + screenAxis[1] * 24, { steps: 3 });
|
||||
await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "1");
|
||||
await page.mouse.up();
|
||||
await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "0");
|
||||
await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1);
|
||||
});
|
||||
|
||||
test("validates the VDB conversion boundary and NanoVDB streaming contract", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => {
|
||||
const result = await page.evaluate(() => new Promise<{
|
||||
preparedByteLength: number;
|
||||
conversionTarget: string;
|
||||
conversionRequestSha256: string;
|
||||
relocationKeepsContentKey: boolean;
|
||||
ranges: Array<{ chunkIndex: number; start: number; endExclusive: number }>;
|
||||
consumed: number[];
|
||||
progress: number[];
|
||||
stream: { completedChunks: number; completedBytes: number; totalBytes: number };
|
||||
httpRangeByteLength: number;
|
||||
invalidHttpRange: string;
|
||||
outsideProject: string;
|
||||
tamperedChunk: string;
|
||||
incompleteStream: string;
|
||||
cancelled: boolean;
|
||||
rawBrowserGate: { status: string; issues: Array<{ code: string }> };
|
||||
streamGate: { status: string };
|
||||
renderGate: { status: string; issues: Array<{ code: string }> };
|
||||
}>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.decodedByteLength).toBe(4);
|
||||
expect(result.preparedByteLength).toBe(64);
|
||||
expect(result.conversionTarget).toBe("SERVER");
|
||||
expect(result.conversionRequestSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.relocationKeepsContentKey).toBe(true);
|
||||
expect(result.ranges).toEqual([
|
||||
{ chunkIndex: 0, start: 0, endExclusive: 32, sha256: expect.any(String) },
|
||||
{ chunkIndex: 1, start: 32, endExclusive: 64, sha256: expect.any(String) },
|
||||
]);
|
||||
expect(result.consumed).toEqual([0, 1]);
|
||||
expect(result.progress).toEqual([32, 64]);
|
||||
expect(result.stream).toMatchObject({ completedChunks: 2, completedBytes: 64, totalBytes: 64 });
|
||||
expect(result.httpRangeByteLength).toBe(32);
|
||||
expect(result.invalidHttpRange).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(result.outsideProject).toContain("NON_MESH_RESOURCE_OUTSIDE_PROJECT");
|
||||
expect(result.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH");
|
||||
expect(result.incompleteStream).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(result.rawBrowserGate.status).toBe("BLOCKED");
|
||||
expect(result.rawBrowserGate.issues[0].code).toBe("VDB_CONVERSION_REQUIRED");
|
||||
expect(result.streamGate.status).toBe("READY");
|
||||
expect(result.renderGate.status).toBe("BLOCKED");
|
||||
expect(result.renderGate.issues[0].code).toBe("VOLUME_SHADER_UNAVAILABLE");
|
||||
});
|
||||
|
||||
test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const run = (action: "commit" | "reopen", state?: unknown) => page.evaluate(({ action, state }) => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ action, state });
|
||||
}), { action, state });
|
||||
const committed = await run("commit");
|
||||
expect(committed.committed).toMatchObject({ chunks: committed.realChunkCount, deduplicated: false });
|
||||
expect(committed.deduplicated).toBe(true);
|
||||
expect(committed.cancelled).toBe(true);
|
||||
expect(committed.recovered.removedIncompleteBundles).toBe(1);
|
||||
expect(committed.realBundleBytes).toBeGreaterThan(10_000_000);
|
||||
expect(committed.realChunkCount).toBeGreaterThan(1);
|
||||
|
||||
// A new Worker proves discovery does not depend on temporary in-memory state.
|
||||
const reopened = await run("reopen", committed.state);
|
||||
expect(reopened.bindingStatus.status).toBe("READY");
|
||||
expect(reopened.staleStatus).toMatchObject({ status: "BLOCKED", code: "VDB_SOURCE_CHANGED" });
|
||||
expect(reopened.bundleHash).toBe(reopened.expectedHash);
|
||||
expect(reopened.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH");
|
||||
expect(reopened.manifestRollback).toContain("NANOVDB_HASH_MISMATCH");
|
||||
expect(reopened.pruned).toMatchObject({ removed: [committed.state.bundleSha256], retainedBytes: 0 });
|
||||
});
|
||||
|
||||
test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-webgpu-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.capability.available).toBe(true);
|
||||
expect(result.payloadBytes).toBeGreaterThan(1_000_000);
|
||||
expect(result.cpuSamples).toHaveLength(result.nativeSamples.length);
|
||||
expect(result.gpuSamples).toHaveLength(result.nativeSamples.length);
|
||||
result.nativeSamples.forEach((sample: any, index: number) => {
|
||||
expect(result.cpuSamples[index].active).toBe(sample.active);
|
||||
expect(result.cpuSamples[index].value).toBeCloseTo(sample.value, 6);
|
||||
expect(result.gpuSamples[index].valid).toBe(true);
|
||||
expect(result.gpuSamples[index].active).toBe(sample.active);
|
||||
expect(result.gpuSamples[index].value).toBeCloseTo(sample.value, 6);
|
||||
});
|
||||
expect(result.visiblePixels).toBeGreaterThan(500);
|
||||
expect(result.alphaSum).toBeGreaterThan(10_000);
|
||||
expect(result.imageSha256).toBe("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978");
|
||||
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]);
|
||||
expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] });
|
||||
expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual([
|
||||
"VOLUME_COLOR_GRID_UNSUPPORTED",
|
||||
"VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED",
|
||||
"VOLUME_VELOCITY_RENDER_UNSUPPORTED",
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
const snapshot: any = {
|
||||
schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" },
|
||||
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
|
||||
nodes: [{
|
||||
id: "object:Volume", name: "Viewport Volume", type: "VOLUME", dataId: asset.dataId, parentId: null, visible: true,
|
||||
localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
|
||||
}],
|
||||
meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [{ id: "scene:Volume", name: "Volume" }],
|
||||
nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }],
|
||||
activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 },
|
||||
};
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 20_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("viewport volume timed out");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
};
|
||||
const createCanvas = (): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const mainCanvas = createCanvas();
|
||||
const main = new ViewportRenderer(mainCanvas);
|
||||
main.setSnapshot(snapshot);
|
||||
main.setVolumeAssets([asset]);
|
||||
await waitFor(() => mainCanvas.dataset.volumeStatus === "ready");
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
let mainVolumeObjects = 0;
|
||||
main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; });
|
||||
const mainPixels = new Uint8Array(64 * 64 * 4);
|
||||
const gl = main.renderer.getContext();
|
||||
gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels);
|
||||
const mainVisible = Array.from({ length: 64 * 64 }, (_, index) => mainPixels[index * 4 + 3] > 0 && (mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2]) > 40).filter(Boolean).length;
|
||||
main.dispose();
|
||||
mainCanvas.remove();
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(snapshot);
|
||||
offscreen.setVolumeAssets([asset]);
|
||||
await waitFor(() => offscreenCanvas.dataset.volumeStatus === "ready" && Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) };
|
||||
offscreen.dispose();
|
||||
offscreenCanvas.remove();
|
||||
return {
|
||||
payloadBytes: asset.grids[0].data.byteLength,
|
||||
main: { status: mainCanvas.dataset.volumeStatus, count: Number(mainCanvas.dataset.volumeCount), volumeObjects: mainVolumeObjects, visible: mainVisible },
|
||||
offscreen: offscreenResult,
|
||||
};
|
||||
});
|
||||
expect(result.payloadBytes).toBeGreaterThan(1_000_000);
|
||||
expect(result.main).toMatchObject({ status: "ready", count: 1, volumeObjects: 1 });
|
||||
expect(result.main.visible).toBeGreaterThan(100);
|
||||
expect(result.offscreen).toMatchObject({ status: "ready", count: 1 });
|
||||
expect(result.offscreen.visible).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test("recovers NanoVDB paging from network, Worker and WebGPU device faults", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const gpu = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-fault-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(gpu.network.attempts).toBe(3);
|
||||
expect(gpu.network.ifRanges[0]).toBe("");
|
||||
expect(gpu.network.ifRanges[2]).toMatch(/^"vdb-/);
|
||||
expect(gpu.network.resumeRanges).toHaveLength(2);
|
||||
expect(gpu.network.resumeRanges[0]).toMatch(/^bytes=\d+-\d+$/);
|
||||
const originalStart = Number(gpu.network.resumeRanges[0].match(/^bytes=(\d+)-/)?.[1]);
|
||||
const resumedStart = Number(gpu.network.resumeRanges[1].match(/^bytes=(\d+)-/)?.[1]);
|
||||
expect(resumedStart).toBe(originalStart + 4096);
|
||||
expect(gpu.network.resumeIfRanges[0]).toBe("");
|
||||
expect(gpu.network.resumeIfRanges[1]).toMatch(/^"vdb-/);
|
||||
expect(gpu.network.resumedBytes).toBe(gpu.network.firstBytes);
|
||||
expect(gpu.network.shortResponse).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(gpu.network.changedEtag).toContain("NANOVDB_HASH_MISMATCH");
|
||||
expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] });
|
||||
expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED");
|
||||
expect(gpu.paging.pageCount).toBeGreaterThan(1);
|
||||
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
|
||||
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
|
||||
expect(gpu.samplesStable).toBe(true);
|
||||
|
||||
const interrupted = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
|
||||
const timeout = setTimeout(() => { worker.terminate(); reject(new Error("OPFS interrupt gate timed out")); }, 20_000);
|
||||
worker.onmessage = (event) => {
|
||||
if (!event.data.staged) return;
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
setTimeout(() => {
|
||||
const recovery = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
|
||||
recovery.onmessage = (recoveryEvent) => { recovery.terminate(); recoveryEvent.data.error ? reject(new Error(recoveryEvent.data.error)) : resolve(recoveryEvent.data); };
|
||||
recovery.onerror = (error) => { recovery.terminate(); reject(new Error(error.message)); };
|
||||
recovery.postMessage({ action: "recoverInterrupted" });
|
||||
}, 100);
|
||||
};
|
||||
worker.onerror = (error) => { clearTimeout(timeout); worker.terminate(); reject(new Error(error.message)); };
|
||||
worker.postMessage({ action: "interrupt" });
|
||||
}));
|
||||
expect(interrupted.removedIncompleteBundles).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const runOPFS = (action: "prepareQuota" | "quota" | "verifyQuota", state?: unknown): Promise<any> => page.evaluate(({ action, state }) => new Promise<any>((resolve, reject) => {
|
||||
const holder = window as unknown as { vdbQuotaWorker?: Worker };
|
||||
const worker = holder.vdbQuotaWorker ?? new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" });
|
||||
holder.vdbQuotaWorker = worker;
|
||||
worker.onmessage = (event) => { event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (error) => { reject(new Error(error.message)); };
|
||||
worker.postMessage({ action, state });
|
||||
}), { action, state });
|
||||
const baseline = await runOPFS("prepareQuota");
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const origin = new URL(page.url()).origin;
|
||||
const usage = await cdp.send("Storage.getUsageAndQuota", { origin });
|
||||
await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 96 * 1024 });
|
||||
const quota = await runOPFS("quota", baseline.state);
|
||||
expect(quota.quotaError).toMatch(/QuotaExceededError|quota/i);
|
||||
if (quota.recoveryWhileQuotaLimited) expect(quota.recoveryWhileQuotaLimited).toMatch(/QuotaExceededError|quota/i);
|
||||
await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 512 * 1024 * 1024 });
|
||||
const verified = await runOPFS("verifyQuota", baseline.state);
|
||||
expect(verified.previousBundleReadable).toBe(true);
|
||||
expect((quota.recoveredWhileQuotaLimited?.removedIncompleteBundles ?? 0) + verified.recovered.removedIncompleteBundles).toBeGreaterThanOrEqual(1);
|
||||
await page.evaluate(() => {
|
||||
const holder = window as unknown as { vdbQuotaWorker?: Worker };
|
||||
holder.vdbQuotaWorker?.terminate();
|
||||
delete holder.vdbQuotaWorker;
|
||||
});
|
||||
});
|
||||
|
||||
test("returns real Blender evaluations for legacy non-mesh geometry", async ({ page }) => {
|
||||
@@ -865,7 +1444,9 @@ test("keeps N-015 non-mesh previews in the OffscreenCanvas renderer", async ({ p
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker");
|
||||
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6", { timeout: 20_000 });
|
||||
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
|
||||
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "1");
|
||||
await expect(canvas).toHaveAttribute("data-volume-status", "blocked");
|
||||
await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING");
|
||||
await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
|
||||
expect(await canvas.getAttribute("data-renderer-error")).toBeNull();
|
||||
});
|
||||
@@ -1025,16 +1606,27 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => {
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const transformFrame = (frame: number, x: number): ArrayBuffer => {
|
||||
const bytes = new ArrayBuffer(88);
|
||||
const view = new DataView(bytes);
|
||||
view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true);
|
||||
const id = new TextEncoder().encode("object:CacheTarget"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
|
||||
[x, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
|
||||
return bytes;
|
||||
};
|
||||
const projectId = `simulation-e2e-${Date.now()}`;
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer;
|
||||
const source = Uint8Array.from([11, 12, 13, 21, 22, 23, 24]);
|
||||
const frameOne = source.buffer.slice(0, 3);
|
||||
const frameTwo = source.buffer.slice(3);
|
||||
const payload = source.buffer.slice(0);
|
||||
const frameOne = transformFrame(1, 1);
|
||||
const frameTwo = transformFrame(2, 2);
|
||||
const payloadBytes = new Uint8Array(frameOne.byteLength + frameTwo.byteLength);
|
||||
payloadBytes.set(new Uint8Array(frameOne), 0);
|
||||
payloadBytes.set(new Uint8Array(frameTwo), frameOne.byteLength);
|
||||
const payload = payloadBytes.buffer;
|
||||
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
@@ -1048,8 +1640,8 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
frameEnd: 2,
|
||||
byteLength: payload.byteLength,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: 3, sha256: await digest(frameOne) },
|
||||
{ frame: 2, byteOffset: 3, byteLength: 4, sha256: await digest(frameTwo) },
|
||||
{ frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) },
|
||||
{ frame: 2, byteOffset: frameOne.byteLength, byteLength: frameTwo.byteLength, sha256: await digest(frameTwo) },
|
||||
],
|
||||
};
|
||||
const first = new StorageClient();
|
||||
@@ -1061,6 +1653,12 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
const listed = await restarted.listSimulationCaches(projectId);
|
||||
const read = await restarted.readSimulationCache(projectId, stored.cacheKey);
|
||||
const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2);
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const scene = { schemaVersion: 1 as const, revision: 1, sceneId: "scene:Cache", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: 2 }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] };
|
||||
let publishedFrame = 0;
|
||||
let publishedTranslation: number[] = [];
|
||||
const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; });
|
||||
await playback.seek(2);
|
||||
let missingFrameCode = "";
|
||||
try {
|
||||
await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3);
|
||||
@@ -1070,7 +1668,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
}
|
||||
let corruptCode = "";
|
||||
try {
|
||||
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0));
|
||||
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, frameOne.slice(0));
|
||||
}
|
||||
catch (error) {
|
||||
corruptCode = String((error as Error & { code?: string }).code ?? "");
|
||||
@@ -1080,10 +1678,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
cacheKey: stored.cacheKey,
|
||||
path: stored.path,
|
||||
listed: listed.caches.map((cache) => cache.cacheKey),
|
||||
bytes: Array.from(new Uint8Array(read.data)),
|
||||
bytes: read.data.byteLength,
|
||||
frame: frameRead.frame,
|
||||
frameOffset: frameRead.byteOffset,
|
||||
frameBytes: Array.from(new Uint8Array(frameRead.data)),
|
||||
frameBytes: frameRead.data.byteLength,
|
||||
frameMagic: new DataView(frameRead.data).getUint32(0, true),
|
||||
publishedFrame,
|
||||
publishedTranslation,
|
||||
missingFrameCode,
|
||||
corruptCode,
|
||||
};
|
||||
@@ -1091,10 +1692,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/);
|
||||
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
|
||||
expect(result.listed).toContain(result.cacheKey);
|
||||
expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]);
|
||||
expect(result.bytes).toBe(176);
|
||||
expect(result.frame).toBe(2);
|
||||
expect(result.frameOffset).toBe(3);
|
||||
expect(result.frameBytes).toEqual([21, 22, 23, 24]);
|
||||
expect(result.frameOffset).toBe(88);
|
||||
expect(result.frameBytes).toBe(88);
|
||||
expect(result.frameMagic).toBe(0x31465442);
|
||||
expect(result.publishedFrame).toBe(2);
|
||||
expect(result.publishedTranslation).toEqual([2, 0, 0]);
|
||||
expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING");
|
||||
expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
});
|
||||
@@ -1386,15 +1990,35 @@ test("discovers and reuses a valid LOD cache after an application refresh", asyn
|
||||
await expect(page.locator("[data-testid=engine-status]")).toContainText("cached LOD mesh", { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("workspace context routes mode and operator search state", async ({ page }) => {
|
||||
test("operator search executes context-filtered workspace, mode and Main commands", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Modeling" }).click();
|
||||
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling");
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
|
||||
await page.keyboard.press("F3");
|
||||
await page.getByRole("textbox", { name: "搜索操作" }).fill("switch to animation");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Animation");
|
||||
await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press("F3");
|
||||
await page.getByRole("textbox", { name: "搜索操作" }).fill("enter edit");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "操作搜索" }).click();
|
||||
await page.keyboard.press("F3");
|
||||
await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
|
||||
await expect(page.getByRole("button", { name: "Add Cube" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Edit Mode" }).click();
|
||||
await page.keyboard.press("F3");
|
||||
await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
|
||||
await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (4 objects)");
|
||||
});
|
||||
|
||||
test("keeps Outliner selection bound to SceneIR activeObjectId", async ({ page }) => {
|
||||
@@ -1425,6 +2049,42 @@ test("timeline transport controls update the imported frame range", async ({ pag
|
||||
await expect(page.locator(".frame-number")).toHaveText("1");
|
||||
});
|
||||
|
||||
test("serializes concurrent WebEngine init and blend open before the first Main edit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
|
||||
const result = await page.evaluate((input) => new Promise<{ initReady: boolean; frame: number; revision: number }>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
|
||||
let initReady = false;
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.kind !== "result") return;
|
||||
if (!event.data.ok) {
|
||||
worker.terminate();
|
||||
reject(new Error(event.data.error?.message ?? "WebEngine request failed"));
|
||||
return;
|
||||
}
|
||||
if (event.data.requestId === "concurrent-init") {
|
||||
initReady = event.data.result?.status?.ready === true;
|
||||
return;
|
||||
}
|
||||
if (event.data.requestId === "concurrent-open") {
|
||||
worker.postMessage({ requestId: "concurrent-edit", command: { type: "applyCommand", payload: { type: "setFrame", frame: 24 } } });
|
||||
return;
|
||||
}
|
||||
if (event.data.requestId === "concurrent-edit") {
|
||||
const snapshot = event.data.result?.snapshot;
|
||||
worker.terminate();
|
||||
if (!snapshot) { reject(new Error("WebEngine edit returned no SceneIR")); return; }
|
||||
resolve({ initReady, frame: snapshot.frame.current, revision: snapshot.revision });
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
|
||||
worker.postMessage({ requestId: "concurrent-init", command: { type: "init" } });
|
||||
worker.postMessage({ requestId: "concurrent-open", command: { type: "openBlend", buffer } }, [buffer]);
|
||||
}), new Uint8Array(bytes));
|
||||
expect(result).toEqual({ initReady: true, frame: 24, revision: 2 });
|
||||
});
|
||||
|
||||
test("loads the local web_engine WASM worker", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<{ ok: boolean; ready?: boolean; error?: string }>((resolve) => {
|
||||
|
||||
37
web/tests/e2e/texture-4k-performance.spec.ts
Normal file
37
web/tests/e2e/texture-4k-performance.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("decodes, uploads and renders a validated 4K texture in Chromium", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts");
|
||||
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
|
||||
const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js");
|
||||
const source = document.createElement("canvas");
|
||||
source.width = 4096; source.height = 4096;
|
||||
const context = source.getContext("2d", { alpha: false });
|
||||
if (!context) throw new Error("2D texture fixture context is unavailable");
|
||||
context.fillStyle = "#dd3322"; context.fillRect(0, 0, 2048, 4096);
|
||||
context.fillStyle = "#22bb66"; context.fillRect(2048, 0, 2048, 4096);
|
||||
const blob = await new Promise<Blob>((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("4K PNG encoding failed")), "image/png"));
|
||||
const data = await blob.arrayBuffer();
|
||||
const asset = await createGPUTextureAsset({ assetId: "asset:4k", imageId: "image:4k", mimeType: "image/png", width: 4096, height: 4096, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
|
||||
const started = performance.now();
|
||||
const store = new GPUTextureStore();
|
||||
const status = await store.upload([asset]);
|
||||
const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; document.body.append(canvas);
|
||||
const renderer = new WebGLRenderer({ canvas, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
|
||||
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
|
||||
const material = new MeshBasicMaterial({ map: store.get("image:4k", "BASE_COLOR") });
|
||||
scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
|
||||
const gl = renderer.getContext(); const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60) colored += 1;
|
||||
const elapsedMs = Math.round(performance.now() - started);
|
||||
material.dispose(); renderer.dispose(); store.dispose(); canvas.remove();
|
||||
return { status, byteLength: data.byteLength, colored, elapsedMs };
|
||||
});
|
||||
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
|
||||
expect(result.byteLength).toBeGreaterThan(0);
|
||||
expect(result.colored).toBeGreaterThan(3_000);
|
||||
expect(result.elapsedMs).toBeLessThan(30_000);
|
||||
});
|
||||
36
web/tests/e2e/texture-8k-performance.spec.ts
Normal file
36
web/tests/e2e/texture-8k-performance.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("decodes, uploads and renders a validated 8K texture in Chromium", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts");
|
||||
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
|
||||
const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js");
|
||||
const target = document.createElement("canvas"); target.width = 64; target.height = 64; document.body.append(target);
|
||||
const renderer = new WebGLRenderer({ canvas: target, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
|
||||
const gl = renderer.getContext(); const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
|
||||
if (maxTextureSize < 8192) { renderer.dispose(); target.remove(); return { supported: false, maxTextureSize, byteLength: 0, colored: 0, elapsedMs: 0, status: { loaded: 0, rejected: 0, bytes: 0 } }; }
|
||||
const source = document.createElement("canvas"); source.width = 8192; source.height = 8192;
|
||||
const context = source.getContext("2d", { alpha: false });
|
||||
if (!context) throw new Error("2D texture fixture context is unavailable");
|
||||
context.fillStyle = "#2266dd"; context.fillRect(0, 0, 4096, 8192);
|
||||
context.fillStyle = "#ddcc22"; context.fillRect(4096, 0, 4096, 8192);
|
||||
const blob = await new Promise<Blob>((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("8K PNG encoding failed")), "image/png"));
|
||||
const data = await blob.arrayBuffer();
|
||||
const asset = await createGPUTextureAsset({ assetId: "asset:8k", imageId: "image:8k", mimeType: "image/png", width: 8192, height: 8192, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
|
||||
const started = performance.now(); const store = new GPUTextureStore(); const status = await store.upload([asset]);
|
||||
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
|
||||
const material = new MeshBasicMaterial({ map: store.get("image:8k", "BASE_COLOR") }); scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
|
||||
const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60 || pixels[index + 2] > 60) colored += 1;
|
||||
const elapsedMs = Math.round(performance.now() - started);
|
||||
material.dispose(); renderer.dispose(); store.dispose(); target.remove();
|
||||
return { supported: true, maxTextureSize, status, byteLength: data.byteLength, colored, elapsedMs };
|
||||
});
|
||||
expect(result.supported, `WebGL MAX_TEXTURE_SIZE=${result.maxTextureSize}`).toBe(true);
|
||||
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
|
||||
expect(result.byteLength).toBeGreaterThan(0);
|
||||
expect(result.colored).toBeGreaterThan(3_000);
|
||||
expect(result.elapsedMs).toBeLessThan(45_000);
|
||||
});
|
||||
Reference in New Issue
Block a user