Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user