Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
import { normalizeProjectAssetPath } from "./asset-path";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const ASSET_LIBRARY_SCHEMA = 1 as const;
export const ASSET_LIBRARY_BUDGET = {
maxCatalogs: 10_000,
maxAssets: 100_000,
maxTagsPerAsset: 128,
maxLibraries: 1_024,
maxDependenciesPerLibrary: 1_024,
maxArchiveEntries: 100_000,
maxEntryBytes: 2 * 1024 * 1024 * 1024,
maxArchiveBytes: 4 * 1024 * 1024 * 1024,
maxCompressionRatio: 100,
maxExternalUris: 10_000,
} as const;
export type AssetKind = "OBJECT" | "COLLECTION" | "MATERIAL" | "WORLD" | "NODE_GROUP" | "ACTION" | "IMAGE";
export type IOFormat = "GLB" | "GLTF" | "OBJ" | "PLY" | "STL" | "USD" | "ALEMBIC";
export interface AssetCatalogIR { id: string; name: string; parentId: string | null }
export interface AssetPreviewIR { assetId: string; sha256: string; mimeType: "image/png" | "image/webp"; width: number; height: number; byteLength: number }
export interface AssetEntryIR {
id: string;
name: string;
kind: AssetKind;
catalogId: string | null;
tags: string[];
author: string;
license: string;
sourceSha256: string;
sourcePath?: string;
preview?: AssetPreviewIR;
}
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 IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] }
export class AssetLibraryValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "AssetLibraryValidationError"; this.code = code; }
}
const SHA256 = /^[a-f0-9]{64}$/;
const KINDS = new Set<AssetKind>(["OBJECT", "COLLECTION", "MATERIAL", "WORLD", "NODE_GROUP", "ACTION", "IMAGE"]);
const FORMATS = new Set<IOFormat>(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "ALEMBIC"]);
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`); return value; }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`); return value; }
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
function projectPath(value: unknown, name: string, code: ErrorCode = "ASSET_MANIFEST_INVALID"): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new AssetLibraryValidationError(code, `${name} is outside the project`); } }
function assertAcyclic(nodes: ReadonlyMap<string, readonly string[]>, code: ErrorCode, label: string): void {
const active = new Set<string>(); const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new AssetLibraryValidationError(code, `${label} cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
for (const dependency of nodes.get(id) ?? []) { if (!nodes.has(dependency)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${id} references missing ${dependency}`); visit(dependency); }
active.delete(id); complete.add(id);
};
nodes.forEach((_dependencies, id) => visit(id));
}
export function parseAssetLibraryManifest(value: unknown): AssetLibraryManifestIR {
if (!record(value) || value.schemaVersion !== ASSET_LIBRARY_SCHEMA || !Array.isArray(value.catalogs) || !Array.isArray(value.assets) || !Array.isArray(value.libraries)) throw new AssetLibraryValidationError("PROTOCOL_MISMATCH", "Unsupported asset library manifest schema");
if (value.catalogs.length > ASSET_LIBRARY_BUDGET.maxCatalogs || value.assets.length > ASSET_LIBRARY_BUDGET.maxAssets || value.libraries.length > ASSET_LIBRARY_BUDGET.maxLibraries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "Asset manifest exceeds the collection budget");
const catalogIds = new Set<string>();
const catalogs = value.catalogs.map((item, index): AssetCatalogIR => { if (!record(item)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `catalogs[${index}] is invalid`); const id = text(item.id, `catalogs[${index}].id`); if (catalogIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate catalog ${id}`); catalogIds.add(id); return { id, name: text(item.name, `catalogs[${index}].name`), parentId: item.parentId === null ? null : text(item.parentId, `catalogs[${index}].parentId`) }; });
const catalogGraph = new Map(catalogs.map((item) => [item.id, item.parentId === null ? [] : [item.parentId]]));
assertAcyclic(catalogGraph, "ASSET_MANIFEST_INVALID", "Catalog");
const assetIds = new Set<string>();
const assets = value.assets.map((item, index): AssetEntryIR => {
const name = `assets[${index}]`; if (!record(item) || !KINDS.has(item.kind as AssetKind) || !Array.isArray(item.tags)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`);
const id = text(item.id, `${name}.id`); if (assetIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate asset ${id}`); assetIds.add(id);
const catalogId = item.catalogId === null ? null : text(item.catalogId, `${name}.catalogId`); if (catalogId !== null && !catalogIds.has(catalogId)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} references missing catalog ${catalogId}`);
if (item.tags.length > ASSET_LIBRARY_BUDGET.maxTagsPerAsset) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name}.tags exceeds the budget`);
const tags = item.tags.map((tag, tagIndex) => text(tag, `${name}.tags[${tagIndex}]`, 64)); if (new Set(tags).size !== tags.length) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.tags has duplicates`);
const license = typeof item.license === "string" ? item.license.trim() : ""; if (!license) throw new AssetLibraryValidationError("ASSET_LICENSE_MISSING", `${name} has no license metadata`);
const asset: AssetEntryIR = { id, name: text(item.name, `${name}.name`), kind: item.kind as AssetKind, catalogId, tags, author: text(item.author, `${name}.author`), license, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`) };
if (item.sourcePath !== undefined) asset.sourcePath = projectPath(item.sourcePath, `${name}.sourcePath`);
if (item.preview !== undefined) { const preview = item.preview; if (!record(preview) || !["image/png", "image/webp"].includes(preview.mimeType as string)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.preview is invalid`); asset.preview = { assetId: text(preview.assetId, `${name}.preview.assetId`), sha256: digest(preview.sha256, `${name}.preview.sha256`), mimeType: preview.mimeType as AssetPreviewIR["mimeType"], width: integer(preview.width, `${name}.preview.width`, 1, 4096), height: integer(preview.height, `${name}.preview.height`, 1, 4096), byteLength: integer(preview.byteLength, `${name}.preview.byteLength`, 1, 64 * 1024 * 1024) }; }
return asset;
});
const libraryIds = new Set<string>();
const libraries = value.libraries.map((item, index): AssetLibraryIR => { const name = `libraries[${index}]`; if (!record(item) || !Array.isArray(item.dependencyIds) || item.dependencyIds.length > ASSET_LIBRARY_BUDGET.maxDependenciesPerLibrary) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name} is invalid or exceeds the dependency budget`); const id = text(item.id, `${name}.id`); if (libraryIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate library ${id}`); libraryIds.add(id); const dependencyIds = item.dependencyIds.map((dependency, dependencyIndex) => text(dependency, `${name}.dependencyIds[${dependencyIndex}]`)); if (new Set(dependencyIds).size !== dependencyIds.length) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.dependencyIds has duplicates`); return { id, name: text(item.name, `${name}.name`), sourcePath: projectPath(item.sourcePath, `${name}.sourcePath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), dependencyIds, readOnly: item.readOnly !== false }; });
assertAcyclic(new Map(libraries.map((item) => [item.id, item.dependencyIds])), "LIBRARY_DEPENDENCY_CYCLE", "Library dependency");
return { schemaVersion: ASSET_LIBRARY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), catalogs, assets, libraries };
}
export function libraryLoadOrder(value: unknown): string[] {
const manifest = parseAssetLibraryManifest(value); const byId = new Map(manifest.libraries.map((item) => [item.id, item])); const complete = new Set<string>(); const result: string[] = [];
const visit = (id: string): void => { if (complete.has(id)) return; for (const dependency of byId.get(id)?.dependencyIds ?? []) visit(dependency); complete.add(id); result.push(id); };
[...byId.keys()].sort().forEach(visit); return result;
}
export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): void {
if (!SHA256.test(actualSha256) || actualSha256 !== asset.sourceSha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Source hash does not match ${asset.id}`);
}
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");
const request: IORequestIR = { format: value.format as IOFormat, operation: value.operation as IORequestIR["operation"], externalUris: value.externalUris.map((uri, index) => projectPath(uri, `externalUris[${index}]`, "IO_EXTERNAL_URI_BLOCKED")), archiveEntries: [] };
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;
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");
return { path, compressedBytes, uncompressedBytes };
});
return request;
}
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);
return blockedGate("N-023", capability, [capabilityIssue("IO_FORMAT_UNSUPPORTED", `${capability} has no verified local or server executor`)]);
}
export function gateLibraryMutation(operation: "CATALOG_EDIT" | "APPEND" | "LINK" | "OVERRIDE" | "RELOAD" | "RELOCATE"): CapabilityGateResult {
if (operation === "CATALOG_EDIT") return readyGate("N-023", operation);
return blockedGate("N-023", operation, [capabilityIssue("LIBRARY_MUTATION_UNAVAILABLE", `${operation} requires a real Blender Main transaction`)]);
}
export function assetStorageCapabilities(scope: typeof globalThis = globalThis): { contentAddressedIndex: "LOCAL_BOUNDED"; opfs: "PROBE_REQUIRED" | "UNAVAILABLE" } {
const storage = (scope.navigator as Navigator & { storage?: { getDirectory?: unknown } } | undefined)?.storage;
return { contentAddressedIndex: "LOCAL_BOUNDED", opfs: storage && typeof storage.getDirectory === "function" ? "PROBE_REQUIRED" : "UNAVAILABLE" };
}

View File

@@ -0,0 +1,23 @@
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
export function normalizeProjectAssetPath(sourcePath: string): string {
if (typeof sourcePath !== "string" || sourcePath.length === 0 || sourcePath.length > 2048) {
throw new Error("ASSET_PATH_INVALID");
}
if (sourcePath.includes("\0") || sourcePath.includes("\\") || sourcePath.includes("%")) {
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
}
let relative = sourcePath.startsWith("//") ? sourcePath.slice(2) : sourcePath;
if (relative.startsWith("/") || DRIVE_PATH.test(relative) || URI_SCHEME.test(relative)) {
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
}
const segments = relative.split("/");
if (segments.length === 0 || segments.some((segment) =>
segment.length === 0 || segment === "." || segment === ".." || /[\u0000-\u001f\u007f]/.test(segment))) {
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
}
relative = segments.join("/");
if (!relative) throw new Error("ASSET_PATH_INVALID");
return relative;
}

177
web/protocol/budget.ts Normal file
View File

@@ -0,0 +1,177 @@
export type BudgetMetric = "triangles" | "vertices" | "indexBytes" | "materialSlots" | "textureBytes" | "gpuBytes";
export interface LightweightBudget {
schemaVersion: 1;
triangles?: number;
vertices?: number;
indexBytes?: number;
materialSlots?: number;
textureBytes?: number;
gpuBytes?: number;
}
export interface LightweightUsage {
triangles: number;
vertices: number;
indexBytes: number;
materialSlots: number;
textureBytes: number;
gpuBytes: number;
}
export interface BudgetViolation {
metric: BudgetMetric;
limit: number;
actual: number;
excess: number;
}
export interface LightweightBudgetReport {
withinBudget: boolean;
budget: LightweightBudget;
usage: LightweightUsage;
violations: BudgetViolation[];
}
export interface LightweightObjectUsage {
objectId: string;
collectionIds: string[];
usage: LightweightUsage;
}
export interface LightweightLODUsage {
lodId: string;
objectId: string;
usage: LightweightUsage;
}
export interface LightweightUsageAggregation {
project: LightweightUsage;
collections: Record<string, LightweightUsage>;
objects: Record<string, LightweightUsage>;
lod: Record<string, LightweightUsage>;
}
export interface LightweightBudgetScopes {
project?: LightweightBudget;
collections?: Record<string, LightweightBudget>;
objects?: Record<string, LightweightBudget>;
lod?: Record<string, LightweightBudget>;
}
export interface LightweightBudgetAggregationReport {
usage: LightweightUsageAggregation;
project?: LightweightBudgetReport;
collections: Record<string, LightweightBudgetReport>;
objects: Record<string, LightweightBudgetReport>;
lod: Record<string, LightweightBudgetReport>;
}
export class BudgetValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "BudgetValidationError";
}
}
const metrics: BudgetMetric[] = ["triangles", "vertices", "indexBytes", "materialSlots", "textureBytes", "gpuBytes"];
const emptyUsage = (): LightweightUsage => ({ triangles: 0, vertices: 0, indexBytes: 0, materialSlots: 0, textureBytes: 0, gpuBytes: 0 });
function validateUsage(value: LightweightUsage, field: string): LightweightUsage {
for (const metric of metrics) nonNegativeInteger(value[metric], `${field}.${metric}`);
return { ...value };
}
function addUsage(target: LightweightUsage, source: LightweightUsage, field: string): void {
for (const metric of metrics) {
const value = target[metric] + source[metric];
if (!Number.isSafeInteger(value)) throw new BudgetValidationError(`${field}.${metric} exceeds safe integer range`);
target[metric] = value;
}
}
function requireId(value: string, field: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256) throw new BudgetValidationError(`${field} must be a non-empty id`);
return value;
}
function nonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new BudgetValidationError(`${field} must be a non-negative safe integer`);
return value;
}
export function parseLightweightBudget(value: unknown): LightweightBudget {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new BudgetValidationError("budget must be an object");
const input = value as Record<string, unknown>;
if (input.schemaVersion !== 1) throw new BudgetValidationError("Unsupported budget schema");
const budget: LightweightBudget = { schemaVersion: 1 };
for (const metric of metrics) {
if (input[metric] !== undefined) budget[metric] = nonNegativeInteger(input[metric], metric);
}
if (metrics.every((metric) => budget[metric] === undefined)) throw new BudgetValidationError("budget must define at least one metric");
return budget;
}
export function evaluateLightweightBudget(budgetValue: unknown, usageValue: LightweightUsage): LightweightBudgetReport {
const budget = parseLightweightBudget(budgetValue);
validateUsage(usageValue, "usage");
const violations = metrics.flatMap((metric) => {
const limit = budget[metric];
if (limit === undefined || usageValue[metric] <= limit) return [];
return [{ metric, limit, actual: usageValue[metric], excess: usageValue[metric] - limit }];
});
return { withinBudget: violations.length === 0, budget, usage: { ...usageValue }, violations };
}
/** Aggregates source object usage once, collection membership independently, and LOD usage separately. */
export function aggregateLightweightUsage(objects: readonly LightweightObjectUsage[], lod: readonly LightweightLODUsage[] = []): LightweightUsageAggregation {
const project = emptyUsage();
const collections: Record<string, LightweightUsage> = {};
const objectUsage: Record<string, LightweightUsage> = {};
const lodUsage: Record<string, LightweightUsage> = {};
for (const object of objects) {
const objectId = requireId(object.objectId, "object.objectId");
if (objectUsage[objectId]) throw new BudgetValidationError(`duplicate object id: ${objectId}`);
if (!Array.isArray(object.collectionIds)) throw new BudgetValidationError(`object.${objectId}.collectionIds must be an array`);
const usage = validateUsage(object.usage, `object.${objectId}.usage`);
objectUsage[objectId] = usage;
addUsage(project, usage, "project");
const collectionIds = new Set<string>();
for (const collectionIdValue of object.collectionIds) {
const collectionId = requireId(collectionIdValue, "object.collectionIds[]");
if (collectionIds.has(collectionId)) throw new BudgetValidationError(`duplicate collection membership: ${objectId}:${collectionId}`);
collectionIds.add(collectionId);
const collection = collections[collectionId] ?? (collections[collectionId] = emptyUsage());
addUsage(collection, usage, `collection.${collectionId}`);
}
}
for (const item of lod) {
const lodId = requireId(item.lodId, "lod.lodId");
const objectId = requireId(item.objectId, "lod.objectId");
if (!objectUsage[objectId]) throw new BudgetValidationError(`LOD references unknown object: ${objectId}`);
if (lodUsage[lodId]) throw new BudgetValidationError(`duplicate lod id: ${lodId}`);
lodUsage[lodId] = validateUsage(item.usage, `lod.${lodId}.usage`);
}
return { project, collections, objects: objectUsage, lod: lodUsage };
}
export function evaluateLightweightBudgets(scopes: LightweightBudgetScopes, aggregation: LightweightUsageAggregation): LightweightBudgetAggregationReport {
const project = scopes.project ? evaluateLightweightBudget(scopes.project, aggregation.project) : undefined;
const collections: Record<string, LightweightBudgetReport> = {};
for (const [id, budget] of Object.entries(scopes.collections ?? {})) {
const usage = aggregation.collections[id] ?? emptyUsage();
collections[id] = evaluateLightweightBudget(budget, usage);
}
const objects: Record<string, LightweightBudgetReport> = {};
for (const [id, budget] of Object.entries(scopes.objects ?? {})) {
const usage = aggregation.objects[id] ?? emptyUsage();
objects[id] = evaluateLightweightBudget(budget, usage);
}
const lod: Record<string, LightweightBudgetReport> = {};
for (const [id, budget] of Object.entries(scopes.lod ?? {})) {
const usage = aggregation.lod[id] ?? emptyUsage();
lod[id] = evaluateLightweightBudget(budget, usage);
}
return { usage: aggregation, project, collections, objects, lod };
}

View File

@@ -0,0 +1,31 @@
import type { ErrorCode } from "./error";
export interface CapabilityIssue {
code: ErrorCode;
message: string;
path?: string;
recoverable: boolean;
}
export interface CapabilityGateResult {
taskId: "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012";
capability: string;
status: "READY" | "BLOCKED";
issues: CapabilityIssue[];
}
export function readyGate(taskId: CapabilityGateResult["taskId"], capability: string): CapabilityGateResult {
return { taskId, capability, status: "READY", issues: [] };
}
export function blockedGate(
taskId: CapabilityGateResult["taskId"],
capability: string,
issues: CapabilityIssue[],
): CapabilityGateResult {
return { taskId, capability, status: "BLOCKED", issues };
}
export function capabilityIssue(code: ErrorCode, message: string, path?: string, recoverable = true): CapabilityIssue {
return { code, message, path, recoverable };
}

368
web/protocol/compositor.ts Normal file
View File

@@ -0,0 +1,368 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const COMPOSITOR_SCHEMA = 1 as const;
export const COMPOSITOR_BUDGET = {
maxNodes: 4_096,
maxLinks: 16_384,
maxResources: 4_096,
maxDimension: 8_192,
maxPixels: 16_777_216,
maxImageBytes: 256 * 1024 * 1024,
maxBlurRadius: 32,
maxOperations: 100_000_000,
} as const;
export const COMPOSITOR_NODE_TYPES = [
"IMAGE",
"RENDER_LAYER",
"CONSTANT_COLOR",
"TRANSFORM",
"INVERT",
"EXPOSURE",
"ALPHA_OVER",
"BLUR",
"MIX",
"VIEWER",
"COMPOSITE",
"UNSUPPORTED",
] as const;
export type CompositorNodeType = typeof COMPOSITOR_NODE_TYPES[number];
export interface CompositorResourceIR {
id: string;
kind: "IMAGE" | "RENDER_LAYER";
sourceId: string;
width?: number;
height?: number;
sha256?: string;
}
export interface CompositorNodeIR {
id: string;
type: CompositorNodeType;
name: string;
blenderType?: string;
properties: Record<string, unknown>;
}
export interface CompositorLinkIR {
fromNodeId: string;
fromSocket: string;
toNodeId: string;
toSocket: string;
}
export interface CompositorGraphIR {
schemaVersion: typeof COMPOSITOR_SCHEMA;
id: string;
name: string;
outputNodeId: string;
nodes: CompositorNodeIR[];
links: CompositorLinkIR[];
resources: CompositorResourceIR[];
}
export interface CompositorImageBuffer {
width: number;
height: number;
data: Float32Array;
colorSpace: "LINEAR_SRGB";
}
export interface CompositorExecutionResult {
composite: CompositorImageBuffer;
viewers: Map<string, CompositorImageBuffer>;
evaluatedNodeIds: string[];
}
export class CompositorValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "CompositorValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const SUPPORTED = new Set<CompositorNodeType>(COMPOSITOR_NODE_TYPES.filter((type) => type !== "UNSUPPORTED"));
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${name} is invalid`);
}
return value;
}
function finite(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${name} is outside the bounded range`);
}
return value;
}
function properties(value: unknown, index: number): Record<string, unknown> {
if (!record(value)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].properties must be an object`);
let encoded: string;
try { encoded = JSON.stringify(value); }
catch { throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].properties is not serializable`); }
if (new TextEncoder().encode(encoded).byteLength > 64 * 1024) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `nodes[${index}].properties exceeds 64 KiB`);
}
return value;
}
function validateNodeProperties(node: CompositorNodeIR, index: number): void {
const value = node.properties;
const allowed = (names: string[]): void => {
if (Object.keys(value).some((key) => !names.includes(key))) {
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}] contains unsupported properties`);
}
};
if (node.type === "IMAGE" || node.type === "RENDER_LAYER") {
allowed(["resourceId"]);
text(value.resourceId, `nodes[${index}].properties.resourceId`);
}
else if (node.type === "CONSTANT_COLOR") {
allowed(["color"]);
if (!Array.isArray(value.color) || value.color.length !== 4) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].color must contain RGBA`);
value.color.forEach((channel, channelIndex) => finite(channel, `nodes[${index}].color[${channelIndex}]`, -65504, 65504));
}
else if (node.type === "TRANSFORM") {
allowed(["translateX", "translateY", "scaleX", "scaleY"]);
for (const key of ["translateX", "translateY"] as const) if (value[key] !== undefined) finite(value[key], `nodes[${index}].${key}`, -1_000_000, 1_000_000);
for (const key of ["scaleX", "scaleY"] as const) if (value[key] !== undefined) finite(value[key], `nodes[${index}].${key}`, 0.0001, 10_000);
}
else if (node.type === "EXPOSURE") {
allowed(["exposure"]);
finite(value.exposure ?? 0, `nodes[${index}].exposure`, -20, 20);
}
else if (node.type === "BLUR") {
allowed(["radius"]);
const radius = finite(value.radius ?? 0, `nodes[${index}].radius`, 0, COMPOSITOR_BUDGET.maxBlurRadius);
if (!Number.isSafeInteger(radius)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].radius must be an integer`);
}
else if (node.type === "MIX") {
allowed(["factor"]);
finite(value.factor ?? 0.5, `nodes[${index}].factor`, 0, 1);
}
else if (["INVERT", "ALPHA_OVER", "VIEWER", "COMPOSITE"].includes(node.type)) allowed([]);
else if (node.type === "UNSUPPORTED") {
if (!node.blenderType) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}] must preserve blenderType`);
}
}
export function parseCompositorGraph(value: unknown): CompositorGraphIR {
if (!record(value) || value.schemaVersion !== COMPOSITOR_SCHEMA || !Array.isArray(value.nodes) ||
!Array.isArray(value.links) || !Array.isArray(value.resources)) {
throw new CompositorValidationError("PROTOCOL_MISMATCH", "Unsupported Compositor graph schema");
}
if (value.nodes.length === 0 || value.nodes.length > COMPOSITOR_BUDGET.maxNodes ||
value.links.length > COMPOSITOR_BUDGET.maxLinks || value.resources.length > COMPOSITOR_BUDGET.maxResources) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor graph exceeds the node, link or resource budget");
}
const nodeIds = new Set<string>();
const nodes = value.nodes.map((item, index): CompositorNodeIR => {
if (!record(item) || !COMPOSITOR_NODE_TYPES.includes(item.type as CompositorNodeType)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}] is invalid`);
const id = text(item.id, `nodes[${index}].id`);
if (nodeIds.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Duplicate node ${id}`);
nodeIds.add(id);
const node = { id, type: item.type as CompositorNodeType, name: text(item.name, `nodes[${index}].name`), properties: properties(item.properties ?? {}, index), blenderType: item.blenderType === undefined ? undefined : text(item.blenderType, `nodes[${index}].blenderType`) };
validateNodeProperties(node, index);
return node;
});
const resourceIds = new Set<string>();
const resources = value.resources.map((item, index): CompositorResourceIR => {
if (!record(item) || !["IMAGE", "RENDER_LAYER"].includes(item.kind as string)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `resources[${index}] is invalid`);
const id = text(item.id, `resources[${index}].id`);
if (resourceIds.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Duplicate resource ${id}`);
resourceIds.add(id);
const width = item.width === undefined ? undefined : finite(item.width, `resources[${index}].width`, 1, COMPOSITOR_BUDGET.maxDimension);
const height = item.height === undefined ? undefined : finite(item.height, `resources[${index}].height`, 1, COMPOSITOR_BUDGET.maxDimension);
if ((width !== undefined && !Number.isSafeInteger(width)) || (height !== undefined && !Number.isSafeInteger(height))) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `resources[${index}] dimensions must be integers`);
if (item.sha256 !== undefined && (typeof item.sha256 !== "string" || !SHA256.test(item.sha256))) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `resources[${index}].sha256 is invalid`);
return { id, kind: item.kind as CompositorResourceIR["kind"], sourceId: text(item.sourceId, `resources[${index}].sourceId`), width, height, sha256: item.sha256 as string | undefined };
});
const destinations = new Set<string>();
const links = value.links.map((item, index): CompositorLinkIR => {
if (!record(item)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `links[${index}] is invalid`);
const link = { fromNodeId: text(item.fromNodeId, `links[${index}].fromNodeId`), fromSocket: text(item.fromSocket, `links[${index}].fromSocket`), toNodeId: text(item.toNodeId, `links[${index}].toNodeId`), toSocket: text(item.toSocket, `links[${index}].toSocket`) };
if (!nodeIds.has(link.fromNodeId) || !nodeIds.has(link.toNodeId)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `links[${index}] references a missing node`);
const destination = `${link.toNodeId}:${link.toSocket}`;
if (destinations.has(destination)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Multiple links target ${destination}`);
destinations.add(destination);
return link;
});
const outputNodeId = text(value.outputNodeId, "outputNodeId");
if (!nodeIds.has(outputNodeId) || nodes.find((node) => node.id === outputNodeId)?.type !== "COMPOSITE") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "outputNodeId must reference a COMPOSITE node");
const outgoing = new Map(nodes.map((node) => [node.id, [] as string[]]));
for (const link of links) outgoing.get(link.fromNodeId)?.push(link.toNodeId);
const active = new Set<string>();
const done = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_CYCLE", `Compositor graph cycle includes ${id}`);
if (done.has(id)) return;
active.add(id);
for (const next of outgoing.get(id) ?? []) visit(next);
active.delete(id);
done.add(id);
};
nodes.forEach((node) => visit(node.id));
return { schemaVersion: COMPOSITOR_SCHEMA, id: text(value.id, "id"), name: text(value.name, "name"), outputNodeId, nodes, links, resources };
}
function validateImage(image: CompositorImageBuffer, name: string): CompositorImageBuffer {
const pixels = image.width * image.height;
if (!Number.isSafeInteger(image.width) || !Number.isSafeInteger(image.height) || image.width < 1 || image.height < 1 ||
image.width > COMPOSITOR_BUDGET.maxDimension || image.height > COMPOSITOR_BUDGET.maxDimension || pixels > COMPOSITOR_BUDGET.maxPixels ||
image.data.length !== pixels * 4 || image.data.byteLength > COMPOSITOR_BUDGET.maxImageBytes) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${name} exceeds the image budget`);
}
if (image.colorSpace !== "LINEAR_SRGB") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${name} must use LINEAR_SRGB`);
return image;
}
function allocate(width: number, height: number): CompositorImageBuffer {
const pixels = width * height;
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 ||
width > COMPOSITOR_BUDGET.maxDimension || height > COMPOSITOR_BUDGET.maxDimension ||
pixels > COMPOSITOR_BUDGET.maxPixels || pixels * 16 > COMPOSITOR_BUDGET.maxImageBytes)
{
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor output exceeds the image budget");
}
return validateImage({ width, height, data: new Float32Array(width * height * 4), colorSpace: "LINEAR_SRGB" }, "Compositor output");
}
export function gateCompositorGraph(value: unknown, availableResourceIds: ReadonlySet<string>): CapabilityGateResult {
try {
const graph = parseCompositorGraph(value);
const unsupported = graph.nodes.filter((node) => !SUPPORTED.has(node.type)).map((node) => node.blenderType ?? node.type);
if (unsupported.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_NODE_UNSUPPORTED", `Unsupported compositor nodes: ${unsupported.join(", ")}`)]);
const missing = graph.resources.filter((resource) => !availableResourceIds.has(resource.sourceId));
if (missing.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resources: ${missing.map((resource) => resource.sourceId).join(", ")}`)]);
return readyGate("N-020", "BOUNDED_CPU_COMPOSITOR");
}
catch (error) {
const code = error instanceof CompositorValidationError ? error.code : "COMPOSITOR_GRAPH_INVALID";
return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue(code, error instanceof Error ? error.message : "Invalid compositor graph")]);
}
}
export function executeCompositorGraph(
value: unknown,
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
options: { width?: number; height?: number; cancelled?: () => boolean } = {},
): CompositorExecutionResult {
const graph = parseCompositorGraph(value);
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
const incoming = new Map<string, CompositorLinkIR>();
graph.links.forEach((link) => incoming.set(`${link.toNodeId}:${link.toSocket}`, link));
const outputs = new Map<string, CompositorImageBuffer>();
const viewers = new Map<string, CompositorImageBuffer>();
const evaluatedNodeIds: string[] = [];
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
const image = source ? outputs.get(source) : undefined;
if (!image) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId}.${socket} is not connected to an evaluated image`);
return image;
};
const sameSize = (left: CompositorImageBuffer, right: CompositorImageBuffer, nodeId: string): void => {
if (left.width !== right.width || left.height !== right.height) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId} inputs have different dimensions`);
};
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");
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);
let output: CompositorImageBuffer;
if (node.type === "UNSUPPORTED") throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `${node.blenderType ?? node.name} is not executable locally`);
if (node.type === "IMAGE" || node.type === "RENDER_LAYER") {
const resourceId = node.properties.resourceId as string;
const resource = graph.resources.find((item) => item.id === resourceId);
const image = resource ? sourceImages.get(resource.sourceId) : undefined;
if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource?.sourceId ?? resourceId}`);
output = validateImage(image, node.id);
}
else if (node.type === "CONSTANT_COLOR") {
const width = options.width ?? 1;
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);
}
else if (node.type === "TRANSFORM") {
const input = requireInput(id, "Image");
output = allocate(input.width, input.height);
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++) {
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);
}
}
else if (node.type === "INVERT" || node.type === "EXPOSURE") {
const input = requireInput(id, "Image");
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) {
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];
}
}
else if (node.type === "MIX" || node.type === "ALPHA_OVER") {
const left = requireInput(id, node.type === "MIX" ? "A" : "Background");
const right = requireInput(id, node.type === "MIX" ? "B" : "Foreground");
sameSize(left, right, id);
output = allocate(left.width, left.height);
for (let offset = 0; offset < left.data.length; offset += 4) {
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;
}
else {
const backgroundAlpha = left.data[offset + 3], foregroundAlpha = right.data[offset + 3];
const alpha = foregroundAlpha + backgroundAlpha * (1 - foregroundAlpha);
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = alpha > 0 ? (right.data[offset + channel] * foregroundAlpha + left.data[offset + channel] * backgroundAlpha * (1 - foregroundAlpha)) / alpha : 0;
output.data[offset + 3] = alpha;
}
}
}
else if (node.type === "BLUR") {
const input = requireInput(id, "Image");
const radius = Number(node.properties.radius ?? 0);
const operations = input.width * input.height * (radius * 2 + 1) ** 2;
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++) {
const target = (y * input.width + x) * 4;
let samples = 0;
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
const px = x + dx, py = y + dy;
if (px < 0 || py < 0 || px >= input.width || py >= input.height) continue;
const source = (py * input.width + px) * 4;
for (let channel = 0; channel < 4; channel++) output.data[target + channel] += input.data[source + channel];
samples++;
}
for (let channel = 0; channel < 4; channel++) output.data[target + channel] /= samples;
}
}
else {
output = requireInput(id, "Image");
if (node.type === "VIEWER") viewers.set(id, output);
}
outputs.set(id, output);
evaluatedNodeIds.push(id);
return output;
};
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
}

102
web/protocol/deformation.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { ArmatureBoneIR, MeshSummaryIR, SceneSnapshotIR } from "./scene-ir";
function multiply(left: readonly number[], right: readonly number[]): number[] {
const result = new Array<number>(16).fill(0);
for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) for (let index = 0; index < 4; index++) {
result[column * 4 + row] += left[index * 4 + row] * right[column * 4 + index];
}
return result;
}
function inverse(matrix: readonly number[]): number[] {
const rows = Array.from({ length: 4 }, (_, row) => [
matrix[row], matrix[4 + row], matrix[8 + row], matrix[12 + row],
row === 0 ? 1 : 0, row === 1 ? 1 : 0, row === 2 ? 1 : 0, row === 3 ? 1 : 0,
]);
for (let column = 0; column < 4; column++) {
let pivot = column;
for (let row = column + 1; row < 4; row++) if (Math.abs(rows[row][column]) > Math.abs(rows[pivot][column])) pivot = row;
if (Math.abs(rows[pivot][column]) < 1e-10) throw new Error("deformation matrix is singular");
[rows[column], rows[pivot]] = [rows[pivot], rows[column]];
const scale = rows[column][column];
for (let index = 0; index < 8; index++) rows[column][index] /= scale;
for (let row = 0; row < 4; row++) if (row !== column) {
const factor = rows[row][column];
for (let index = 0; index < 8; index++) rows[row][index] -= factor * rows[column][index];
}
}
const result = new Array<number>(16);
for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) result[column * 4 + row] = rows[row][4 + column];
return result;
}
function transform(matrix: readonly number[], point: readonly number[]): [number, number, number] {
const x = point[0], y = point[1], z = point[2];
return [
matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14],
];
}
function baseShapePositions(mesh: MeshSummaryIR): number[] {
if (!mesh.positions) throw new Error(`mesh ${mesh.id} has no positions`);
const positions = [...mesh.positions];
for (const shape of mesh.shapeKeys ?? []) {
const value = shape.value ?? 0;
if (Math.abs(value) < 1e-12) continue;
if (shape.positions.length !== positions.length) throw new Error(`shape key ${shape.name} does not match mesh ${mesh.id}`);
for (let index = 0; index < positions.length; index++) positions[index] += (shape.positions[index] - mesh.positions[index]) * value;
}
return positions;
}
function boneByJoint(snapshot: SceneSnapshotIR, armatureId: string, jointIds: readonly string[]): ArmatureBoneIR[] {
const armature = snapshot.armatures?.find((candidate) => candidate.id === armatureId);
if (!armature) throw new Error(`armature ${armatureId} is missing`);
return jointIds.map((id) => {
const bone = armature.bones.find((candidate) => candidate.id === id);
if (!bone) throw new Error(`joint ${id} is missing from armature ${armatureId}`);
return bone;
});
}
/** Evaluate Blender's linear armature deformation for a SceneIR mesh at its current pose. */
export function evaluateDeformedMesh(snapshot: SceneSnapshotIR, meshId: string): number[] {
const mesh = snapshot.meshes.find((candidate) => candidate.id === meshId);
if (!mesh?.positions) throw new Error(`mesh ${meshId} has no available geometry`);
const skin = mesh.skinWeights;
if (!skin?.armatureId || !skin.jointIds) return baseShapePositions(mesh);
const meshNode = snapshot.nodes.find((node) => node.dataId === meshId && node.type === "MESH");
const armature = snapshot.armatures?.find((candidate) => candidate.id === skin.armatureId);
const armatureNode = armature?.objectId ? snapshot.nodes.find((node) => node.id === armature.objectId) : undefined;
if (!meshNode || !armatureNode || !armature) throw new Error(`mesh ${meshId} bind objects are missing`);
const meshWorld = meshNode.worldMatrix;
const armatureWorld = armatureNode.worldMatrix;
const preMatrix = multiply(inverse(armatureWorld), meshWorld);
const postMatrix = multiply(inverse(meshWorld), armatureWorld);
const bones = boneByJoint(snapshot, skin.armatureId, skin.jointIds);
const deformMatrices = bones.map((bone) => multiply(bone.poseMatrix ?? bone.restMatrix, inverse(bone.restMatrix)));
const source = baseShapePositions(mesh);
const output = new Array<number>(source.length).fill(0);
for (let vertex = 0; vertex < mesh.vertexCount; vertex++) {
const local = transform(preMatrix, source.slice(vertex * 3, vertex * 3 + 3));
let total = 0;
for (let slot = 0; slot < 4; slot++) total += skin.weights[vertex * 4 + slot] ?? 0;
const normalized = total > 1e-12 ? 1 / total : 0;
const deformed: [number, number, number] = [0, 0, 0];
for (let slot = 0; slot < 4; slot++) {
const weight = (skin.weights[vertex * 4 + slot] ?? 0) * normalized;
if (weight <= 0) continue;
const transformed = transform(deformMatrices[skin.indices[vertex * 4 + slot]], local);
deformed[0] += transformed[0] * weight;
deformed[1] += transformed[1] * weight;
deformed[2] += transformed[2] * weight;
}
const result = total > 1e-12 ? transform(postMatrix, deformed) : source.slice(vertex * 3, vertex * 3 + 3) as [number, number, number];
output[vertex * 3] = result[0];
output[vertex * 3 + 1] = result[1];
output[vertex * 3 + 2] = result[2];
}
return output;
}

336
web/protocol/depsgraph.ts Normal file
View File

@@ -0,0 +1,336 @@
export interface DepsgraphMeshEvaluationIR {
objectId: string;
meshId: string;
sourceMeshId: string;
vertexCount: number;
triangleCount: number;
modifierCount: number;
modifiers: DepsgraphModifierEvaluationIR[];
worldMatrix: number[];
positions: number[];
indices: number[];
}
export interface DepsgraphModifierEvaluationIR {
uuid: string;
index: number;
persistentUid: number;
typeCode: number;
type: string;
name: string;
showViewport: boolean;
showRender: boolean;
showEditmode: boolean;
showOnCage: boolean;
status: "EVALUATED" | "DISABLED" | "BLOCKED";
reason?: string;
error?: string;
errorCode?: "UNSUPPORTED_MODIFIER_TYPE" | "MODIFIER_TARGET_MISSING" | "BLENDER_MODIFIER_ERROR";
suggestion?: string;
targetObjectIds?: string[];
dependsOn?: string[];
}
export interface DepsgraphObjectEvaluationIR {
objectId: string;
type: "MESH" | "CURVE" | "SURFACE" | "FONT" | "METABALL" | "CURVES" | "POINT_CLOUD" | "VOLUME" | "LATTICE" | "ARMATURE" | "GREASE_PENCIL" | "EMPTY" | "OTHER";
modifierCount: number;
modifiers: DepsgraphModifierEvaluationIR[];
}
export interface DepsgraphConstraintEvaluationIR {
name: string;
typeCode: number;
influence: number;
flag: number;
targetObjectId?: string;
}
export interface DepsgraphBoneEvaluationIR {
id: string;
name: string;
poseMatrix: number[];
constraints: DepsgraphConstraintEvaluationIR[];
}
export interface DepsgraphArmatureEvaluationIR {
id: string;
objectId: string;
bones: DepsgraphBoneEvaluationIR[];
}
export interface DepsgraphNonMeshGeometryIR {
objectId: string;
sourceDataId: string;
sourceType: "CURVE" | "SURFACE" | "FONT" | "METABALL";
meshId: string;
status: "EVALUATED" | "BLOCKED";
errorCode?: "NON_MESH_DATA_BUDGET_EXCEEDED";
vertexCount: number;
edgeCount: number;
triangleCount: number;
worldMatrix: number[];
positions?: number[];
normals?: number[];
edgeVertexIndices?: number[];
indices?: number[];
uvs?: number[];
triangleMaterialIndices?: number[];
sourceElementIndices?: number[];
materialSlotIds: string[];
sourceMappingStatus: "EVALUATED_FACE" | "EVALUATED_EDGE";
}
export interface DepsgraphEvaluationIR {
engine: "BlenderDepsgraph";
status: "EVALUATED";
scene: string;
viewLayer: string;
frame: number;
objectCount: number;
meshObjectCount: number;
objects: DepsgraphObjectEvaluationIR[];
meshes: DepsgraphMeshEvaluationIR[];
nonMeshGeometries?: DepsgraphNonMeshGeometryIR[];
armatures?: DepsgraphArmatureEvaluationIR[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function numberField(record: Record<string, unknown>, field: string): number {
const value = record[field];
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`Depsgraph field ${field} is invalid`);
return value;
}
function stringField(record: Record<string, unknown>, field: string): string {
const value = record[field];
if (typeof value !== "string") throw new Error(`Depsgraph field ${field} is invalid`);
return value;
}
function numberArray(record: Record<string, unknown>, field: string): number[] {
const value = record[field];
if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
throw new Error(`Depsgraph field ${field} is invalid`);
}
return value;
}
function countField(record: Record<string, unknown>, field: string): number {
const value = numberField(record, field);
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Depsgraph field ${field} is not a non-negative count`);
return value;
}
function booleanField(record: Record<string, unknown>, field: string): boolean {
const value = record[field];
if (typeof value !== "boolean") throw new Error(`Depsgraph field ${field} is invalid`);
return value;
}
function constraintReports(record: Record<string, unknown>): DepsgraphConstraintEvaluationIR[] {
const value = record.constraints;
if (!Array.isArray(value)) throw new Error("Depsgraph bone constraints are invalid");
return value.map((candidate) => {
if (!isRecord(candidate)) throw new Error("Depsgraph constraint entry is invalid");
const targetObjectId = candidate.targetObjectId;
if (targetObjectId !== undefined && typeof targetObjectId !== "string") {
throw new Error("Depsgraph constraint targetObjectId is invalid");
}
return {
name: stringField(candidate, "name"),
typeCode: countField(candidate, "typeCode"),
influence: numberField(candidate, "influence"),
flag: countField(candidate, "flag"),
...(targetObjectId === undefined ? {} : { targetObjectId }),
};
});
}
function armatureReports(value: unknown): DepsgraphArmatureEvaluationIR[] {
if (!Array.isArray(value)) throw new Error("Depsgraph armatures are invalid");
return value.map((candidate) => {
if (!isRecord(candidate)) throw new Error("Depsgraph armature entry is invalid");
const bonesValue = candidate.bones;
if (!Array.isArray(bonesValue)) throw new Error("Depsgraph armature bones are invalid");
const bones = bonesValue.map((boneValue) => {
if (!isRecord(boneValue)) throw new Error("Depsgraph bone entry is invalid");
const poseMatrix = numberArray(boneValue, "poseMatrix");
if (poseMatrix.length !== 16) throw new Error("Depsgraph bone poseMatrix must contain 16 numbers");
return {
id: stringField(boneValue, "id"),
name: stringField(boneValue, "name"),
poseMatrix,
constraints: constraintReports(boneValue),
};
});
return { id: stringField(candidate, "id"), objectId: stringField(candidate, "objectId"), bones };
});
}
function modifierReports(record: Record<string, unknown>): DepsgraphModifierEvaluationIR[] {
const value = record.modifiers;
if (!Array.isArray(value)) throw new Error(`Depsgraph field modifiers is invalid`);
return value.map((candidate) => {
if (!isRecord(candidate)) throw new Error("Depsgraph modifier entry is invalid");
const status = stringField(candidate, "status");
if (status !== "EVALUATED" && status !== "DISABLED" && status !== "BLOCKED") {
throw new Error(`Depsgraph modifier status is invalid: ${status}`);
}
const dependsOn = candidate.dependsOn;
if (dependsOn !== undefined && (!Array.isArray(dependsOn) || dependsOn.some((item) => typeof item !== "string"))) {
throw new Error("Depsgraph modifier dependsOn is invalid");
}
const targetObjectIds = candidate.targetObjectIds;
if (targetObjectIds !== undefined && (!Array.isArray(targetObjectIds) || targetObjectIds.some((item) => typeof item !== "string"))) {
throw new Error("Depsgraph modifier targetObjectIds is invalid");
}
const reason = candidate.reason;
const error = candidate.error;
const errorCode = candidate.errorCode;
const suggestion = candidate.suggestion;
if (reason !== undefined && typeof reason !== "string") throw new Error("Depsgraph modifier reason is invalid");
if (error !== undefined && typeof error !== "string") throw new Error("Depsgraph modifier error is invalid");
if (errorCode !== undefined && !["UNSUPPORTED_MODIFIER_TYPE", "MODIFIER_TARGET_MISSING", "BLENDER_MODIFIER_ERROR"].includes(errorCode as string)) {
throw new Error("Depsgraph modifier errorCode is invalid");
}
if (suggestion !== undefined && typeof suggestion !== "string") throw new Error("Depsgraph modifier suggestion is invalid");
return {
uuid: stringField(candidate, "uuid"),
index: countField(candidate, "index"),
persistentUid: countField(candidate, "persistentUid"),
typeCode: countField(candidate, "typeCode"),
type: stringField(candidate, "type"),
name: stringField(candidate, "name"),
showViewport: booleanField(candidate, "showViewport"),
showRender: booleanField(candidate, "showRender"),
showEditmode: booleanField(candidate, "showEditmode"),
showOnCage: booleanField(candidate, "showOnCage"),
status,
...(dependsOn === undefined ? {} : { dependsOn: dependsOn as string[] }),
...(targetObjectIds === undefined ? {} : { targetObjectIds: targetObjectIds as string[] }),
...(reason === undefined ? {} : { reason }),
...(error === undefined ? {} : { error }),
...(errorCode === undefined ? {} : { errorCode: errorCode as DepsgraphModifierEvaluationIR["errorCode"] }),
...(suggestion === undefined ? {} : { suggestion }),
};
});
}
export function parseDepsgraphEvaluation(value: unknown): DepsgraphEvaluationIR {
if (!isRecord(value) || value.engine !== "BlenderDepsgraph" || value.status !== "EVALUATED") {
throw new Error("Blender Depsgraph report has an invalid status");
}
if (!Array.isArray(value.meshes)) throw new Error("Blender Depsgraph report has no mesh evaluations");
if (!Array.isArray(value.objects)) throw new Error("Blender Depsgraph report has no object evaluations");
const objects = value.objects.map((candidate) => {
if (!isRecord(candidate)) throw new Error("Blender Depsgraph object entry is invalid");
const type = stringField(candidate, "type");
if (!["MESH", "CURVE", "SURFACE", "FONT", "METABALL", "CURVES", "POINT_CLOUD", "VOLUME", "LATTICE", "ARMATURE", "GREASE_PENCIL", "EMPTY", "OTHER"].includes(type)) {
throw new Error(`Blender Depsgraph object type is invalid: ${type}`);
}
const modifiers = modifierReports(candidate);
const modifierCount = countField(candidate, "modifierCount");
if (modifiers.length !== modifierCount) throw new Error("Blender Depsgraph object modifier count is inconsistent");
return {
objectId: stringField(candidate, "objectId"),
type: type as DepsgraphObjectEvaluationIR["type"],
modifierCount,
modifiers,
};
});
const meshes = value.meshes.map((candidate) => {
if (!isRecord(candidate)) throw new Error("Blender Depsgraph mesh entry is invalid");
const vertexCount = countField(candidate, "vertexCount");
const triangleCount = countField(candidate, "triangleCount");
const modifiers = modifierReports(candidate);
const modifierCount = countField(candidate, "modifierCount");
if (modifiers.length !== modifierCount) throw new Error("Blender Depsgraph mesh modifier count is inconsistent");
const worldMatrix = numberArray(candidate, "worldMatrix");
const positions = numberArray(candidate, "positions");
const indices = numberArray(candidate, "indices");
if (worldMatrix.length !== 16 || positions.length !== vertexCount * 3 || indices.length !== triangleCount * 3) {
throw new Error(`Depsgraph mesh ${stringField(candidate, "sourceMeshId")} buffer lengths are inconsistent`);
}
if (indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= vertexCount)) {
throw new Error(`Depsgraph mesh ${stringField(candidate, "sourceMeshId")} has an invalid index`);
}
return {
objectId: stringField(candidate, "objectId"),
meshId: stringField(candidate, "meshId"),
sourceMeshId: stringField(candidate, "sourceMeshId"),
vertexCount,
triangleCount,
modifierCount,
modifiers,
worldMatrix,
positions,
indices,
};
});
const objectCount = countField(value, "objectCount");
const meshObjectCount = countField(value, "meshObjectCount");
if (objects.length !== objectCount) throw new Error("Blender Depsgraph object count is inconsistent");
if (meshes.length !== meshObjectCount) throw new Error("Blender Depsgraph mesh object count is inconsistent");
const armatures = value.armatures === undefined ? undefined : armatureReports(value.armatures);
const nonMeshGeometries = value.nonMeshGeometries === undefined ? undefined : (() => {
if (!Array.isArray(value.nonMeshGeometries)) throw new Error("Depsgraph non-mesh geometries are invalid");
return value.nonMeshGeometries.map((candidate): DepsgraphNonMeshGeometryIR => {
if (!isRecord(candidate)) throw new Error("Depsgraph non-mesh geometry entry is invalid");
const sourceType = stringField(candidate, "sourceType");
const status = stringField(candidate, "status");
if (!["CURVE", "SURFACE", "FONT", "METABALL"].includes(sourceType) || (status !== "EVALUATED" && status !== "BLOCKED")) throw new Error("Depsgraph non-mesh geometry type or status is invalid");
const vertexCount = countField(candidate, "vertexCount");
const edgeCount = countField(candidate, "edgeCount");
const triangleCount = countField(candidate, "triangleCount");
const worldMatrix = numberArray(candidate, "worldMatrix");
if (worldMatrix.length !== 16) throw new Error("Depsgraph non-mesh world matrix is invalid");
const materialSlotIds = candidate.materialSlotIds;
if (!Array.isArray(materialSlotIds) || materialSlotIds.some((item) => typeof item !== "string")) throw new Error("Depsgraph non-mesh material slots are invalid");
const common = {
objectId: stringField(candidate, "objectId"),
sourceDataId: stringField(candidate, "sourceDataId"),
sourceType: sourceType as DepsgraphNonMeshGeometryIR["sourceType"],
meshId: stringField(candidate, "meshId"),
status: status as DepsgraphNonMeshGeometryIR["status"],
vertexCount,
edgeCount,
triangleCount,
worldMatrix,
materialSlotIds: materialSlotIds as string[],
sourceMappingStatus: stringField(candidate, "sourceMappingStatus") as DepsgraphNonMeshGeometryIR["sourceMappingStatus"],
};
if (common.sourceMappingStatus !== "EVALUATED_FACE" && common.sourceMappingStatus !== "EVALUATED_EDGE") throw new Error("Depsgraph non-mesh source mapping status is invalid");
if (status === "BLOCKED") {
if (candidate.errorCode !== "NON_MESH_DATA_BUDGET_EXCEEDED") throw new Error("Depsgraph blocked non-mesh geometry has no budget error");
return { ...common, errorCode: candidate.errorCode };
}
const positions = numberArray(candidate, "positions");
const normals = numberArray(candidate, "normals");
const edgeVertexIndices = numberArray(candidate, "edgeVertexIndices");
const indices = numberArray(candidate, "indices");
const uvs = numberArray(candidate, "uvs");
const triangleMaterialIndices = numberArray(candidate, "triangleMaterialIndices");
const sourceElementIndices = numberArray(candidate, "sourceElementIndices");
if (positions.length !== vertexCount * 3 || normals.length !== vertexCount * 3 || edgeVertexIndices.length !== edgeCount * 2 || indices.length !== triangleCount * 3 || (uvs.length !== 0 && uvs.length !== triangleCount * 3 * 2) || triangleMaterialIndices.length !== triangleCount || sourceElementIndices.length !== triangleCount) throw new Error(`Depsgraph non-mesh ${common.sourceDataId} buffer lengths are inconsistent`);
if (edgeVertexIndices.some((item) => !Number.isSafeInteger(item) || item < 0 || item >= vertexCount) || indices.some((item) => !Number.isSafeInteger(item) || item < 0 || item >= vertexCount) || triangleMaterialIndices.some((item) => !Number.isSafeInteger(item) || item < 0) || sourceElementIndices.some((item) => !Number.isSafeInteger(item) || item < 0)) throw new Error(`Depsgraph non-mesh ${common.sourceDataId} indices are invalid`);
return { ...common, positions, normals, edgeVertexIndices, indices, uvs, triangleMaterialIndices, sourceElementIndices };
});
})();
return {
engine: "BlenderDepsgraph",
status: "EVALUATED",
scene: stringField(value, "scene"),
viewLayer: stringField(value, "viewLayer"),
frame: numberField(value, "frame"),
objectCount,
meshObjectCount,
objects,
meshes,
...(nonMeshGeometries === undefined ? {} : { nonMeshGeometries }),
...(armatures === undefined ? {} : { armatures }),
};
}

View File

@@ -0,0 +1,83 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const EDITOR_WORKFLOW_SCHEMA = 1 as const;
export const EDITOR_WORKFLOW_BUDGET = { maxWorkspaces: 64, maxAreas: 1_024, maxRegions: 4_096, maxSelection: 100_000, maxKeymaps: 4_096 } as const;
export const EDITOR_TYPES = ["VIEW_3D", "OUTLINER", "PROPERTIES", "UV_IMAGE", "NODE", "GRAPH", "DOPE_SHEET", "NLA", "SPREADSHEET", "TIMELINE", "CLIP", "MASK", "SEQUENCER"] as const;
export type EditorTypeIR = typeof EDITOR_TYPES[number];
export type EditorRegionKind = "HEADER" | "MAIN" | "TOOLBAR" | "SIDEBAR" | "FOOTER";
export type EditorMode = "OBJECT" | "EDIT" | "POSE";
export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: boolean }
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 EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] }
export type EditorWorkflowEditIR =
| { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string }
| { type: "SET_ACTIVE_AREA"; revision: number; areaId: string }
| { type: "SET_SELECTION"; revision: number; selectedIds: string[]; activeObjectId: string | null }
| { type: "TOGGLE_REGION"; revision: number; areaId: string; regionId: string; visible: boolean };
export class EditorWorkflowValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "EditorWorkflowValidationError"; this.code = code; }
}
const EDITOR_REGION_KINDS = new Set<EditorRegionKind>(["HEADER", "MAIN", "TOOLBAR", "SIDEBAR", "FOOTER"]);
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); return value; }
function finite(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; }
function rect(value: unknown, name: string): EditorAreaIR["rect"] {
if (!record(value)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`);
const next = { x: finite(value.x, `${name}.x`, 0, 1), y: finite(value.y, `${name}.y`, 0, 1), width: finite(value.width, `${name}.width`, 0.0001, 1), height: finite(value.height, `${name}.height`, 0.0001, 1) };
if (next.x + next.width > 1 || next.y + next.height > 1) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} exceeds workspace bounds`);
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; }
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");
if (value.workspaces.length > EDITOR_WORKFLOW_BUDGET.maxWorkspaces || value.keymaps.length > EDITOR_WORKFLOW_BUDGET.maxKeymaps) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Editor workflow exceeds the budget");
let areaCount = 0; let regionCount = 0; const workspaceIds = new Set<string>();
const workspaces = value.workspaces.map((workspaceValue, workspaceIndex): EditorWorkspaceIR => {
const name = `workspaces[${workspaceIndex}]`; if (!record(workspaceValue) || !Array.isArray(workspaceValue.areas)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`);
const id = text(workspaceValue.id, `${name}.id`); if (workspaceIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate workspace ${id}`); workspaceIds.add(id);
areaCount += workspaceValue.areas.length; if (areaCount > EDITOR_WORKFLOW_BUDGET.maxAreas) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Area count exceeds the budget");
const areaIds = new Set<string>();
const areas = workspaceValue.areas.map((areaValue, areaIndex): EditorAreaIR => {
const areaName = `${name}.areas[${areaIndex}]`; if (!record(areaValue) || !EDITOR_TYPES.includes(areaValue.editor as EditorTypeIR) || !Array.isArray(areaValue.regions)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${areaName} is invalid`);
const areaId = text(areaValue.id, `${areaName}.id`); if (areaIds.has(areaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate area ${areaId}`); areaIds.add(areaId);
regionCount += areaValue.regions.length; if (regionCount > EDITOR_WORKFLOW_BUDGET.maxRegions) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Region count exceeds the budget");
const regionIds = new Set<string>(); const regions = areaValue.regions.map((regionValue, regionIndex): EditorRegionIR => { const regionName = `${areaName}.regions[${regionIndex}]`; if (!record(regionValue) || !EDITOR_REGION_KINDS.has(regionValue.kind as EditorRegionKind)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${regionName} is invalid`); const regionId = text(regionValue.id, `${regionName}.id`); if (regionIds.has(regionId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate region ${regionId}`); regionIds.add(regionId); return { id: regionId, kind: regionValue.kind as EditorRegionKind, visible: regionValue.visible !== false }; });
return { id: areaId, editor: areaValue.editor as EditorTypeIR, regions, rect: rect(areaValue.rect, `${areaName}.rect`), maximized: areaValue.maximized === true };
});
const activeAreaId = text(workspaceValue.activeAreaId, `${name}.activeAreaId`); if (!areaIds.has(activeAreaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name}.activeAreaId is missing`);
for (let index = 0; index < areas.length; index++) for (let other = index + 1; other < areas.length; other++) if (!areas[index].maximized && !areas[other].maximized && overlap(areas[index].rect, areas[other].rect)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} has overlapping areas`);
return { id, name: text(workspaceValue.name, `${name}.name`), areas, activeAreaId, revision: integer(workspaceValue.revision, `${name}.revision`, 0, Number.MAX_SAFE_INTEGER) };
});
const contextValue = value.context; const workspaceId = text(contextValue.workspaceId, "context.workspaceId"); const workspace = workspaces.find((item) => item.id === workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context workspace is missing");
const activeAreaId = text(contextValue.activeAreaId, "context.activeAreaId"); const activeArea = workspace.areas.find((item) => item.id === activeAreaId); if (!activeArea || activeArea.editor !== contextValue.activeEditor) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context active area/editor is inconsistent");
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 }; });
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 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"; }
else if (edit.type === "SET_ACTIVE_AREA") { const workspace = clone.workspaces.find((item) => item.id === clone.context.workspaceId); const area = workspace?.areas.find((item) => item.id === edit.areaId); if (!area) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown area ${edit.areaId}`); clone.context.activeAreaId = area.id; clone.context.activeEditor = area.editor; }
else if (edit.type === "SET_SELECTION") { if (new Set(edit.selectedIds).size !== edit.selectedIds.length || edit.selectedIds.length > EDITOR_WORKFLOW_BUDGET.maxSelection || edit.selectedIds.some((id) => typeof id !== "string" || !id)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection is invalid"); if (edit.activeObjectId !== null && !edit.selectedIds.includes(edit.activeObjectId)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object must be selected"); clone.context.selection = [...edit.selectedIds]; clone.context.activeObjectId = edit.activeObjectId; }
else { const area = clone.workspaces.find((item) => item.id === clone.context.workspaceId)?.areas.find((item) => item.id === edit.areaId); const region = area?.regions.find((item) => item.id === edit.regionId); if (!region) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown region ${edit.regionId}`); region.visible = edit.visible; }
clone.context.revision += 1; return parseEditorWorkflow(clone);
}
export function gateEditorOperation(operation: "READ_ONLY_VIEW" | "SELECTION_SYNC" | "KEYMAP" | "WRITER" | "GIZMO" | "TOUCH_DRAG"): CapabilityGateResult {
if (["READ_ONLY_VIEW", "SELECTION_SYNC", "KEYMAP"].includes(operation)) return readyGate("N-024", operation);
return blockedGate("N-024", operation, [capabilityIssue(operation === "GIZMO" || operation === "TOUCH_DRAG" ? "EDITOR_GIZMO_UNAVAILABLE" : "EDITOR_WRITER_UNAVAILABLE", `${operation} requires editor-specific Main transaction and interaction verification`)]);
}

33
web/protocol/engine.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { ErrorReport } from "./error";
import type { ProgressEvent } from "./progress";
import type { SceneSnapshotIR } from "./scene-ir";
export type EngineCommand =
| { type: "init"; protocolVersion: number }
| { type: "getSceneSnapshot"; sinceRevision?: number }
| { type: "setFrame"; frame: number }
| { type: "setObjectVisibility"; objectId: string; visible: boolean }
| { type: "shutdown" };
export interface EngineRequest {
requestId: string;
expectedRevision: number;
command: EngineCommand;
}
export interface EngineCapabilities {
engine: "mock" | "blender-wasm";
protocolVersion: number;
supportsBlend: boolean;
supportsMeshEdit: boolean;
}
export interface EngineResponse {
requestId: string;
ok: boolean;
revision: number;
capabilities?: EngineCapabilities;
snapshot?: SceneSnapshotIR;
progress?: ProgressEvent;
reports?: ErrorReport[];
}

155
web/protocol/error.ts Normal file
View File

@@ -0,0 +1,155 @@
export type ErrorSeverity = "info" | "warning" | "error" | "fatal";
export type ErrorCode =
| "CAPABILITY_MISSING"
| "PROTOCOL_MISMATCH"
| "REVISION_CONFLICT"
| "INVALID_ARGUMENT"
| "WASM_INIT_FAILED"
| "WASM_OUT_OF_MEMORY"
| "BLEND_READ_FAILED"
| "BLEND_WRITE_FAILED"
| "STORAGE_QUOTA"
| "STORAGE_TRANSACTION"
| "WORKER_TERMINATED"
| "TASK_VALIDATION_FAILED"
| "LINKED_DATA_MUTATION_BLOCKED"
| "SCULPT_MESH_NOT_SINGLE_USER"
| "SCULPT_TOPOLOGY_UNSUPPORTED"
| "SCULPT_STROKE_BUDGET_EXCEEDED"
| "SCULPT_ATTRIBUTE_INVALID"
| "GN_INVALID_GRAPH"
| "GN_NODE_UNSUPPORTED"
| "GN_SOCKET_TYPE_MISMATCH"
| "GN_GROUP_RECURSION"
| "GN_EXTERNAL_RESOURCE_MISSING"
| "GN_DEPENDENCY_CYCLE"
| "GEOMETRY_NODES_SIMULATION_UNAVAILABLE"
| "SIMULATION_CACHE_INVALID"
| "SIMULATION_CACHE_MISSING"
| "SIMULATION_CACHE_HASH_MISMATCH"
| "SHADER_NODE_UNSUPPORTED"
| "SHADER_INVALID_GRAPH"
| "SHADER_GRAPH_CYCLE"
| "SHADER_SOCKET_TYPE_MISMATCH"
| "SHADER_EXTERNAL_RESOURCE_MISSING"
| "GPU_TEXTURE_INVALID"
| "GPU_TEXTURE_HASH_MISMATCH"
| "GPU_TEXTURE_BUDGET_EXCEEDED"
| "GPU_TEXTURE_DECODE_FAILED"
| "UDIM_MANIFEST_INVALID"
| "UDIM_TILE_MISSING"
| "UDIM_MULTI_TILE_UNAVAILABLE"
| "IBL_FORMAT_UNSUPPORTED"
| "IBL_ENVIRONMENT_MISSING"
| "VOLUME_SHADER_UNAVAILABLE"
| "SUBSURFACE_SHADER_UNAVAILABLE"
| "WEBGPU_RENDERER_UNAVAILABLE"
| "POSTPROCESS_PASS_UNAVAILABLE"
| "NLA_INVALID_STACK"
| "NLA_ACTION_MISSING"
| "NLA_PATH_INCOMPATIBLE"
| "NLA_TIME_WARP_UNSUPPORTED"
| "NLA_STRIP_UNSUPPORTED"
| "NON_MESH_DATA_UNSUPPORTED"
| "NON_MESH_DATA_BUDGET_EXCEEDED"
| "NON_MESH_PERFORMANCE_BUDGET_EXCEEDED"
| "NON_MESH_RESOURCE_MISSING"
| "NON_MESH_RESOURCE_OUTSIDE_PROJECT"
| "NON_MESH_VDB_BUDGET_EXCEEDED"
| "NON_MESH_BINARY_INVALID"
| "NON_MESH_DATA_SHARED"
| "NON_MESH_PROPERTY_INVALID"
| "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED"
| "SELECTION_HISTORY_INVALID"
| "SELECTION_HISTORY_BUDGET_EXCEEDED"
| "RAYCAST_HIT_INVALID"
| "SELECTION_UNDO_UNAVAILABLE"
| "GREASE_PENCIL_SCHEMA_INVALID"
| "GREASE_PENCIL_BUDGET_EXCEEDED"
| "PAINT_SCHEMA_INVALID"
| "PAINT_BUDGET_EXCEEDED"
| "PHYSICS_MANIFEST_INVALID"
| "PHYSICS_BUDGET_EXCEEDED"
| "PHYSICS_DEPENDENCY_CYCLE"
| "PHYSICS_CACHE_FRAME_MISMATCH"
| "PHYSICS_CACHE_PLAYBACK_UNAVAILABLE"
| "PHYSICS_SOLVER_UNAVAILABLE"
| "PHYSICS_SERVER_UNAVAILABLE"
| "RENDER_PROPERTY_INVALID"
| "COMPOSITOR_GRAPH_INVALID"
| "COMPOSITOR_GRAPH_CYCLE"
| "COMPOSITOR_BUDGET_EXCEEDED"
| "COMPOSITOR_RESOURCE_MISSING"
| "COMPOSITOR_NODE_UNSUPPORTED"
| "COMPOSITOR_CANCELLED"
| "SEQUENCER_SCHEMA_INVALID"
| "SEQUENCER_BUDGET_EXCEEDED"
| "SEQUENCER_DEPENDENCY_CYCLE"
| "SEQUENCER_RESOURCE_MISSING"
| "SEQUENCER_RESOURCE_OUTSIDE_PROJECT"
| "SEQUENCER_CODEC_UNSUPPORTED"
| "TRACKING_SCHEMA_INVALID"
| "TRACKING_BUDGET_EXCEEDED"
| "TRACKING_RESOURCE_OUTSIDE_PROJECT"
| "TRACKING_SOURCE_HASH_MISMATCH"
| "TRACKING_BINDING_MISSING"
| "TRACKING_SOLVE_UNAVAILABLE"
| "MASK_SCHEMA_INVALID"
| "ASSET_MANIFEST_INVALID"
| "ASSET_BUDGET_EXCEEDED"
| "ASSET_SOURCE_HASH_MISMATCH"
| "ASSET_LICENSE_MISSING"
| "LIBRARY_DEPENDENCY_CYCLE"
| "LIBRARY_MUTATION_UNAVAILABLE"
| "IO_FORMAT_UNSUPPORTED"
| "IO_ARCHIVE_UNSAFE"
| "IO_EXTERNAL_URI_BLOCKED"
| "EDITOR_LAYOUT_INVALID"
| "EDITOR_LAYOUT_BUDGET_EXCEEDED"
| "EDITOR_SELECTION_INVALID"
| "EDITOR_KEYMAP_INVALID"
| "EDITOR_WRITER_UNAVAILABLE"
| "EDITOR_GIZMO_UNAVAILABLE"
| "SCRIPT_MANIFEST_INVALID"
| "SCRIPT_POLICY_DENIED"
| "SCRIPT_SIGNATURE_INVALID"
| "SCRIPT_SANDBOX_UNAVAILABLE"
| "SCRIPT_BUDGET_EXCEEDED"
| "PLATFORM_CAPABILITY_UNAVAILABLE"
| "SERVER_JOB_UNAVAILABLE"
| "DRIVER_EXECUTION_BLOCKED"
| "ADDON_INSTALL_BLOCKED"
| "RELEASE_MANIFEST_INVALID"
| "RELEASE_GATE_BLOCKED"
| "RELEASE_EVIDENCE_MISSING"
| "RELEASE_DEPENDENCY_CYCLE"
| "RELEASE_TEST_CHANNEL_MISSING"
| "RELEASE_PERFORMANCE_MISSING"
| "RELEASE_FAULT_EVIDENCE_MISSING"
| "RELEASE_PROVENANCE_MISSING";
export interface ErrorReport {
code: ErrorCode;
severity: ErrorSeverity;
message: string;
detail?: string;
taskId?: string;
revision?: number;
recoverable: boolean;
cause?: string;
}
export function errorReport(
code: ErrorCode,
message: string,
options: Partial<Omit<ErrorReport, "code" | "message">> = {},
): ErrorReport {
return {
code,
message,
severity: "error",
recoverable: true,
...options,
};
}

View File

@@ -0,0 +1,282 @@
import type { ErrorCode } from "./error";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
export const GEOMETRY_NODE_GRAPH_SCHEMA = 1 as const;
export type GeometryNodeDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "STRING" | "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE";
export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "INSTANCE";
export type GeometryNodeSocketDirection = "INPUT" | "OUTPUT";
export interface GeometryNodeSocketIR {
id: string;
name: string;
direction: GeometryNodeSocketDirection;
dataType: GeometryNodeDataType;
domain?: GeometryNodeDomain;
defaultValue?: boolean | number | string | number[];
}
export interface GeometryNodeIR {
id: string;
type: string;
name: string;
sockets: GeometryNodeSocketIR[];
groupTreeId?: string | null;
properties?: Record<string, boolean | number | string | number[]>;
}
export interface GeometryNodeLinkIR {
fromNodeId: string;
fromSocketId: string;
toNodeId: string;
toSocketId: string;
}
export interface GeometryNodeGraphIR {
schemaVersion: typeof GEOMETRY_NODE_GRAPH_SCHEMA;
id: string;
name: string;
interfaceInputs: GeometryNodeSocketIR[];
interfaceOutputs: GeometryNodeSocketIR[];
nodes: GeometryNodeIR[];
links: GeometryNodeLinkIR[];
groupReferences?: string[];
graphHash?: string;
}
export interface GeometryNodeGraphValidation {
status: "SUPPORTED" | "BLOCKED";
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
supportedNodes: string[];
unsupportedNodes: string[];
cycles: string[][];
}
export interface GeometryNodeGraphSetValidation {
status: "SUPPORTED" | "BLOCKED";
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
cycles: string[][];
}
export interface GeometryNodeResourceContext {
availableResourceIds: ReadonlySet<string>;
blockedResourceIds?: ReadonlySet<string>;
ownerObjectId?: string;
}
export class GeometryNodeGraphError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "GeometryNodeGraphError";
this.code = code;
this.path = path;
}
}
const supportedNodeTypes = new Set([
"NodeGroupInput",
"NodeGroupOutput",
"GeometryNodeTransform",
"GeometryNodeSetPosition",
"GeometryNodeJoinGeometry",
"GeometryNodeSeparateGeometry",
"GeometryNodeRealizeInstances",
"GeometryNodeStoreNamedAttribute",
"FunctionNodeInputInt",
"FunctionNodeInputFloat",
"FunctionNodeInputVector",
"FunctionNodeCompare",
"ShaderNodeMath",
"GeometryNodeObjectInfo",
"GeometryNodeCollectionInfo",
"GeometryNodeImageInfo",
]);
const externalResourceNodeTypes = new Set([
"GeometryNodeObjectInfo",
"GeometryNodeCollectionInfo",
"GeometryNodeImageInfo",
]);
const externalResourcePrefixes: Readonly<Record<string, string>> = {
GeometryNodeObjectInfo: "object:",
GeometryNodeCollectionInfo: "collection:",
GeometryNodeImageInfo: "image:",
};
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validSocket(value: unknown, path: string): value is GeometryNodeSocketIR {
if (!record(value) || typeof value.id !== "string" || typeof value.name !== "string" || !["INPUT", "OUTPUT"].includes(value.direction as string) || !["BOOLEAN", "INT", "FLOAT", "VECTOR", "STRING", "GEOMETRY", "INSTANCE", "OBJECT", "IMAGE"].includes(value.dataType as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path);
if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "INSTANCE"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`);
if (value.defaultValue !== undefined && !(typeof value.defaultValue === "boolean" || typeof value.defaultValue === "number" || typeof value.defaultValue === "string" || (Array.isArray(value.defaultValue) && value.defaultValue.every((item) => typeof item === "number" && Number.isFinite(item))))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.defaultValue is invalid`, `${path}.defaultValue`);
return true;
}
export function parseGeometryNodeGraph(value: unknown): GeometryNodeGraphIR {
if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_GRAPH_SCHEMA) throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported GeometryNodeGraph schema");
for (const field of ["id", "name"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${field} is required`, field);
if (!Array.isArray(value.interfaceInputs) || !Array.isArray(value.interfaceOutputs) || !Array.isArray(value.nodes) || !Array.isArray(value.links)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Graph arrays are required");
value.interfaceInputs.forEach((socket, index) => {
validSocket(socket, `interfaceInputs[${index}]`);
if (record(socket) && socket.direction !== "INPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface input must be an INPUT socket", `interfaceInputs[${index}].direction`);
});
value.interfaceOutputs.forEach((socket, index) => {
validSocket(socket, `interfaceOutputs[${index}]`);
if (record(socket) && socket.direction !== "OUTPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface output must be an OUTPUT socket", `interfaceOutputs[${index}].direction`);
});
value.nodes.forEach((node, index) => {
if (!record(node) || typeof node.id !== "string" || typeof node.type !== "string" || typeof node.name !== "string" || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`);
node.sockets.forEach((socket, socketIndex) => validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`));
if (node.groupTreeId !== undefined && node.groupTreeId !== null && typeof node.groupTreeId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].groupTreeId is invalid`, `nodes[${index}].groupTreeId`);
if (node.properties !== undefined && (!record(node.properties) || Object.values(node.properties).some((item) => !(typeof item === "boolean" || typeof item === "number" || typeof item === "string" || (Array.isArray(item) && item.every((entry) => typeof entry === "number" && Number.isFinite(entry))))))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`);
});
value.links.forEach((link, index) => {
if (!record(link) || typeof link.fromNodeId !== "string" || typeof link.fromSocketId !== "string" || typeof link.toNodeId !== "string" || typeof link.toSocketId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `links[${index}] is invalid`, `links[${index}]`);
});
if (value.groupReferences !== undefined && (!Array.isArray(value.groupReferences) || value.groupReferences.some((item) => typeof item !== "string"))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain strings", "groupReferences");
return value as unknown as GeometryNodeGraphIR;
}
function compatible(from: GeometryNodeSocketIR, to: GeometryNodeSocketIR): boolean {
if (from.direction !== "OUTPUT" || to.direction !== "INPUT") return false;
if (from.dataType !== to.dataType && !((from.dataType === "INT" || from.dataType === "FLOAT") && (to.dataType === "INT" || to.dataType === "FLOAT"))) return false;
return from.domain === undefined || to.domain === undefined || from.domain === to.domain;
}
export function validateGeometryNodeGraph(
graph: GeometryNodeGraphIR,
options: { allowResolvedGroups?: boolean; resources?: GeometryNodeResourceContext } = {},
): GeometryNodeGraphValidation {
const issues: GeometryNodeGraphValidation["issues"] = [];
const nodes = new Map<string, GeometryNodeIR>();
const sockets = new Map<string, GeometryNodeSocketIR>();
for (const node of graph.nodes) {
if (nodes.has(node.id)) issues.push({ code: "GN_INVALID_GRAPH", message: `duplicate node ID: ${node.id}`, path: "nodes" });
nodes.set(node.id, node);
if (!supportedNodeTypes.has(node.type)) issues.push({ code: "GN_NODE_UNSUPPORTED", message: `unsupported node type: ${node.type}`, path: `nodes.${node.id}` });
if (externalResourceNodeTypes.has(node.type)) {
const resourceId = typeof node.properties?.resourceId === "string" ? node.properties.resourceId : "";
const expectedPrefix = externalResourcePrefixes[node.type];
if (resourceId && expectedPrefix && !resourceId.startsWith(expectedPrefix)) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `Geometry Node resource type does not match ${node.type}: ${resourceId}`, path: `nodes.${node.id}.properties.resourceId` });
}
else if (!resourceId || !options.resources?.availableResourceIds.has(resourceId)) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `Geometry Node resource is missing: ${resourceId || "none"}`, path: `nodes.${node.id}.properties.resourceId` });
}
else if (options.resources.blockedResourceIds?.has(resourceId)) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `Geometry Node resource is blocked by the library/path sandbox: ${resourceId}`, path: `nodes.${node.id}.properties.resourceId` });
}
else if (options.resources.ownerObjectId && resourceId === options.resources.ownerObjectId) {
issues.push({ code: "GN_DEPENDENCY_CYCLE", message: "Geometry Node graph cannot depend on its owner object", path: `nodes.${node.id}.properties.resourceId` });
}
}
for (const socket of node.sockets) {
if (sockets.has(`${node.id}:${socket.id}`)) issues.push({ code: "GN_INVALID_GRAPH", message: `duplicate socket ID: ${node.id}:${socket.id}`, path: `nodes.${node.id}.sockets` });
sockets.set(`${node.id}:${socket.id}`, socket);
}
}
const edges = new Map<string, string[]>();
for (const [index, link] of graph.links.entries()) {
const from = sockets.get(`${link.fromNodeId}:${link.fromSocketId}`);
const to = sockets.get(`${link.toNodeId}:${link.toSocketId}`);
if (!from || !to) {
issues.push({ code: "GN_INVALID_GRAPH", message: "link references an unknown socket", path: `links.${index}` });
continue;
}
if (!compatible(from, to)) issues.push({ code: "GN_SOCKET_TYPE_MISMATCH", message: `incompatible link ${link.fromNodeId}:${link.fromSocketId} -> ${link.toNodeId}:${link.toSocketId}`, path: `links.${index}` });
const outgoing = edges.get(link.fromNodeId) ?? [];
outgoing.push(link.toNodeId);
edges.set(link.fromNodeId, outgoing);
}
const cycles: string[][] = [];
const state = new Map<string, 0 | 1 | 2>();
const path: string[] = [];
const visit = (id: string): void => {
const current = state.get(id) ?? 0;
if (current === 2) return;
if (current === 1) {
const start = path.indexOf(id);
cycles.push(start < 0 ? [id] : [...path.slice(start), id]);
return;
}
state.set(id, 1);
path.push(id);
for (const next of edges.get(id) ?? []) visit(next);
path.pop();
state.set(id, 2);
};
for (const node of graph.nodes) visit(node.id);
if (cycles.length > 0) issues.push({ code: "GN_DEPENDENCY_CYCLE", message: "Geometry Node graph contains a cycle", path: "links" });
if (graph.groupReferences?.includes(graph.id) || graph.nodes.some((node) => node.groupTreeId === graph.id)) issues.push({ code: "GN_GROUP_RECURSION", message: "Geometry Node group recursively references itself", path: "groupReferences" });
if (!options.allowResolvedGroups && (graph.groupReferences?.some((reference) => reference !== graph.id) || graph.nodes.some((node) => node.groupTreeId !== undefined && node.groupTreeId !== null && node.groupTreeId !== graph.id))) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: "Nested Geometry Node groups require an explicit graph set", path: "groupReferences" });
}
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues, supportedNodes: graph.nodes.filter((node) => supportedNodeTypes.has(node.type)).map((node) => node.id), unsupportedNodes: graph.nodes.filter((node) => !supportedNodeTypes.has(node.type)).map((node) => node.id), cycles };
}
export function validateGeometryNodeGraphSet(values: readonly unknown[]): GeometryNodeGraphSetValidation {
const issues: GeometryNodeGraphSetValidation["issues"] = [];
const graphs: GeometryNodeGraphIR[] = [];
const graphIds = new Set<string>();
for (const [index, value] of values.entries()) {
try {
const graph = parseGeometryNodeGraph(value);
if (graphIds.has(graph.id)) issues.push({ code: "GN_INVALID_GRAPH", message: `duplicate graph ID: ${graph.id}`, path: `graphs.${index}.id` });
graphIds.add(graph.id);
graphs.push(graph);
}
catch (error) {
const issue = error as GeometryNodeGraphError;
issues.push({ code: issue.code ?? "GN_INVALID_GRAPH", message: issue.message, path: issue.path });
}
}
const dependencies = new Map<string, string[]>();
for (const graph of graphs) {
const references = new Set([...(graph.groupReferences ?? []), ...graph.nodes.flatMap((node) => node.groupTreeId ? [node.groupTreeId] : [])]);
for (const reference of references) {
if (!graphIds.has(reference)) issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `missing Geometry Node group: ${reference}`, path: `graphs.${graph.id}.groupReferences` });
dependencies.set(graph.id, [...(dependencies.get(graph.id) ?? []), reference]);
}
}
const cycles: string[][] = [];
const state = new Map<string, 0 | 1 | 2>();
const path: string[] = [];
const visit = (id: string): void => {
const current = state.get(id) ?? 0;
if (current === 2) return;
if (current === 1) {
const start = path.indexOf(id);
cycles.push(start < 0 ? [id] : [...path.slice(start), id]);
return;
}
state.set(id, 1);
path.push(id);
for (const next of dependencies.get(id) ?? []) if (graphIds.has(next)) visit(next);
path.pop();
state.set(id, 2);
};
for (const graph of graphs) visit(graph.id);
if (cycles.length > 0) issues.push({ code: "GN_GROUP_RECURSION", message: "Geometry Node group set contains recursive references", path: "graphs" });
for (const graph of graphs) {
const validation = validateGeometryNodeGraph(graph, { allowResolvedGroups: true });
issues.push(...validation.issues.map((issue) => ({ ...issue, path: issue.path ? `graphs.${graph.id}.${issue.path}` : `graphs.${graph.id}` })));
}
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues, cycles };
}
export function gateGeometryNodeGraph(value: unknown, resources?: GeometryNodeResourceContext): CapabilityGateResult {
try {
const graph = parseGeometryNodeGraph(value);
const validation = validateGeometryNodeGraph(graph, { resources });
if (validation.status === "SUPPORTED") return readyGate("N-012", "GEOMETRY_NODE_GRAPH");
return blockedGate("N-012", "GEOMETRY_NODE_GRAPH", validation.issues.map((issue) => capabilityIssue(issue.code, issue.message, issue.path)));
}
catch (error) {
const issue = error as GeometryNodeGraphError;
return blockedGate("N-012", "GEOMETRY_NODE_GRAPH", [capabilityIssue(issue.code ?? "GN_INVALID_GRAPH", issue.message, issue.path)]);
}
}

719
web/protocol/glb-export.ts Normal file
View File

@@ -0,0 +1,719 @@
import type { MeshGeometryBuffer } from "./web-engine";
import type { MaterialIR, MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
import type { NonMeshGeometryChunk } from "./nonmesh-binary";
import { mapBinaryNonMeshForExport } from "./nonmesh-export";
export interface GLBAssetBuffer {
assetId: string;
mimeType: string;
data: ArrayBuffer;
}
export type GLBWarningCode =
| "SUMMARY_ONLY_MESH"
| "MISSING_GEOMETRY_BUFFER"
| "EXTERNAL_IMAGE"
| "PACKED_IMAGE_UNAVAILABLE"
| "LINKED_MATERIAL_INPUT_UNEVALUATED"
| "SHADER_GRAPH_UNMAPPABLE"
| "MODIFIER_STACK_NOT_BAKED"
| "SKIN_REMAP_UNAVAILABLE"
| "SHAPE_KEY_DATA_INVALID"
| "NON_MESH_EVALUATION_REQUIRED"
| "GLB_NON_MESH_UNMAPPED"
| "GLB_VOLUME_UNSUPPORTED"
| "NON_MESH_ATTRIBUTE_LOSS"
| "NO_EXPORTABLE_GEOMETRY";
export interface GLBExportWarning {
code: GLBWarningCode;
severity: "warning" | "error";
message: string;
id?: string;
}
export interface GLBExportReport {
canExport: boolean;
warnings: GLBExportWarning[];
}
export interface GLBExportResult {
report: GLBExportReport;
glb?: ArrayBuffer;
}
interface Accessor {
bufferView: number;
componentType: number;
count: number;
type: "SCALAR" | "VEC2" | "VEC3" | "VEC4" | "MAT4";
min?: number[];
max?: number[];
normalized?: boolean;
}
interface BufferView {
buffer: number;
byteOffset: number;
byteLength: number;
target?: number;
}
interface Primitive {
attributes: Record<string, number>;
indices: number;
mode?: 0 | 1;
material?: number;
targets?: Array<Record<string, number>>;
}
interface AnimationGroup {
node: number;
path: "translation" | "rotation" | "scale";
source: "VECTOR" | "EULER" | "QUATERNION";
components: Map<number, Map<number, number>>;
}
const COMPONENT_FLOAT = 5126;
const COMPONENT_UNSIGNED_SHORT = 5123;
const COMPONENT_UNSIGNED_INT = 5125;
const TARGET_ARRAY_BUFFER = 34962;
const TARGET_ELEMENT_ARRAY_BUFFER = 34963;
function align4(value: number): number {
return (value + 3) & ~3;
}
function minMax(values: ArrayLike<number>, width: number): { min: number[]; max: number[] } {
const min = Array.from({ length: width }, () => Number.POSITIVE_INFINITY);
const max = Array.from({ length: width }, () => Number.NEGATIVE_INFINITY);
for (let index = 0; index < values.length; index += width) {
for (let component = 0; component < width; component++) {
const value = values[index + component];
min[component] = Math.min(min[component], value);
max[component] = Math.max(max[component], value);
}
}
return { min, max };
}
function appendBytes(parts: Uint8Array[], currentLength: number, bytes: Uint8Array): number {
const offset = align4(currentLength);
if (offset > currentLength) parts.push(new Uint8Array(offset - currentLength));
parts.push(bytes);
return offset + bytes.byteLength;
}
function typedArrayBytes(values: Float32Array | Uint16Array | Uint32Array): Uint8Array {
return new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
}
function matrixMultiply(left: number[], right: number[]): number[] {
const result = new Array<number>(16).fill(0);
for (let column = 0; column < 4; column++) {
for (let row = 0; row < 4; row++) {
for (let index = 0; index < 4; index++) result[column * 4 + row] += left[index * 4 + row] * right[column * 4 + index];
}
}
return result;
}
function matrixInverse(matrix: readonly number[]): number[] {
const rows = Array.from({ length: 4 }, (_, row) => [
matrix[row], matrix[4 + row], matrix[8 + row], matrix[12 + row],
row === 0 ? 1 : 0, row === 1 ? 1 : 0, row === 2 ? 1 : 0, row === 3 ? 1 : 0,
]);
for (let column = 0; column < 4; column++) {
let pivot = column;
for (let row = column + 1; row < 4; row++) if (Math.abs(rows[row][column]) > Math.abs(rows[pivot][column])) pivot = row;
if (Math.abs(rows[pivot][column]) < 1e-10) throw new Error("matrix is singular");
[rows[column], rows[pivot]] = [rows[pivot], rows[column]];
const divisor = rows[column][column];
for (let index = 0; index < 8; index++) rows[column][index] /= divisor;
for (let row = 0; row < 4; row++) if (row !== column) {
const factor = rows[row][column];
for (let index = 0; index < 8; index++) rows[row][index] -= factor * rows[column][index];
}
}
const inverse = new Array<number>(16);
for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) inverse[column * 4 + row] = rows[row][4 + column];
return inverse;
}
function convertMatrix(matrix: readonly number[]): number[] {
const basis = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 0, 1];
return matrixMultiply(matrixMultiply(basis, Array.from(matrix)), basis);
}
function convertPosition(values: ArrayLike<number>): Float32Array {
const result = new Float32Array(values.length);
for (let index = 0; index < values.length; index += 3) {
result[index] = values[index];
result[index + 1] = values[index + 2];
result[index + 2] = -values[index + 1];
}
return result;
}
function convertNormal(values: ArrayLike<number>): Float32Array {
return convertPosition(values);
}
function convertPositionDelta(target: ArrayLike<number>, base: ArrayLike<number>): Float32Array {
const result = new Float32Array(target.length);
for (let index = 0; index < target.length; index += 3) {
result[index] = target[index] - base[index];
result[index + 1] = target[index + 2] - base[index + 2];
result[index + 2] = -(target[index + 1] - base[index + 1]);
}
return result;
}
function eulerXYZQuaternion(euler: readonly number[]): [number, number, number, number] {
const hx = euler[0] * 0.5, hy = euler[1] * 0.5, hz = euler[2] * 0.5;
const sx = Math.sin(hx), cx = Math.cos(hx), sy = Math.sin(hy), cy = Math.cos(hy), sz = Math.sin(hz), cz = Math.cos(hz);
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 convertQuaternion(values: readonly number[], source: "EULER" | "QUATERNION"): [number, number, number, number] {
const blender = source === "EULER" ? eulerXYZQuaternion(values) : [values[1], values[2], values[3], values[0]] as [number, number, number, number];
return [blender[0], blender[2], -blender[1], blender[3]];
}
function geometryArray<T extends Float32Array | Uint32Array>(payload: MeshGeometryBuffer | undefined, summary: MeshSummaryIR, key: "positions" | "indices" | "normals" | "uvs" | "colors" | "triangleMaterialIndices"): T | undefined {
if (payload) {
const data = payload[key];
if (data) return new (key === "indices" || key === "triangleMaterialIndices" ? Uint32Array : Float32Array)(data) as T;
}
const data = summary[key];
if (data) return new (key === "indices" || key === "triangleMaterialIndices" ? Uint32Array : Float32Array)(data) as T;
return undefined;
}
interface ShaderPbrMapping {
baseColorImageId?: string;
normalImageId?: string;
baseColorFactor?: [number, number, number, number];
roughnessFactor?: number;
metallicFactor?: number;
}
function shaderColor(node: NonNullable<MaterialIR["nodes"]>[number]): [number, number, number, number] | undefined {
const value = node.defaultValue;
return value?.length === 4 && value.every((component) => Number.isFinite(component) && component >= 0 && component <= 1)
? [value[0], value[1], value[2], value[3]]
: undefined;
}
function shaderFactor(node: NonNullable<MaterialIR["nodes"]>[number]): number | undefined {
const value = node.defaultValue?.[0];
return node.defaultValue?.length === 1 && value !== undefined && Number.isFinite(value) && value >= 0 && value <= 1 ? value : undefined;
}
function shaderPbrMapping(material: MaterialIR): ShaderPbrMapping | string {
if (!material.nodes || material.nodes.length === 0) return {};
const nodes = new Map(material.nodes.map((node) => [node.id, node]));
const count = (type: string) => material.nodes?.filter((node) => node.type === type).length ?? 0;
if (count("PRINCIPLED") !== 1 || count("OUTPUT") !== 1) {
return "Shader graph must contain exactly one Principled and one Material Output node";
}
const unsupported = material.nodes.find((node) => !["RGB", "VALUE", "PRINCIPLED", "IMAGE_TEXTURE", "NORMAL_MAP", "OUTPUT"].includes(node.type));
if (unsupported) return "Shader node " + unsupported.type + " cannot be represented by glTF PBR";
const principled = material.nodes.find((node) => node.type === "PRINCIPLED");
const output = material.nodes.find((node) => node.type === "OUTPUT");
if (!principled || !output) return "Shader graph is incomplete";
const mapping: ShaderPbrMapping = {};
const normalInputs = new Map<string, string>();
const mappedInputs = new Set<string>();
let hasSurface = false;
for (const link of material.links ?? []) {
const from = nodes.get(link.fromNodeId);
const to = nodes.get(link.toNodeId);
if (!from || !to) return "Shader graph link references a missing node";
if (from.id === principled.id && link.fromSocket === "BSDF" && to.id === output.id && link.toSocket === "Surface") {
hasSurface = true;
continue;
}
if (from.type === "IMAGE_TEXTURE" && from.imageId && to.id === principled.id && link.fromSocket === "Color" && link.toSocket === "Base Color") {
if (mappedInputs.has("Base Color")) return "Shader graph has multiple Base Color inputs";
mappedInputs.add("Base Color");
mapping.baseColorImageId = from.imageId;
continue;
}
if (from.type === "RGB" && to.id === principled.id && link.fromSocket === "Color" && link.toSocket === "Base Color") {
const factor = shaderColor(from);
if (!factor) return "RGB Base Color must contain four finite values in the glTF [0, 1] range";
if (mappedInputs.has("Base Color")) return "Shader graph has multiple Base Color inputs";
mappedInputs.add("Base Color");
mapping.baseColorFactor = factor;
continue;
}
if (from.type === "VALUE" && to.id === principled.id && link.fromSocket === "Value" && (link.toSocket === "Roughness" || link.toSocket === "Metallic")) {
const factor = shaderFactor(from);
if (factor === undefined) return `Value ${link.toSocket} must contain one finite value in the glTF [0, 1] range`;
if (mappedInputs.has(link.toSocket)) return `Shader graph has multiple ${link.toSocket} inputs`;
mappedInputs.add(link.toSocket);
if (link.toSocket === "Roughness") mapping.roughnessFactor = factor;
else mapping.metallicFactor = factor;
continue;
}
if (from.type === "IMAGE_TEXTURE" && from.imageId && to.type === "NORMAL_MAP" && link.fromSocket === "Color" && link.toSocket === "Color") {
normalInputs.set(to.id, from.imageId);
continue;
}
if (from.type === "NORMAL_MAP" && to.id === principled.id && link.fromSocket === "Normal" && link.toSocket === "Normal") {
const imageId = normalInputs.get(from.id);
if (!imageId) return "Normal Map input is not an Image Texture output";
if (mapping.normalImageId) return "Shader graph has multiple normal textures";
mapping.normalImageId = imageId;
continue;
}
return "Shader link " + from.type + "." + link.fromSocket + " -> " + to.type + "." + link.toSocket + " cannot be represented by glTF PBR";
}
if (!hasSurface) return "Shader graph Material Output has no Principled BSDF surface link";
return mapping;
}
function materialJSON(material: MaterialIR, textureIndexByImageId: ReadonlyMap<string, number>): Record<string, unknown> {
const mapping = shaderPbrMapping(material);
if (typeof mapping === "string") throw new Error("Unmappable Shader graph escaped export validation: " + mapping);
const alphaMode = material.alpha < 0.999 ? "BLEND" : "OPAQUE";
// A reader may expose Image Texture metadata without a fully serializable node graph.
// Preserve that bounded base-color/normal path instead of silently dropping the asset.
const baseColorImageId = mapping.baseColorImageId ?? (material.nodes?.length ? undefined : material.imageIds?.[0]);
const normalImageId = mapping.normalImageId ?? material.normalImageId;
const baseColorTexture = baseColorImageId ? textureIndexByImageId.get(baseColorImageId) : undefined;
const normalTexture = normalImageId ? textureIndexByImageId.get(normalImageId) : undefined;
const pbr: Record<string, unknown> = {
baseColorFactor: mapping.baseColorFactor ?? material.baseColor,
metallicFactor: mapping.metallicFactor ?? material.metallic,
roughnessFactor: mapping.roughnessFactor ?? material.roughness,
};
if (baseColorTexture !== undefined) pbr.baseColorTexture = { index: baseColorTexture };
const extensions: Record<string, unknown> = {};
if (Math.abs(material.ior - 1.5) > 1e-6) extensions.KHR_materials_ior = { ior: material.ior };
if ((material.transmissionWeight ?? 0) > 0) extensions.KHR_materials_transmission = { transmissionFactor: material.transmissionWeight };
if ((material.coatWeight ?? 0) > 0) extensions.KHR_materials_clearcoat = {
clearcoatFactor: material.coatWeight,
clearcoatRoughnessFactor: material.coatRoughness ?? 0.03,
};
if (Math.abs((material.specularIORLevel ?? 0.5) - 0.5) > 1e-6) extensions.KHR_materials_specular = {
// Blender's Specular IOR Level is half the glTF KHR_materials_specular factor.
specularFactor: Math.min(1, Math.max(0, (material.specularIORLevel ?? 0.5) * 2)),
};
if (Math.abs((material.emissionStrength ?? 1) - 1) > 1e-6) extensions.KHR_materials_emissive_strength = { emissiveStrength: material.emissionStrength };
return {
name: material.name,
pbrMetallicRoughness: pbr,
...(normalTexture === undefined ? {} : { normalTexture: { index: normalTexture } }),
emissiveFactor: material.emissionColor.slice(0, 3),
...(Object.keys(extensions).length === 0 ? {} : { extensions }),
alphaMode,
alphaCutoff: 0.5,
doubleSided: true,
extras: { blenderId: material.id, ior: material.ior, emissionColor: material.emissionColor },
};
}
function meshWarnings(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], assetBuffers: readonly GLBAssetBuffer[]): GLBExportWarning[] {
const warnings: GLBExportWarning[] = [];
const bufferIds = new Set(geometryBuffers.map((geometry) => geometry.meshId));
const assetIds = new Set(assetBuffers.map((asset) => asset.assetId));
let exportableGeometry = 0;
for (const mesh of snapshot.meshes) {
if (mesh.geometryStatus === "summary-only") warnings.push({ code: "SUMMARY_ONLY_MESH", severity: "error", message: `Mesh ${mesh.name} has summary-only geometry`, id: mesh.id });
const geometryId = mesh.geometryBufferId ?? mesh.id;
if (mesh.geometryStatus === "binary" && !bufferIds.has(geometryId)) warnings.push({ code: "MISSING_GEOMETRY_BUFFER", severity: "error", message: `Mesh ${mesh.name} has no transferable geometry buffer`, id: mesh.id });
if (mesh.geometryStatus === "available" || bufferIds.has(geometryId)) exportableGeometry++;
if (mesh.modifierStack?.some((modifier) => modifier.enabled)) warnings.push({ code: "MODIFIER_STACK_NOT_BAKED", severity: "warning", message: `Mesh ${mesh.name} has enabled modifiers that are not baked for export`, id: mesh.id });
if (mesh.skinWeights) {
const armature = mesh.skinWeights.armatureId ? snapshot.armatures?.find((candidate) => candidate.id === mesh.skinWeights?.armatureId) : undefined;
const boneIds = new Set(armature?.bones.map((bone) => bone.id));
const hasArmature = Boolean(armature && mesh.skinWeights.jointIds?.length === mesh.skinWeights.boneNames.length && mesh.skinWeights.jointIds.every((id) => boneIds.has(id)));
warnings.push(...(hasArmature ? [] : [{ code: "SKIN_REMAP_UNAVAILABLE" as const, severity: "error" as const, message: `Mesh ${mesh.name} skin weights require an armature joint mapping`, id: mesh.id }]));
}
for (const shape of mesh.shapeKeys ?? []) {
if (shape.positions.length !== mesh.vertexCount * 3) warnings.push({ code: "SHAPE_KEY_DATA_INVALID", severity: "error", message: `Shape key ${shape.name} does not match ${mesh.name} vertex count`, id: mesh.id });
}
}
for (const data of snapshot.nonMeshData ?? []) {
if (["CURVE", "SURFACE", "FONT", "METABALL"].includes(data.type)) {
const evaluated = data.evaluatedGeometry?.filter((geometry) => geometry.status === "EVALUATED") ?? [];
if (evaluated.length === 0) {
warnings.push({ code: "NON_MESH_EVALUATION_REQUIRED", severity: "error", message: `${data.type} ${data.name} requires Blender evaluated geometry before GLB export`, id: data.id });
}
else if (evaluated.every((geometry) => geometry.triangleCount === 0 && (geometry.edgeCount ?? 0) === 0)) {
warnings.push({ code: "GLB_NON_MESH_UNMAPPED", severity: "error", message: `${data.type} ${data.name} evaluated geometry has neither triangles nor edges for GLB export`, id: data.id });
}
}
else if (data.type === "VOLUME") {
warnings.push({ code: "GLB_VOLUME_UNSUPPORTED", severity: "error", message: `Volume ${data.name} cannot be represented by GLB`, id: data.id });
}
else {
warnings.push({ code: "GLB_NON_MESH_UNMAPPED", severity: "error", message: `${data.type} ${data.name} requires an explicit mesh bake for GLB export`, id: data.id });
}
}
if (exportableGeometry === 0) warnings.push({ code: "NO_EXPORTABLE_GEOMETRY", severity: "error", message: "The scene has no exportable mesh geometry" });
for (const image of snapshot.images) {
if (assetIds.has(image.assetId)) continue;
if (image.packed) warnings.push({ code: "PACKED_IMAGE_UNAVAILABLE", severity: "warning", message: `Packed image ${image.name} has no decoded pixel asset`, id: image.id });
else if (image.sourcePath) warnings.push({ code: "EXTERNAL_IMAGE", severity: "warning", message: `External image ${image.name} is not embedded; the GLB uses material factors only`, id: image.id });
}
for (const material of snapshot.materials) {
if (material.warnings?.some((warning) => warning.includes("linked_input_not_evaluated"))) warnings.push({ code: "LINKED_MATERIAL_INPUT_UNEVALUATED", severity: "warning", message: `Material ${material.name} contains unevaluated linked inputs`, id: material.id });
const mapping = shaderPbrMapping(material);
if (typeof mapping === "string") warnings.push({ code: "SHADER_GRAPH_UNMAPPABLE", severity: "error", message: "Material " + material.name + ": " + mapping, id: material.id });
}
return warnings;
}
export function analyzeGLBExport(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[] = [], assetBuffers: readonly GLBAssetBuffer[] = []): GLBExportReport {
const warnings = meshWarnings(snapshot, geometryBuffers, assetBuffers);
return { canExport: warnings.every((warning) => warning.severity !== "error"), warnings };
}
function appendAccessor(
parts: Uint8Array[],
binaryLength: number,
bufferViews: BufferView[],
accessors: Accessor[],
values: Float32Array | Uint16Array | Uint32Array,
type: Accessor["type"],
target?: number,
): { index: number; length: number } {
const offset = align4(binaryLength);
const length = appendBytes(parts, binaryLength, typedArrayBytes(values));
const viewIndex = bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: values.byteLength, target }) - 1;
const width = type === "SCALAR" ? 1 : type === "MAT4" ? 16 : Number(type.slice(3));
const bounds = type === "SCALAR" || type === "MAT4" ? undefined : minMax(values, width);
const accessor: Accessor = { bufferView: viewIndex, componentType: values instanceof Float32Array ? COMPONENT_FLOAT : values instanceof Uint16Array ? COMPONENT_UNSIGNED_SHORT : COMPONENT_UNSIGNED_INT, count: values.length / width, type };
if (bounds) { accessor.min = bounds.min; accessor.max = bounds.max; }
accessors.push(accessor);
return { index: accessors.length - 1, length };
}
function buildGLB(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], assetBuffers: readonly GLBAssetBuffer[]): ArrayBuffer {
const parts: Uint8Array[] = [];
let binaryLength = 0;
const bufferViews: BufferView[] = [];
const accessors: Accessor[] = [];
const materialIndex = new Map(snapshot.materials.map((material, index) => [material.id, index]));
const bufferById = new Map(geometryBuffers.map((geometry) => [geometry.meshId, geometry]));
const textureIndexByImageId = new Map<string, number>();
const gltfImages: Array<Record<string, unknown>> = [];
const gltfTextures: Array<Record<string, unknown>> = [];
const imageByAssetId = new Map(snapshot.images.map((image) => [image.assetId, image]));
for (const asset of assetBuffers) {
const image = imageByAssetId.get(asset.assetId);
if (!image || asset.data.byteLength === 0) continue;
const offset = align4(binaryLength);
binaryLength = appendBytes(parts, binaryLength, new Uint8Array(asset.data));
const viewIndex = bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: asset.data.byteLength }) - 1;
const imageIndex = gltfImages.push({ name: image.name, bufferView: viewIndex, mimeType: asset.mimeType, extras: { blenderId: image.id } }) - 1;
const textureIndex = gltfTextures.push({ source: imageIndex, sampler: 0, name: image.name, extras: { blenderId: image.id } }) - 1;
textureIndexByImageId.set(image.id, textureIndex);
}
const materials = snapshot.materials.map((material) => materialJSON(material, textureIndexByImageId));
const gltfMeshes: Array<{ name: string; primitives: Primitive[]; extras?: Record<string, unknown> }> = [];
const meshIndexById = new Map<string, number>();
const skinnedMeshes = new Map<string, MeshSummaryIR>();
for (const summary of snapshot.meshes) {
const geometryId = summary.geometryBufferId ?? summary.id;
const payload = bufferById.get(geometryId) ?? bufferById.get(summary.id);
const positionsSource = geometryArray<Float32Array>(payload, summary, "positions");
const linePrimitive = summary.topology === "lines";
const pointPrimitive = summary.topology === "points";
const indicesSource = linePrimitive
? payload?.edgeVertexIndices ? new Uint32Array(payload.edgeVertexIndices) : summary.edgeVertexIndices ? Uint32Array.from(summary.edgeVertexIndices) : undefined
: geometryArray<Uint32Array>(payload, summary, "indices");
if (!positionsSource || !indicesSource || positionsSource.length % 3 !== 0 || indicesSource.length % (linePrimitive ? 2 : pointPrimitive ? 1 : 3) !== 0) continue;
if (indicesSource.some((index) => index >= positionsSource.length / 3)) continue;
const normalsSource = linePrimitive || pointPrimitive ? undefined : geometryArray<Float32Array>(payload, summary, "normals");
const cornerSource = payload?.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : summary.triangleCornerIndices ? new Uint32Array(summary.triangleCornerIndices) : undefined;
const uvsSource = geometryArray<Float32Array>(payload, summary, "uvs");
const colorsSource = geometryArray<Float32Array>(payload, summary, "colors");
const materialSource = linePrimitive || pointPrimitive ? undefined : geometryArray<Uint32Array>(payload, summary, "triangleMaterialIndices");
const vertexCount = positionsSource.length / 3;
const hasCornerUVs = Boolean(uvsSource && uvsSource.length === indicesSource.length * 2 && uvsSource.length !== vertexCount * 2);
const hasCornerColors = Boolean(colorsSource && colorsSource.length === indicesSource.length * 4 && colorsSource.length !== vertexCount * 4);
const deindexed = Boolean((cornerSource && (uvsSource || colorsSource)) || hasCornerUVs || hasCornerColors);
const positions = deindexed ? new Float32Array(indicesSource.length * 3) : convertPosition(positionsSource);
const normals = normalsSource ? (deindexed ? new Float32Array(indicesSource.length * 3) : convertNormal(normalsSource)) : undefined;
const uvs = uvsSource ? new Float32Array((deindexed ? indicesSource.length : positionsSource.length / 3) * 2) : undefined;
const colors = colorsSource ? new Float32Array((deindexed ? indicesSource.length : positionsSource.length / 3) * 4) : undefined;
const hasSkin = Boolean(summary.skinWeights && summary.skinWeights.indices.length === vertexCount * 4 && summary.skinWeights.weights.length === vertexCount * 4 && summary.skinWeights.armatureId);
const joints = hasSkin ? new Uint16Array((deindexed ? indicesSource.length : vertexCount) * 4) : undefined;
const skinWeights = hasSkin ? new Float32Array((deindexed ? indicesSource.length : vertexCount) * 4) : undefined;
const indices = deindexed ? new Uint32Array(indicesSource.length).map((_, index) => index) : new Uint32Array(indicesSource);
if (deindexed) {
for (let corner = 0; corner < indicesSource.length; corner++) {
const vertex = indicesSource[corner];
positions[corner * 3] = positionsSource[vertex * 3];
positions[corner * 3 + 1] = positionsSource[vertex * 3 + 2];
positions[corner * 3 + 2] = -positionsSource[vertex * 3 + 1];
if (normals && normalsSource) {
normals[corner * 3] = normalsSource[vertex * 3];
normals[corner * 3 + 1] = normalsSource[vertex * 3 + 2];
normals[corner * 3 + 2] = -normalsSource[vertex * 3 + 1];
}
const sourceCorner = cornerSource?.[corner] ?? vertex;
if (uvs && uvsSource && sourceCorner * 2 + 1 < uvsSource.length) uvs.set(uvsSource.subarray(sourceCorner * 2, sourceCorner * 2 + 2), corner * 2);
if (colors && colorsSource && sourceCorner * 4 + 3 < colorsSource.length) colors.set(colorsSource.subarray(sourceCorner * 4, sourceCorner * 4 + 4), corner * 4);
if (joints && skinWeights && summary.skinWeights) {
let total = 0;
for (let slot = 0; slot < 4; slot++) total += summary.skinWeights.weights[vertex * 4 + slot];
for (let slot = 0; slot < 4; slot++) {
joints[corner * 4 + slot] = summary.skinWeights.indices[vertex * 4 + slot];
skinWeights[corner * 4 + slot] = total > 0 ? summary.skinWeights.weights[vertex * 4 + slot] / total : slot === 0 ? 1 : 0;
}
}
}
} else {
if (uvs && uvsSource && uvsSource.length === uvs.length) uvs.set(uvsSource);
if (colors && colorsSource && colorsSource.length === colors.length) colors.set(colorsSource);
if (joints && skinWeights && summary.skinWeights) {
joints.set(summary.skinWeights.indices);
skinWeights.set(summary.skinWeights.weights);
for (let vertex = 0; vertex < vertexCount; vertex++) {
let total = 0;
for (let slot = 0; slot < 4; slot++) total += skinWeights[vertex * 4 + slot];
for (let slot = 0; slot < 4; slot++) skinWeights[vertex * 4 + slot] = total > 0 ? skinWeights[vertex * 4 + slot] / total : slot === 0 ? 1 : 0;
}
}
}
const positionAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, positions, "VEC3", TARGET_ARRAY_BUFFER); binaryLength = positionAccessor.length;
const normalAccessor = normals ? appendAccessor(parts, binaryLength, bufferViews, accessors, normals, "VEC3", TARGET_ARRAY_BUFFER) : undefined; if (normalAccessor) binaryLength = normalAccessor.length;
const uvAccessor = uvs ? appendAccessor(parts, binaryLength, bufferViews, accessors, uvs, "VEC2", TARGET_ARRAY_BUFFER) : undefined; if (uvAccessor) binaryLength = uvAccessor.length;
const colorAccessor = colors ? appendAccessor(parts, binaryLength, bufferViews, accessors, colors, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (colorAccessor) binaryLength = colorAccessor.length;
const jointAccessor = joints ? appendAccessor(parts, binaryLength, bufferViews, accessors, joints, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (jointAccessor) binaryLength = jointAccessor.length;
const weightAccessor = skinWeights ? appendAccessor(parts, binaryLength, bufferViews, accessors, skinWeights, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (weightAccessor) binaryLength = weightAccessor.length;
const indexAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, indices, "SCALAR", TARGET_ELEMENT_ARRAY_BUFFER); binaryLength = indexAccessor.length;
const shapeTargets: Array<Record<string, number>> = [];
const shapeNames: string[] = [];
for (const shape of summary.shapeKeys ?? []) {
if (shape.positions.length !== positionsSource.length) continue;
const shapePositions = deindexed ? new Float32Array(indicesSource.length * 3) : convertPositionDelta(shape.positions, positionsSource);
if (deindexed) for (let corner = 0; corner < indicesSource.length; corner++) {
const vertex = indicesSource[corner];
shapePositions[corner * 3] = shape.positions[vertex * 3] - positionsSource[vertex * 3];
shapePositions[corner * 3 + 1] = shape.positions[vertex * 3 + 2] - positionsSource[vertex * 3 + 2];
shapePositions[corner * 3 + 2] = -(shape.positions[vertex * 3 + 1] - positionsSource[vertex * 3 + 1]);
}
const targetAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, shapePositions, "VEC3", TARGET_ARRAY_BUFFER); binaryLength = targetAccessor.length;
shapeTargets.push({ POSITION: targetAccessor.index });
shapeNames.push(shape.name);
}
const attributes: Record<string, number> = { POSITION: positionAccessor.index };
if (normalAccessor) attributes.NORMAL = normalAccessor.index;
if (uvAccessor) attributes.TEXCOORD_0 = uvAccessor.index;
if (colorAccessor) attributes.COLOR_0 = colorAccessor.index;
if (jointAccessor) attributes.JOINTS_0 = jointAccessor.index;
if (weightAccessor) attributes.WEIGHTS_0 = weightAccessor.index;
const primitives: Primitive[] = [];
const materialGroups = materialSource && materialSource.length === indicesSource.length / 3 ? new Map<number, number[]>() : new Map<number, number[]>([[0, Array.from({ length: indices.length }, (_, index) => index)] ]);
if (materialSource && materialSource.length === indicesSource.length / 3) for (let triangle = 0; triangle < materialSource.length; triangle++) {
const key = materialSource[triangle];
const group = materialGroups.get(key) ?? [];
group.push(triangle * 3, triangle * 3 + 1, triangle * 3 + 2);
materialGroups.set(key, group);
}
for (const [slot, group] of materialGroups) {
const groupIndices = deindexed ? Uint32Array.from(group) : Uint32Array.from(group.map((index) => indices[index]));
const groupAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, groupIndices, "SCALAR", TARGET_ELEMENT_ARRAY_BUFFER); binaryLength = groupAccessor.length;
const primitive: Primitive = { attributes, indices: groupAccessor.index, ...(linePrimitive ? { mode: 1 as const } : pointPrimitive ? { mode: 0 as const } : {}) };
const materialId = summary.materialSlotIds?.[slot];
if (materialId && materialIndex.has(materialId)) primitive.material = materialIndex.get(materialId);
if (shapeTargets.length > 0) primitive.targets = shapeTargets;
primitives.push(primitive);
}
meshIndexById.set(summary.id, gltfMeshes.length);
if (hasSkin) skinnedMeshes.set(summary.id, summary);
gltfMeshes.push({ name: summary.name, primitives, extras: { blenderId: summary.id, sourceRevision: snapshot.revision, targetNames: shapeNames } });
}
const nodes = snapshot.nodes.map((node: SceneNodeIR) => {
const result: Record<string, unknown> = { name: node.name, matrix: convertMatrix(node.localMatrix), extras: { blenderId: node.id, visible: node.visible, selectable: node.selectable } };
if (node.dataId && meshIndexById.has(node.dataId)) result.mesh = meshIndexById.get(node.dataId);
return result;
});
const nodeIndex = new Map(snapshot.nodes.map((node, index) => [node.id, index]));
for (const [index, node] of snapshot.nodes.entries()) if (node.parentId && nodeIndex.has(node.parentId)) {
const parent = nodes[nodeIndex.get(node.parentId)!] as { children?: number[] };
parent.children = [...(parent.children ?? []), index];
}
const armatureById = new Map((snapshot.armatures ?? []).map((armature) => [armature.id, armature]));
const jointNodeById = new Map<string, number>();
const armatureSkeletonNode = new Map<string, number>();
const referencedArmatures = new Set(Array.from(skinnedMeshes.values()).map((mesh) => mesh.skinWeights?.armatureId).filter((id): id is string => Boolean(id)));
for (const armatureId of referencedArmatures) {
const armature = armatureById.get(armatureId);
if (!armature) continue;
const boneById = new Map(armature.bones.map((bone) => [bone.id, bone]));
for (const bone of armature.bones) {
const parent = bone.parentId ? boneById.get(bone.parentId) : undefined;
const local = parent ? matrixMultiply(matrixInverse(parent.restMatrix), bone.restMatrix) : bone.restMatrix;
const index = nodes.push({ name: bone.name, matrix: convertMatrix(local), extras: { blenderId: bone.id, armatureId } }) - 1;
jointNodeById.set(bone.id, index);
if (!bone.parentId && !armatureSkeletonNode.has(armatureId)) armatureSkeletonNode.set(armatureId, index);
}
for (const bone of armature.bones) {
const childIndex = jointNodeById.get(bone.id);
if (childIndex === undefined) continue;
if (bone.parentId) {
const parentIndex = jointNodeById.get(bone.parentId);
if (parentIndex !== undefined) {
const parent = nodes[parentIndex] as { children?: number[] };
parent.children = [...(parent.children ?? []), childIndex];
}
} else if (armature.objectId && nodeIndex.has(armature.objectId)) {
const owner = nodes[nodeIndex.get(armature.objectId)!] as { children?: number[] };
owner.children = [...(owner.children ?? []), childIndex];
}
}
}
const gltfSkins: Array<Record<string, unknown>> = [];
const skinIndexByMeshId = new Map<string, number>();
for (const [meshId, mesh] of skinnedMeshes) {
const skin = mesh.skinWeights;
const armature = skin?.armatureId ? armatureById.get(skin.armatureId) : undefined;
if (!skin || !armature || !skin.jointIds) continue;
const boneById = new Map(armature.bones.map((bone) => [bone.id, bone]));
const joints = skin.jointIds.map((id) => jointNodeById.get(id));
if (joints.some((index) => index === undefined)) continue;
const armatureNode = armature.objectId ? snapshot.nodes.find((node) => node.id === armature.objectId) : undefined;
const armatureWorld = armatureNode?.worldMatrix ?? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const inverseBindMatrices = new Float32Array(skin.jointIds.length * 16);
for (const [joint, id] of skin.jointIds.entries()) {
const bone = boneById.get(id);
if (!bone) continue;
const jointWorld = matrixMultiply(Array.from(armatureWorld), bone.restMatrix);
inverseBindMatrices.set(convertMatrix(matrixMultiply(matrixInverse(jointWorld), skin.bindMatrix)), joint * 16);
}
const accessor = appendAccessor(parts, binaryLength, bufferViews, accessors, inverseBindMatrices, "MAT4");
binaryLength = accessor.length;
const skinIndex = gltfSkins.push({
name: armature.name,
joints: joints as number[],
inverseBindMatrices: accessor.index,
...(armatureSkeletonNode.has(armature.id) ? { skeleton: armatureSkeletonNode.get(armature.id) } : {}),
extras: { blenderId: armature.id, meshBindMatrix: skin.bindMatrix },
}) - 1;
skinIndexByMeshId.set(meshId, skinIndex);
}
for (const [index, node] of snapshot.nodes.entries()) {
if (node.dataId && skinIndexByMeshId.has(node.dataId)) (nodes[index] as Record<string, unknown>).skin = skinIndexByMeshId.get(node.dataId);
}
const gltfAnimations: Array<Record<string, unknown>> = [];
for (const animation of snapshot.animations) {
const groups = new Map<string, AnimationGroup>();
for (const channel of animation.channels) {
const boneMatch = channel.path.match(/^pose\.bones\["(.+)"\]\.(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/);
const objectMatch = channel.path.match(/^(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/);
const property = boneMatch?.[2] ?? objectMatch?.[1];
const component = Number(boneMatch?.[3] ?? objectMatch?.[2]);
if (!property || !Number.isInteger(component)) continue;
let targetNode = nodeIndex.get(animation.targetId);
if (boneMatch) {
const armature = (snapshot.armatures ?? []).find((candidate) => candidate.objectId === animation.targetId);
const bone = armature?.bones.find((candidate) => candidate.name === boneMatch[1]);
targetNode = bone ? jointNodeById.get(bone.id) : undefined;
}
if (targetNode === undefined) continue;
const path = property === "location" ? "translation" : property === "scale" ? "scale" : "rotation";
const source = property === "rotation_euler" ? "EULER" : property === "rotation_quaternion" ? "QUATERNION" : "VECTOR";
const key = `${targetNode}:${path}`;
const group = groups.get(key) ?? { node: targetNode, path, source, components: new Map() };
const values = group.components.get(component) ?? new Map<number, number>();
for (const keyframe of channel.keyframes) if (Number.isFinite(keyframe.value[0])) values.set(keyframe.frame, keyframe.value[0]);
group.components.set(component, values);
groups.set(key, group);
}
const samplers: Array<Record<string, unknown>> = [];
const channels: Array<Record<string, unknown>> = [];
for (const group of groups.values()) {
const frames = Array.from(new Set(Array.from(group.components.values()).flatMap((values) => Array.from(values.keys())))).sort((left, right) => left - right);
if (frames.length === 0) continue;
const width = group.path === "rotation" ? 4 : 3;
const output = new Float32Array(frames.length * width);
for (const [frameIndex, frame] of frames.entries()) {
const sourceWidth = group.source === "QUATERNION" ? 4 : 3;
const value = Array.from({ length: sourceWidth }, (_, component) => group.components.get(component)?.get(frame) ?? (group.path === "scale" || (group.source === "QUATERNION" && component === 0) ? 1 : 0));
const converted = group.path === "translation" ? [value[0], value[2], -value[1]] :
group.path === "scale" ? [value[0], value[2], value[1]] : convertQuaternion(value, group.source === "EULER" ? "EULER" : "QUATERNION");
output.set(converted, frameIndex * width);
}
const times = Float32Array.from(frames, (frame) => frame / 24);
const timeAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, times, "SCALAR"); binaryLength = timeAccessor.length;
const outputAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, output, group.path === "rotation" ? "VEC4" : "VEC3"); binaryLength = outputAccessor.length;
const sampler = samplers.push({ input: timeAccessor.index, output: outputAccessor.index, interpolation: "LINEAR" }) - 1;
channels.push({ sampler, target: { node: group.node, path: group.path } });
}
if (channels.length > 0) gltfAnimations.push({ name: animation.name, samplers, channels, extras: { blenderId: animation.id, frameStart: animation.frameStart, frameEnd: animation.frameEnd, framesPerSecond: 24 } });
}
const roots = snapshot.nodes.map((node, index) => node.parentId && nodeIndex.has(node.parentId) ? -1 : index).filter((index) => index >= 0);
const bin = new Uint8Array(binaryLength);
let binOffset = 0;
for (const part of parts) { bin.set(part, binOffset); binOffset += part.byteLength; }
const extensionsUsed = [
{ name: "KHR_materials_ior", enabled: snapshot.materials.some((material) => Math.abs(material.ior - 1.5) > 1e-6) },
{ name: "KHR_materials_transmission", enabled: snapshot.materials.some((material) => (material.transmissionWeight ?? 0) > 0) },
{ name: "KHR_materials_clearcoat", enabled: snapshot.materials.some((material) => (material.coatWeight ?? 0) > 0) },
{ name: "KHR_materials_specular", enabled: snapshot.materials.some((material) => Math.abs((material.specularIORLevel ?? 0.5) - 0.5) > 1e-6) },
{ name: "KHR_materials_emissive_strength", enabled: snapshot.materials.some((material) => Math.abs((material.emissionStrength ?? 1) - 1) > 1e-6) },
].filter((entry) => entry.enabled).map((entry) => entry.name);
const gltf = {
asset: { version: "2.0", generator: "Blender Web SceneIR exporter" },
...(extensionsUsed.length === 0 ? {} : { extensionsUsed }),
scene: 0,
scenes: [{ nodes: roots }],
nodes,
meshes: gltfMeshes,
materials,
...(gltfImages.length === 0 ? {} : { images: gltfImages }),
...(gltfTextures.length === 0 ? {} : { textures: gltfTextures }),
...(gltfTextures.length === 0 ? {} : { samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }] }),
...(gltfSkins.length === 0 ? {} : { skins: gltfSkins }),
...(gltfAnimations.length === 0 ? {} : { animations: gltfAnimations }),
accessors,
bufferViews,
buffers: [{ byteLength: bin.byteLength }],
extras: { blenderSceneId: snapshot.sceneId, sourceRevision: snapshot.revision, frame: snapshot.frame.current },
};
const jsonBytes = new TextEncoder().encode(JSON.stringify(gltf));
const jsonLength = align4(jsonBytes.byteLength);
const totalLength = 12 + 8 + jsonLength + 8 + bin.byteLength;
const output = new ArrayBuffer(totalLength);
const view = new DataView(output);
view.setUint32(0, 0x46546c67, true);
view.setUint32(4, 2, true);
view.setUint32(8, totalLength, true);
view.setUint32(12, jsonLength, true);
view.setUint32(16, 0x4e4f534a, true);
new Uint8Array(output, 20, jsonBytes.byteLength).set(jsonBytes);
new Uint8Array(output, 20 + jsonBytes.byteLength, jsonLength - jsonBytes.byteLength).fill(0x20);
const binHeader = 20 + jsonLength;
view.setUint32(binHeader, bin.byteLength, true);
view.setUint32(binHeader + 4, 0x004e4942, true);
new Uint8Array(output, binHeader + 8).set(bin);
return output;
}
export function exportGLB(
snapshot: SceneSnapshotIR,
geometryBuffers: readonly MeshGeometryBuffer[] = [],
assetBuffers: readonly GLBAssetBuffer[] = [],
nonMeshGeometryBuffers: readonly NonMeshGeometryChunk[] = [],
): GLBExportResult {
let mapped = { snapshot, geometryBuffers: [...geometryBuffers], losses: [] as Array<{ dataId: string; message: string }> };
try {
if (nonMeshGeometryBuffers.length > 0) mapped = mapBinaryNonMeshForExport(snapshot, geometryBuffers, nonMeshGeometryBuffers);
}
catch (error) {
return { report: { canExport: false, warnings: [{ code: "MISSING_GEOMETRY_BUFFER", severity: "error", message: error instanceof Error ? error.message : "WNM geometry is invalid" }] } };
}
const report = analyzeGLBExport(mapped.snapshot, mapped.geometryBuffers, assetBuffers);
report.warnings.push(...mapped.losses.map((loss) => ({ code: "NON_MESH_ATTRIBUTE_LOSS" as const, severity: "warning" as const, message: loss.message, id: loss.dataId })));
if (!report.canExport) return { report };
return { report, glb: buildGLB(mapped.snapshot, mapped.geometryBuffers, assetBuffers) };
}

200
web/protocol/glb-import.ts Normal file
View File

@@ -0,0 +1,200 @@
import type { GLBAssetBuffer } from "./glb-export";
import type { SceneSnapshotIR } from "./scene-ir";
const GLB_MAGIC = 0x46546c67;
const JSON_CHUNK = 0x4e4f534a;
const BIN_CHUNK = 0x004e4942;
interface GLBAccessor {
bufferView?: number;
byteOffset?: number;
componentType: number;
count: number;
type: string;
}
interface GLBBufferView {
buffer: number;
byteOffset?: number;
byteLength: number;
}
interface GLBPrimitive {
attributes?: Record<string, number>;
indices?: number;
targets?: Array<Record<string, number>>;
}
interface GLBDocument {
asset?: { version?: string };
buffers?: Array<{ byteLength?: number }>;
bufferViews?: GLBBufferView[];
accessors?: GLBAccessor[];
meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record<string, unknown> }>;
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; extras?: Record<string, unknown> }>;
skins?: Array<{ joints?: number[]; inverseBindMatrices?: number; extras?: Record<string, unknown> }>;
animations?: Array<{ name?: string; channels?: Array<{ target?: { node?: number; path?: string } }>; extras?: Record<string, unknown> }>;
}
export interface ImportedGLBImage {
name?: string;
blenderId?: string;
mimeType?: string;
byteLength: number;
signature: number[];
}
export interface ImportedGLBSemantics {
version: 2;
meshCount: number;
primitiveCount: number;
meshes: Array<{ name?: string; blenderId?: string; primitiveCount: number; attributes: string[]; morphTargetCount: number }>;
images: ImportedGLBImage[];
skinCount: number;
skins: Array<{ jointCount: number; inverseBindType?: string; inverseBindCount?: number }>;
animationCount: number;
animationChannelCount: number;
animationPaths: string[];
}
export interface GLBSemanticComparison {
compatible: boolean;
mismatches: string[];
}
function recordId(extras: Record<string, unknown> | undefined): string | undefined {
return typeof extras?.blenderId === "string" ? extras.blenderId : undefined;
}
function requireIndex(value: unknown, size: number, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= size) throw new Error(`${label} index is out of range`);
return value as number;
}
function jsonChunk(bytes: Uint8Array, length: number): GLBDocument {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== 2) throw new Error("GLB header is invalid");
if (view.getUint32(8, true) !== bytes.byteLength) throw new Error("GLB length does not match header");
const jsonLength = view.getUint32(12, true);
if (view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
let document: unknown;
try {
document = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)).trim());
}
catch (error) {
throw new Error(`GLB JSON is invalid: ${error instanceof Error ? error.message : "parse failed"}`);
}
if (typeof document !== "object" || document === null || Array.isArray(document)) throw new Error("GLB JSON document is invalid");
if ((document as GLBDocument).asset?.version !== "2.0") throw new Error("GLB asset version is not 2.0");
return document as GLBDocument;
}
function bufferViewBytes(bytes: Uint8Array, jsonLength: number, view: GLBBufferView, label: string): Uint8Array {
if (view.buffer !== 0 || !Number.isSafeInteger(view.byteLength) || view.byteLength < 0) throw new Error(`${label} bufferView is invalid`);
const binaryStart = 20 + jsonLength + 8;
const offset = view.byteOffset ?? 0;
if (!Number.isSafeInteger(offset) || offset < 0 || binaryStart + offset + view.byteLength > bytes.byteLength) throw new Error(`${label} bufferView exceeds BIN chunk`);
return bytes.subarray(binaryStart + offset, binaryStart + offset + view.byteLength);
}
export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
const bytes = new Uint8Array(glb);
const view = new DataView(glb);
const jsonLength = view.getUint32(12, true);
const document = jsonChunk(bytes, glb.byteLength);
const bufferViews = document.bufferViews ?? [];
const accessors = document.accessors ?? [];
for (const [index, bufferView] of bufferViews.entries()) bufferViewBytes(bytes, jsonLength, bufferView, `bufferViews[${index}]`);
const accessorType = (index: number): string => accessors[requireIndex(index, accessors.length, "accessor")]?.type ?? "";
for (const [index, accessor] of accessors.entries()) {
if (!Number.isSafeInteger(accessor.count) || accessor.count < 0 || (accessor.byteOffset ?? 0) < 0) throw new Error(`accessors[${index}] is invalid`);
if (accessor.bufferView !== undefined) {
const bytesForAccessor = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(accessor.bufferView, bufferViews.length, `accessors[${index}]`)], `accessors[${index}]`);
const componentBytes = accessor.componentType === 5126 || accessor.componentType === 5125 ? 4 : accessor.componentType === 5123 ? 2 : 1;
const width = accessor.type === "SCALAR" ? 1 : accessor.type === "VEC2" ? 2 : accessor.type === "VEC3" ? 3 : accessor.type === "VEC4" ? 4 : accessor.type === "MAT4" ? 16 : 0;
if (width === 0 || (accessor.byteOffset ?? 0) + accessor.count * width * componentBytes > bytesForAccessor.byteLength) throw new Error(`accessors[${index}] exceeds bufferView`);
}
}
const images = (document.images ?? []).map((image, index) => {
if (image.bufferView === undefined) throw new Error(`images[${index}] has no embedded bufferView`);
const data = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(image.bufferView, bufferViews.length, `images[${index}]`)], `images[${index}]`);
return { name: image.name, blenderId: recordId(image.extras), mimeType: image.mimeType, byteLength: data.byteLength, signature: Array.from(data.subarray(0, 8)) };
});
const meshes = (document.meshes ?? []).map((mesh, index) => {
const primitives = mesh.primitives ?? [];
const attributes = new Set<string>();
let morphTargetCount = 0;
for (const [primitiveIndex, primitive] of primitives.entries()) {
for (const semantic of Object.keys(primitive.attributes ?? {})) {
const accessor = requireIndex(primitive.attributes?.[semantic], accessors.length, `meshes[${index}].primitives[${primitiveIndex}].${semantic}`);
accessorType(accessor);
attributes.add(semantic);
}
if (primitive.indices !== undefined) accessorType(requireIndex(primitive.indices, accessors.length, "primitive indices"));
morphTargetCount = Math.max(morphTargetCount, primitive.targets?.length ?? 0);
}
return { name: mesh.name, blenderId: recordId(mesh.extras), primitiveCount: primitives.length, attributes: [...attributes].sort(), morphTargetCount };
});
const skins = (document.skins ?? []).map((skin, index) => {
const jointCount = skin.joints?.length ?? 0;
if (jointCount === 0) throw new Error(`skins[${index}] has no joints`);
let inverseBindType: string | undefined;
let inverseBindCount: number | undefined;
if (skin.inverseBindMatrices !== undefined) {
const accessor = accessors[requireIndex(skin.inverseBindMatrices, accessors.length, `skins[${index}].inverseBindMatrices`)]!;
inverseBindType = accessor.type;
inverseBindCount = accessor.count;
if (inverseBindType !== "MAT4" || inverseBindCount !== jointCount) throw new Error(`skins[${index}] inverse bind matrices do not match joints`);
}
return { jointCount, inverseBindType, inverseBindCount };
});
const animations = document.animations ?? [];
const animationPaths = animations.flatMap((animation) => (animation.channels ?? []).map((channel) => channel.target?.path ?? "")).filter(Boolean).sort();
return { version: 2, meshCount: meshes.length, primitiveCount: meshes.reduce((sum, mesh) => sum + mesh.primitiveCount, 0), meshes, images, skinCount: skins.length, skins, animationCount: animations.length, animationChannelCount: animationPaths.length, animationPaths };
}
export function compareGLBToSceneIR(snapshot: SceneSnapshotIR, imported: ImportedGLBSemantics, assetBuffers: readonly GLBAssetBuffer[] = []): GLBSemanticComparison {
const mismatches: string[] = [];
const geometryMeshIds = new Set(snapshot.meshes.filter((mesh) => mesh.geometryStatus !== "summary-only").map((mesh) => mesh.id));
const importedMeshIds = new Set(imported.meshes.map((mesh) => mesh.blenderId).filter((id): id is string => Boolean(id)));
for (const id of geometryMeshIds) if (!importedMeshIds.has(id)) mismatches.push(`mesh missing: ${id}`);
const expectedImages = assetBuffers.filter((asset) => snapshot.images.some((image) => image.assetId === asset.assetId && asset.data.byteLength > 0));
if (imported.images.length !== expectedImages.length) mismatches.push(`image count ${imported.images.length} != ${expectedImages.length}`);
for (const asset of expectedImages) {
const image = imported.images.find((candidate) => candidate.blenderId === snapshot.images.find((source) => source.assetId === asset.assetId)?.id);
if (!image || image.byteLength !== asset.data.byteLength || image.mimeType !== asset.mimeType) mismatches.push(`image semantic mismatch: ${asset.assetId}`);
}
const expectedSkins = snapshot.meshes.filter((mesh) => mesh.skinWeights?.armatureId && snapshot.armatures?.some((armature) => armature.id === mesh.skinWeights?.armatureId));
if (imported.skinCount !== expectedSkins.length) mismatches.push(`skin count ${imported.skinCount} != ${expectedSkins.length}`);
for (const mesh of expectedSkins) {
const expectedJoints = mesh.skinWeights?.jointIds?.length ?? 0;
if (!imported.skins.some((skin) => skin.jointCount === expectedJoints && skin.inverseBindType === "MAT4")) mismatches.push(`skin semantic mismatch: ${mesh.id}`);
}
const expectedMorphs = snapshot.meshes.filter((mesh) => (mesh.shapeKeys?.length ?? 0) > 0).reduce((sum, mesh) => sum + (mesh.shapeKeys?.length ?? 0), 0);
const importedMorphs = imported.meshes.reduce((sum, mesh) => sum + mesh.morphTargetCount, 0);
if (importedMorphs !== expectedMorphs) mismatches.push(`morph target count ${importedMorphs} != ${expectedMorphs}`);
const expectedAnimationPaths: string[] = [];
let expectedAnimations = 0;
for (const animation of snapshot.animations) {
const groups = new Set<string>();
for (const channel of animation.channels) {
const boneMatch = channel.path.match(/^pose\.bones\["(.+)"\]\.(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/);
const objectMatch = channel.path.match(/^(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/);
const property = boneMatch?.[2] ?? objectMatch?.[1];
if (!property || channel.keyframes.length === 0) continue;
if (boneMatch) {
const armature = (snapshot.armatures ?? []).find((candidate) => candidate.objectId === animation.targetId);
if (!armature?.bones.some((bone) => bone.name === boneMatch[1])) continue;
}
else if (!snapshot.nodes.some((node) => node.id === animation.targetId)) continue;
const path = property === "location" ? "translation" : property === "scale" ? "scale" : "rotation";
groups.add(`${boneMatch?.[1] ?? animation.targetId}:${path}`);
}
if (groups.size > 0) expectedAnimations++;
for (const group of groups) expectedAnimationPaths.push(group.slice(group.lastIndexOf(":") + 1));
}
expectedAnimationPaths.sort();
if (imported.animationCount !== expectedAnimations) mismatches.push(`animation count ${imported.animationCount} != ${expectedAnimations}`);
if (imported.animationPaths.join(",") !== expectedAnimationPaths.join(",")) mismatches.push(`animation paths ${imported.animationPaths.join(",")} != ${expectedAnimationPaths.join(",")}`);
return { compatible: mismatches.length === 0, mismatches };
}

View File

@@ -0,0 +1,237 @@
export const GREASE_PENCIL_BUDGET = {
maxLayers: 1_024,
maxFrames: 100_000,
maxStrokes: 1_000_000,
maxPoints: 1_000_000,
maxAttributes: 65_536,
} as const;
export type GreasePencilGeometryStatus = "summary-only" | "available" | "blocked";
export type GreasePencilAttributeDomain = "POINT" | "STROKE" | "CURVE" | "INSTANCE";
export type GreasePencilAttributeDataType = "BOOL" | "INT" | "FLOAT" | "FLOAT2" | "FLOAT3" | "FLOAT4" | "BYTE_COLOR" | "FLOAT_COLOR";
export interface GreasePencilPointIR {
position: [number, number, number];
radius: number;
opacity: number;
vertexColor?: [number, number, number, number];
}
export interface GreasePencilAttributeIR {
name: string;
domain: GreasePencilAttributeDomain;
dataType: GreasePencilAttributeDataType;
components: 1 | 2 | 3 | 4;
values?: Array<number | boolean>;
}
export interface GreasePencilStrokeIR {
id?: string;
cyclic: boolean;
pointCount: number;
points?: GreasePencilPointIR[];
materialIndex?: number;
attributes?: GreasePencilAttributeIR[];
}
export interface GreasePencilDrawingIR {
id: string;
strokeCount: number;
pointCount: number;
strokes: GreasePencilStrokeIR[];
attributes?: GreasePencilAttributeIR[];
}
export interface GreasePencilFrameIR {
frame: number;
drawing: GreasePencilDrawingIR;
duration?: number;
}
export interface GreasePencilLayerIR {
id: string;
name: string;
visible: boolean;
locked: boolean;
opacity: number;
onionSkinning?: boolean;
frames: GreasePencilFrameIR[];
}
export interface GreasePencilDataIR {
id: string;
name: string;
geometryStatus: GreasePencilGeometryStatus;
layerCount: number;
frameCount: number;
strokeCount: number;
pointCount: number;
layers: GreasePencilLayerIR[];
attributes?: GreasePencilAttributeIR[];
errorCode?: "GREASE_PENCIL_SCHEMA_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED";
}
function fail(path: string, message: string): never {
throw new Error(`GREASE_PENCIL_SCHEMA_INVALID: ${path} ${message}`);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
return value as Record<string, unknown>;
}
function string(value: unknown, path: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 255) fail(path, "must be a non-empty string up to 255 characters");
return value;
}
function number(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) fail(path, "must be finite");
return value;
}
function count(value: unknown, path: string): number {
const result = number(value, path);
if (!Number.isSafeInteger(result) || result < 0) fail(path, "must be a non-negative safe integer");
return result;
}
function tuple(value: unknown, length: number, path: string): [number, number, number] | [number, number, number, number] {
if (!Array.isArray(value) || value.length !== length || value.some((item) => typeof item !== "number" || !Number.isFinite(item))) fail(path, `must contain ${length} finite numbers`);
return value as [number, number, number] | [number, number, number, number];
}
function parseAttribute(value: unknown, path: string, domainCount: number): GreasePencilAttributeIR {
const attribute = record(value, path);
const name = string(attribute.name, `${path}.name`);
const domain = attribute.domain;
if (domain !== "POINT" && domain !== "STROKE" && domain !== "CURVE" && domain !== "INSTANCE") fail(`${path}.domain`, "is invalid");
const dataType = attribute.dataType;
if (dataType !== "BOOL" && dataType !== "INT" && dataType !== "FLOAT" && dataType !== "FLOAT2" && dataType !== "FLOAT3" && dataType !== "FLOAT4" && dataType !== "BYTE_COLOR" && dataType !== "FLOAT_COLOR") fail(`${path}.dataType`, "is invalid");
const components = count(attribute.components, `${path}.components`);
if (components < 1 || components > 4) fail(`${path}.components`, "must be between 1 and 4");
const result: GreasePencilAttributeIR = { name, domain, dataType, components: components as 1 | 2 | 3 | 4 };
if (attribute.values !== undefined) {
if (!Array.isArray(attribute.values)) fail(`${path}.values`, "must be an array");
const expected = (domain === "POINT" || domain === "STROKE" || domain === "CURVE" ? domainCount : 1) * components;
if (attribute.values.length !== expected) fail(`${path}.values`, `must contain ${expected} entries`);
if (attribute.values.some((item) => (dataType === "BOOL" ? typeof item !== "boolean" : typeof item !== "number" || !Number.isFinite(item)))) fail(`${path}.values`, "contains an invalid value");
result.values = attribute.values as Array<number | boolean>;
}
return result;
}
function parsePoint(value: unknown, path: string): GreasePencilPointIR {
const point = record(value, path);
const position = tuple(point.position, 3, `${path}.position`) as [number, number, number];
const radius = number(point.radius, `${path}.radius`);
const opacity = number(point.opacity, `${path}.opacity`);
if (radius < 0 || radius > 1_000_000) fail(`${path}.radius`, "is outside the bounded range");
if (opacity < 0 || opacity > 1) fail(`${path}.opacity`, "must be in [0,1]");
const result: GreasePencilPointIR = { position, radius, opacity };
if (point.vertexColor !== undefined) result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number];
return result;
}
function parseStroke(value: unknown, path: string): GreasePencilStrokeIR {
const stroke = record(value, path);
const result: GreasePencilStrokeIR = {
cyclic: stroke.cyclic === true,
pointCount: count(stroke.pointCount, `${path}.pointCount`),
};
if (typeof stroke.cyclic !== "boolean") fail(`${path}.cyclic`, "must be a boolean");
if (stroke.id !== undefined) result.id = string(stroke.id, `${path}.id`);
if (stroke.materialIndex !== undefined) result.materialIndex = count(stroke.materialIndex, `${path}.materialIndex`);
if (stroke.points !== undefined) {
if (!Array.isArray(stroke.points)) fail(`${path}.points`, "must be an array");
if (stroke.points.length !== result.pointCount) fail(`${path}.points`, "length must match pointCount");
result.points = stroke.points.map((point, index) => parsePoint(point, `${path}.points[${index}]`));
}
if (stroke.attributes !== undefined) {
if (!Array.isArray(stroke.attributes)) fail(`${path}.attributes`, "must be an array");
result.attributes = stroke.attributes.map((attribute, index) => parseAttribute(attribute, `${path}.attributes[${index}]`, result.pointCount));
}
return result;
}
function parseDrawing(value: unknown, path: string): GreasePencilDrawingIR {
const drawing = record(value, path);
const id = string(drawing.id, `${path}.id`);
const strokeCount = count(drawing.strokeCount, `${path}.strokeCount`);
const pointCount = count(drawing.pointCount, `${path}.pointCount`);
if (!Array.isArray(drawing.strokes)) fail(`${path}.strokes`, "must be an array");
if (drawing.strokes.length !== strokeCount) fail(`${path}.strokes`, "length must match strokeCount");
const strokes = drawing.strokes.map((stroke, index) => parseStroke(stroke, `${path}.strokes[${index}]`));
if (strokes.reduce((sum, stroke) => sum + stroke.pointCount, 0) !== pointCount) fail(`${path}.pointCount`, "must equal the sum of stroke point counts");
const result: GreasePencilDrawingIR = { id, strokeCount, pointCount, strokes };
if (drawing.attributes !== undefined) {
if (!Array.isArray(drawing.attributes)) fail(`${path}.attributes`, "must be an array");
result.attributes = drawing.attributes.map((attribute, index) => parseAttribute(attribute, `${path}.attributes[${index}]`, strokeCount));
}
return result;
}
function parseFrame(value: unknown, path: string): GreasePencilFrameIR {
const frame = record(value, path);
const frameNumber = number(frame.frame, `${path}.frame`);
if (!Number.isSafeInteger(frameNumber) || frameNumber < -1_000_000 || frameNumber > 1_000_000) fail(`${path}.frame`, "is outside the supported range");
const result: GreasePencilFrameIR = { frame: frameNumber, drawing: parseDrawing(frame.drawing, `${path}.drawing`) };
if (frame.duration !== undefined) {
const duration = count(frame.duration, `${path}.duration`);
if (duration > 1_000_000) fail(`${path}.duration`, "is outside the supported range");
result.duration = duration;
}
return result;
}
function parseLayer(value: unknown, path: string): GreasePencilLayerIR {
const layer = record(value, path);
const name = string(layer.name, `${path}.name`);
const id = string(layer.id, `${path}.id`);
if (typeof layer.visible !== "boolean") fail(`${path}.visible`, "must be a boolean");
if (typeof layer.locked !== "boolean") fail(`${path}.locked`, "must be a boolean");
const opacity = number(layer.opacity, `${path}.opacity`);
if (opacity < 0 || opacity > 1) fail(`${path}.opacity`, "must be in [0,1]");
if (!Array.isArray(layer.frames)) fail(`${path}.frames`, "must be an array");
const frames = layer.frames.map((frame, index) => parseFrame(frame, `${path}.frames[${index}]`));
const result: GreasePencilLayerIR = { id, name, visible: layer.visible, locked: layer.locked, opacity, frames };
if (layer.onionSkinning !== undefined) {
if (typeof layer.onionSkinning !== "boolean") fail(`${path}.onionSkinning`, "must be a boolean");
result.onionSkinning = layer.onionSkinning;
}
return result;
}
export function parseGreasePencilData(value: unknown, path = "greasePencils"): GreasePencilDataIR {
const data = record(value, path);
const id = string(data.id, `${path}.id`);
const name = string(data.name, `${path}.name`);
const geometryStatus = data.geometryStatus;
if (geometryStatus !== "summary-only" && geometryStatus !== "available" && geometryStatus !== "blocked") fail(`${path}.geometryStatus`, "is invalid");
const layerCount = count(data.layerCount, `${path}.layerCount`);
const frameCount = count(data.frameCount, `${path}.frameCount`);
const strokeCount = count(data.strokeCount, `${path}.strokeCount`);
const pointCount = count(data.pointCount, `${path}.pointCount`);
if (!Array.isArray(data.layers)) fail(`${path}.layers`, "must be an array");
const budgetBlocked = geometryStatus === "blocked" && data.errorCode === "GREASE_PENCIL_BUDGET_EXCEEDED";
if (!budgetBlocked && data.layers.length !== layerCount) fail(`${path}.layers`, "length must match layerCount");
if (layerCount > GREASE_PENCIL_BUDGET.maxLayers || frameCount > GREASE_PENCIL_BUDGET.maxFrames || strokeCount > GREASE_PENCIL_BUDGET.maxStrokes || pointCount > GREASE_PENCIL_BUDGET.maxPoints) {
if (!budgetBlocked) fail(path, "exceeds the bounded layer/frame/stroke/point budget");
}
const layers = data.layers.map((layer, index) => parseLayer(layer, `${path}.layers[${index}]`));
const actualFrames = layers.reduce((sum, layer) => sum + layer.frames.length, 0);
const actualStrokes = layers.reduce((sum, layer) => sum + layer.frames.reduce((frameSum, frame) => frameSum + frame.drawing.strokeCount, 0), 0);
const actualPoints = layers.reduce((sum, layer) => sum + layer.frames.reduce((frameSum, frame) => frameSum + frame.drawing.pointCount, 0), 0);
if (!budgetBlocked && (actualFrames !== frameCount || actualStrokes !== strokeCount || actualPoints !== pointCount)) fail(path, "declared counts do not match layer/frame/drawing contents");
const result: GreasePencilDataIR = { id, name, geometryStatus, layerCount, frameCount, strokeCount, pointCount, layers };
if (data.errorCode !== undefined) {
if (data.errorCode !== "GREASE_PENCIL_SCHEMA_INVALID" && data.errorCode !== "GREASE_PENCIL_BUDGET_EXCEEDED") fail(`${path}.errorCode`, "is invalid");
result.errorCode = data.errorCode;
}
if (data.attributes !== undefined) {
if (!Array.isArray(data.attributes)) fail(`${path}.attributes`, "must be an array");
if (data.attributes.length > GREASE_PENCIL_BUDGET.maxAttributes) fail(`${path}.attributes`, "exceeds the attribute budget");
result.attributes = data.attributes.map((attribute, index) => parseAttribute(attribute, `${path}.attributes[${index}]`, pointCount));
}
return result;
}

104
web/protocol/lod.ts Normal file
View File

@@ -0,0 +1,104 @@
import { parseLODManifest, SimplifyValidationError, type LODManifest, type SimplifyProfile } from "./simplify";
export type LODPoseState = "REST" | "POSE";
export interface LODGenerationLevelRequest {
profile: SimplifyProfile;
triangleBudget: number;
maxGeometricError?: number;
screenSpaceError?: number;
}
export interface LODGenerationRequest {
meshId: string;
sourceMeshRevision: number;
levels: LODGenerationLevelRequest[];
}
export interface LODCacheKeyInput {
objectId: string;
meshRevision: number;
modifierStackHash: string;
profileHash: string;
poseOrRestState: LODPoseState;
}
export interface LODCacheRecord extends LODManifest {
cacheKey: string;
objectId: string;
modifierStackHash: string;
profileHash: string;
poseOrRestState: LODPoseState;
generatedAt: string;
byteLength?: number;
lastAccessAt?: string;
}
function fnv1a(value: string, seed: number): number {
let hash = seed >>> 0;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
function canonicalKey(input: LODCacheKeyInput): string {
return JSON.stringify([
input.objectId,
input.meshRevision,
input.modifierStackHash,
input.profileHash,
input.poseOrRestState,
]);
}
/** Stable, path-safe key. It intentionally excludes mutable timestamps. */
export function buildLODCacheKey(input: LODCacheKeyInput): string {
if (!input.objectId || !Number.isInteger(input.meshRevision) || input.meshRevision < 0) {
throw new Error("Invalid LOD cache key input");
}
if (!input.modifierStackHash || !input.profileHash) throw new Error("LOD cache hashes are required");
const value = canonicalKey(input);
const first = fnv1a(value, 0x811c9dc5).toString(16).padStart(8, "0");
const second = fnv1a(value, 0x9e3779b9).toString(16).padStart(8, "0");
return `lod-${first}${second}`;
}
export function lodManifestCacheKey(manifest: LODCacheRecord): string {
return buildLODCacheKey({
objectId: manifest.objectId,
meshRevision: manifest.sourceMeshRevision,
modifierStackHash: manifest.modifierStackHash,
profileHash: manifest.profileHash,
poseOrRestState: manifest.poseOrRestState,
});
}
export function parseLODCacheRecord(value: unknown): LODCacheRecord {
const manifest = parseLODManifest(value);
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD cache record must be an object");
}
const input = value as Record<string, unknown>;
const fields = ["cacheKey", "objectId", "modifierStackHash", "profileHash", "generatedAt"];
if (fields.some((field) => typeof input[field] !== "string" || !input[field])) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD cache record metadata is incomplete");
}
if (input.poseOrRestState !== "REST" && input.poseOrRestState !== "POSE") {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD poseOrRestState must be REST or POSE");
}
const record: LODCacheRecord = {
...manifest,
cacheKey: input.cacheKey as string,
objectId: input.objectId as string,
modifierStackHash: input.modifierStackHash as string,
profileHash: input.profileHash as string,
poseOrRestState: input.poseOrRestState,
generatedAt: input.generatedAt as string,
};
if (lodManifestCacheKey(record) !== record.cacheKey) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD cacheKey does not match its source inputs");
}
return record;
}

44
web/protocol/manifest.ts Normal file
View File

@@ -0,0 +1,44 @@
export interface WasmResource {
id: string;
fileName: string;
url: string;
sha256: string;
required: boolean;
}
export interface WebEngineManifest {
schemaVersion: number;
protocolVersion: number;
engineVersion: string;
engine: "mock" | "blender-wasm";
memory: {
initialPages: number;
maximumPages: number;
shared: boolean;
};
wasm: WasmResource[];
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<WebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as Partial<WebEngineManifest>;
if (manifest.schemaVersion !== 1 || manifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!manifest.engine || !manifest.memory || !Array.isArray(manifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return manifest as WebEngineManifest;
}
export async function verifyWasmResource(resource: WasmResource): Promise<void> {
const response = await fetch(resource.url, { cache: "no-store" });
if (!response.ok) throw new Error(`WASM resource request failed: ${resource.id}`);
const bytes = await response.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
if (actual !== resource.sha256.toLowerCase()) {
throw new Error(`WASM resource hash mismatch: ${resource.id}`);
}
}

138
web/protocol/mesh-cache.ts Normal file
View File

@@ -0,0 +1,138 @@
import type { MeshGeometryBuffer, WebEngineLODLevelResult } from "./web-engine";
const bufferFields = ["positions", "indices", "normals", "triangleCornerIndices", "uvs", "colors", "triangleMaterialIndices"] as const;
type BufferField = typeof bufferFields[number];
interface BufferDescriptor {
field: BufferField;
offset: number;
byteLength: number;
}
interface GeometryDescriptor {
meshId: string;
byteLength: number;
buffers: BufferDescriptor[];
}
interface LevelDescriptor {
level: number;
meshId: string;
triangleBudget: number;
outputTriangleCount: number;
outputVertexCount: number;
geometry: GeometryDescriptor[];
}
interface CacheHeader {
schemaVersion: 1;
levels: LevelDescriptor[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function encodeLODGeometry(levels: readonly WebEngineLODLevelResult[]): ArrayBuffer {
if (levels.length === 0) throw new Error("LOD geometry cache requires at least one level");
const chunks: ArrayBuffer[] = [];
let offset = 0;
const header: CacheHeader = {
schemaVersion: 1,
levels: levels.map((level, expectedLevel) => {
if (level.level !== expectedLevel || level.geometryBuffers.length === 0) throw new Error("LOD cache levels must be contiguous and contain geometry");
return {
level: level.level,
meshId: level.meshId,
triangleBudget: level.triangleBudget,
outputTriangleCount: level.outputTriangleCount,
outputVertexCount: level.outputVertexCount,
geometry: level.geometryBuffers.map((geometry) => {
const descriptors: BufferDescriptor[] = [];
for (const field of bufferFields) {
const value = geometry[field];
if (!(value instanceof ArrayBuffer)) continue;
descriptors.push({ field, offset, byteLength: value.byteLength });
chunks.push(value);
offset += value.byteLength;
}
if (!descriptors.some((descriptor) => descriptor.field === "positions") || !descriptors.some((descriptor) => descriptor.field === "indices")) {
throw new Error("LOD cache geometry requires positions and indices");
}
return { meshId: geometry.meshId, byteLength: geometry.byteLength, buffers: descriptors };
}),
};
}),
};
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
const output = new ArrayBuffer(4 + headerBytes.byteLength + offset);
const view = new DataView(output);
view.setUint32(0, headerBytes.byteLength, true);
const bytes = new Uint8Array(output);
bytes.set(headerBytes, 4);
let writeOffset = 4 + headerBytes.byteLength;
for (const chunk of chunks) {
bytes.set(new Uint8Array(chunk), writeOffset);
writeOffset += chunk.byteLength;
}
return output;
}
export function decodeLODGeometry(data: ArrayBuffer): WebEngineLODLevelResult[] {
if (data.byteLength < 5) throw new Error("LOD geometry cache is truncated");
const headerLength = new DataView(data).getUint32(0, true);
if (headerLength === 0 || headerLength > data.byteLength - 4 || headerLength > 16 * 1024 * 1024) throw new Error("LOD geometry cache header is invalid");
const parsed = JSON.parse(new TextDecoder().decode(new Uint8Array(data, 4, headerLength))) as unknown;
if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.levels) || parsed.levels.length === 0) {
throw new Error("LOD geometry cache schema is invalid");
}
const payloadOffset = 4 + headerLength;
return parsed.levels.map((rawLevel, expectedLevel) => {
if (!isRecord(rawLevel) || rawLevel.level !== expectedLevel || typeof rawLevel.meshId !== "string" || !Array.isArray(rawLevel.geometry)) {
throw new Error("LOD geometry cache level is invalid");
}
const geometryBuffers = rawLevel.geometry.map((rawGeometry): MeshGeometryBuffer => {
if (!isRecord(rawGeometry) || typeof rawGeometry.meshId !== "string" || !Array.isArray(rawGeometry.buffers)) {
throw new Error("LOD geometry cache entry is invalid");
}
const decoded = new Map<BufferField, ArrayBuffer>();
for (const rawBuffer of rawGeometry.buffers) {
if (!isRecord(rawBuffer) || !bufferFields.includes(rawBuffer.field as BufferField) || !Number.isSafeInteger(rawBuffer.offset) || !Number.isSafeInteger(rawBuffer.byteLength)) {
throw new Error("LOD geometry cache buffer descriptor is invalid");
}
const offset = rawBuffer.offset as number;
const byteLength = rawBuffer.byteLength as number;
if (offset < 0 || byteLength < 0 || payloadOffset + offset + byteLength > data.byteLength || decoded.has(rawBuffer.field as BufferField)) {
throw new Error("LOD geometry cache buffer range is invalid");
}
decoded.set(rawBuffer.field as BufferField, data.slice(payloadOffset + offset, payloadOffset + offset + byteLength));
}
const positions = decoded.get("positions");
const indices = decoded.get("indices");
if (!positions || !indices) throw new Error("LOD geometry cache is missing topology buffers");
return {
schemaVersion: 1,
meshId: rawGeometry.meshId,
byteLength: typeof rawGeometry.byteLength === "number" ? rawGeometry.byteLength : [...decoded.values()].reduce((sum, value) => sum + value.byteLength, 0),
positions,
indices,
normals: decoded.get("normals"),
triangleCornerIndices: decoded.get("triangleCornerIndices"),
uvs: decoded.get("uvs"),
colors: decoded.get("colors"),
triangleMaterialIndices: decoded.get("triangleMaterialIndices"),
};
});
if (typeof rawLevel.triangleBudget !== "number" || typeof rawLevel.outputTriangleCount !== "number" || typeof rawLevel.outputVertexCount !== "number") {
throw new Error("LOD geometry cache statistics are invalid");
}
return {
level: expectedLevel,
meshId: rawLevel.meshId,
triangleBudget: rawLevel.triangleBudget,
outputTriangleCount: rawLevel.outputTriangleCount,
outputVertexCount: rawLevel.outputVertexCount,
geometryBuffers,
};
});
}

View File

@@ -0,0 +1,120 @@
import type {
MeshGeometryBuffer,
MeshGeometryBufferField,
MeshGeometryDelta,
MeshGeometryRangePatch,
} from "./web-engine";
export const meshGeometryBufferFields: readonly MeshGeometryBufferField[] = [
"positions",
"indices",
"normals",
"triangleCornerIndices",
"uvs",
"colors",
"triangleMaterialIndices",
"triangleFaceIndices",
"edgeVertexIndices",
"tangents",
"splitNormals",
"sculptMask",
"faceSets",
];
const maxUnchangedGap = 64;
function changedRanges(meshId: string, field: MeshGeometryBufferField, before: ArrayBuffer, after: ArrayBuffer): MeshGeometryRangePatch[] {
const oldBytes = new Uint8Array(before);
const newBytes = new Uint8Array(after);
const patches: MeshGeometryRangePatch[] = [];
let cursor = 0;
while (cursor < newBytes.length) {
while (cursor < newBytes.length && oldBytes[cursor] === newBytes[cursor]) cursor += 1;
if (cursor === newBytes.length) break;
const start = cursor;
let lastChanged = cursor;
let unchanged = 0;
cursor += 1;
while (cursor < newBytes.length) {
if (oldBytes[cursor] === newBytes[cursor]) {
unchanged += 1;
if (unchanged >= maxUnchangedGap) break;
}
else {
lastChanged = cursor;
unchanged = 0;
}
cursor += 1;
}
patches.push({ meshId, field, byteOffset: start, data: after.slice(start, lastChanged + 1) });
}
return patches;
}
function requiresReplacement(before: MeshGeometryBuffer, after: MeshGeometryBuffer): boolean {
return meshGeometryBufferFields.some((field) => {
const oldBuffer = before[field];
const newBuffer = after[field];
return Boolean(oldBuffer) !== Boolean(newBuffer) || (oldBuffer?.byteLength ?? 0) !== (newBuffer?.byteLength ?? 0);
});
}
export function diffMeshGeometryBuffers(before: readonly MeshGeometryBuffer[], after: readonly MeshGeometryBuffer[]): MeshGeometryDelta {
const previous = new Map(before.map((buffer) => [buffer.meshId, buffer]));
const current = new Map(after.map((buffer) => [buffer.meshId, buffer]));
const patches: MeshGeometryRangePatch[] = [];
const replaced: MeshGeometryBuffer[] = [];
const removed: string[] = [];
for (const buffer of after) {
const oldBuffer = previous.get(buffer.meshId);
if (!oldBuffer || requiresReplacement(oldBuffer, buffer)) {
replaced.push(buffer);
continue;
}
for (const field of meshGeometryBufferFields) {
const oldField = oldBuffer[field];
const newField = buffer[field];
if (oldField && newField) patches.push(...changedRanges(buffer.meshId, field, oldField, newField));
}
}
for (const meshId of previous.keys()) if (!current.has(meshId)) removed.push(meshId);
return { schemaVersion: 1, patches, replaced, removed };
}
function bufferByteLength(buffer: MeshGeometryBuffer): number {
return meshGeometryBufferFields.reduce((total, field) => total + (buffer[field]?.byteLength ?? 0), 0);
}
export function applyMeshGeometryDelta(before: readonly MeshGeometryBuffer[], delta: MeshGeometryDelta): MeshGeometryBuffer[] {
if (delta.schemaVersion !== 1) throw new Error("Unsupported MeshGeometryDelta schema");
const result = new Map(before.map((buffer) => [buffer.meshId, buffer]));
for (const meshId of delta.removed) result.delete(meshId);
for (const buffer of delta.replaced) result.set(buffer.meshId, buffer);
const changed = new Map<string, MeshGeometryBuffer>();
for (const patch of delta.patches) {
const current = changed.get(patch.meshId) ?? result.get(patch.meshId);
if (!current) throw new Error(`MeshGeometryDelta mesh not found: ${patch.meshId}`);
const source = current[patch.field];
if (!source || patch.byteOffset < 0 || patch.byteOffset + patch.data.byteLength > source.byteLength) {
throw new Error(`MeshGeometryDelta range is invalid: ${patch.meshId}.${patch.field}`);
}
const next = source.slice(0);
new Uint8Array(next).set(new Uint8Array(patch.data), patch.byteOffset);
const updated = { ...current, [patch.field]: next };
updated.byteLength = bufferByteLength(updated);
changed.set(patch.meshId, updated);
result.set(patch.meshId, updated);
}
return [...result.values()].sort((left, right) => left.meshId.localeCompare(right.meshId));
}
export function cloneMeshGeometryBuffers(buffers: readonly MeshGeometryBuffer[]): MeshGeometryBuffer[] {
return buffers.map((buffer) => {
const clone = { ...buffer };
for (const field of meshGeometryBufferFields) {
const value = buffer[field];
if (value) clone[field] = value.slice(0);
}
return clone;
});
}

View File

@@ -0,0 +1,78 @@
import type { MeshSummaryIR, ModifierIR, SceneSnapshotIR } from "./scene-ir";
export interface ModifierGraphNode {
uuid: string;
meshId: string;
modifier: ModifierIR;
dependencies: string[];
}
export interface ModifierDependencyGraph {
nodes: ModifierGraphNode[];
order: string[];
cycles: string[][];
missingDependencies: string[];
}
export interface ModifierEvaluationReport {
status: "EVALUATED" | "METADATA_ONLY" | "BLOCKED";
order: string[];
cycles: string[][];
blockedBy: string[];
}
export function buildModifierDependencyGraph(snapshot: Pick<SceneSnapshotIR, "meshes">): ModifierDependencyGraph {
const nodes: ModifierGraphNode[] = [];
const known = new Set<string>();
for (const mesh of snapshot.meshes) {
let previous: string | undefined;
for (const modifier of mesh.modifierStack ?? []) {
const dependencies = Array.from(new Set([...(modifier.dependsOn ?? []), ...(previous ? [previous] : [])]));
nodes.push({ uuid: modifier.uuid, meshId: mesh.id, modifier, dependencies });
known.add(modifier.uuid);
previous = modifier.uuid;
}
}
const nodeById = new Map(nodes.map((node) => [node.uuid, node]));
const state = new Map<string, 0 | 1 | 2>();
const order: string[] = [];
const cycles: string[][] = [];
const missingDependencies: string[] = [];
const path: string[] = [];
const visit = (uuid: string): void => {
const current = state.get(uuid) ?? 0;
if (current === 2) return;
if (current === 1) {
const start = path.indexOf(uuid);
cycles.push(start >= 0 ? [...path.slice(start), uuid] : [uuid]);
return;
}
state.set(uuid, 1);
path.push(uuid);
for (const dependency of nodeById.get(uuid)?.dependencies ?? []) {
if (known.has(dependency)) visit(dependency);
else missingDependencies.push(`${uuid}:${dependency}`);
}
path.pop();
state.set(uuid, 2);
order.push(uuid);
};
for (const node of nodes) visit(node.uuid);
return { nodes, order, cycles, missingDependencies: Array.from(new Set(missingDependencies)).sort() };
}
export function evaluateModifierDependencyGraph(snapshot: Pick<SceneSnapshotIR, "meshes">): ModifierEvaluationReport {
const graph = buildModifierDependencyGraph(snapshot);
const blockedBy = graph.nodes.filter((node) => node.modifier.evaluationStatus === "BLOCKED").map((node) => node.uuid);
if (graph.cycles.length > 0) return { status: "BLOCKED", order: graph.order, cycles: graph.cycles, blockedBy: [...blockedBy, "MODIFIER_DEPENDENCY_CYCLE"] };
if (graph.missingDependencies.length > 0) return { status: "BLOCKED", order: graph.order, cycles: [], blockedBy: [...blockedBy, ...graph.missingDependencies] };
if (blockedBy.length > 0) return { status: "BLOCKED", order: graph.order, cycles: [], blockedBy };
if (graph.nodes.some((node) => node.modifier.evaluationStatus === "METADATA_ONLY")) {
return { status: "METADATA_ONLY", order: graph.order, cycles: [], blockedBy: [] };
}
return { status: "EVALUATED", order: graph.order, cycles: [], blockedBy: [] };
}
export function evaluateMeshModifierStack(mesh: MeshSummaryIR): ModifierEvaluationReport {
return evaluateModifierDependencyGraph({ meshes: [mesh] });
}

27
web/protocol/modifier.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { ModifierIR } from "./scene-ir";
function fnv1a(value: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/** Hashes evaluated modifier identity/state, excluding display timestamps. */
export function modifierStackHash(stack: readonly ModifierIR[] | undefined): string {
const canonical = (stack ?? []).map((modifier) => ({
uuid: modifier.uuid,
type: modifier.type,
enabled: modifier.enabled,
showViewport: modifier.showViewport,
showRender: modifier.showRender,
showEditMode: modifier.showEditMode,
showOnCage: modifier.showOnCage,
parameters: modifier.parameters,
dependsOn: modifier.dependsOn,
evaluationStatus: modifier.evaluationStatus,
}));
return `mod-${fnv1a(JSON.stringify(canonical)).toString(16).padStart(8, "0")}`;
}

160
web/protocol/nla.ts Normal file
View File

@@ -0,0 +1,160 @@
import type { ErrorCode } from "./error";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
export const NLA_PROTOCOL_SCHEMA = 1 as const;
export type NlaBlendMode = "REPLACE" | "ADD" | "MULTIPLY" | "COMBINE";
export type NlaExtrapolation = "NOTHING" | "HOLD" | "HOLD_FORWARD";
export interface NlaStripIR {
id: string;
actionId: string;
frameStart: number;
frameEnd: number;
actionFrameStart: number;
actionFrameEnd: number;
scale: number;
repeat: number;
blendIn: number;
blendOut: number;
influence: number;
blendMode: NlaBlendMode;
extrapolation: NlaExtrapolation;
muted: boolean;
selected: boolean;
reverse?: boolean;
useTimeWarp?: boolean;
stripType?: "CLIP" | "TRANSITION" | "META" | "SOUND" | "UNKNOWN";
unsupportedReason?: string;
}
export interface NlaTrackIR {
schemaVersion: typeof NLA_PROTOCOL_SCHEMA;
id: string;
ownerId: string;
name: string;
strips: NlaStripIR[];
muted: boolean;
solo: boolean;
selected: boolean;
}
export interface NlaValidationContext {
actionIds: ReadonlySet<string>;
actionChannelPaths?: ReadonlyMap<string, ReadonlySet<string>>;
targetChannelPaths?: ReadonlySet<string>;
ownerId?: string;
actionNlaReferences?: ReadonlyMap<string, ReadonlySet<string>>;
}
export interface NlaValidationResult {
status: "SUPPORTED" | "BLOCKED";
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
}
export class NlaValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "NlaValidationError";
this.code = code;
this.path = path;
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function finite(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite`, path);
return value;
}
function parseStrip(value: unknown, path: string): NlaStripIR {
if (!record(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be an object`, path);
if (typeof value.id !== "string" || value.id.length === 0 || typeof value.actionId !== "string" || value.actionId.length === 0) throw new NlaValidationError("NLA_INVALID_STACK", `${path} requires id and actionId`, path);
const strip = value as Record<string, unknown>;
const frameStart = finite(strip.frameStart, `${path}.frameStart`);
const frameEnd = finite(strip.frameEnd, `${path}.frameEnd`);
const actionFrameStart = finite(strip.actionFrameStart, `${path}.actionFrameStart`);
const actionFrameEnd = finite(strip.actionFrameEnd, `${path}.actionFrameEnd`);
const scale = finite(strip.scale, `${path}.scale`);
const repeat = finite(strip.repeat, `${path}.repeat`);
const blendIn = finite(strip.blendIn, `${path}.blendIn`);
const blendOut = finite(strip.blendOut, `${path}.blendOut`);
const influence = finite(strip.influence, `${path}.influence`);
if (frameEnd <= frameStart || actionFrameEnd <= actionFrameStart || scale <= 0 || repeat <= 0 || blendIn < 0 || blendOut < 0 || influence < 0 || influence > 1) throw new NlaValidationError("NLA_INVALID_STACK", `${path} has an invalid range or influence`, path);
if (!["REPLACE", "ADD", "MULTIPLY", "COMBINE"].includes(strip.blendMode as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.blendMode is invalid`, `${path}.blendMode`);
if (!["NOTHING", "HOLD", "HOLD_FORWARD"].includes(strip.extrapolation as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.extrapolation is invalid`, `${path}.extrapolation`);
if (typeof strip.muted !== "boolean" || typeof strip.selected !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.muted/selected must be boolean`, path);
if (strip.reverse !== undefined && typeof strip.reverse !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.reverse must be boolean`, `${path}.reverse`);
if (strip.useTimeWarp !== undefined && typeof strip.useTimeWarp !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.useTimeWarp must be boolean`, `${path}.useTimeWarp`);
if (strip.stripType !== undefined && !["CLIP", "TRANSITION", "META", "SOUND", "UNKNOWN"].includes(strip.stripType as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.stripType is invalid`, `${path}.stripType`);
if (strip.unsupportedReason !== undefined && (typeof strip.unsupportedReason !== "string" || strip.unsupportedReason.length === 0)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.unsupportedReason is invalid`, `${path}.unsupportedReason`);
return value as unknown as NlaStripIR;
}
export function parseNlaTracks(value: unknown): NlaTrackIR[] {
if (!Array.isArray(value)) throw new NlaValidationError("NLA_INVALID_STACK", "nlaTracks must be an array", "nlaTracks");
const tracks: NlaTrackIR[] = [];
const ids = new Set<string>();
for (const [index, item] of value.entries()) {
const path = `nlaTracks[${index}]`;
if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || typeof item.id !== "string" || item.id.length === 0 || typeof item.ownerId !== "string" || item.ownerId.length === 0 || typeof item.name !== "string" || item.name.length === 0 || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path);
if (ids.has(item.id)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${item.id}`, path);
ids.add(item.id);
if (typeof item.muted !== "boolean" || typeof item.solo !== "boolean" || typeof item.selected !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path} track flags are invalid`, path);
tracks.push({ ...item, strips: item.strips.map((strip, stripIndex) => parseStrip(strip, `${path}.strips[${stripIndex}]`)) } as NlaTrackIR);
}
return tracks;
}
export function validateNlaTracks(tracks: readonly NlaTrackIR[], context: NlaValidationContext): NlaValidationResult {
const issues: NlaValidationResult["issues"] = [];
const stripIds = new Set<string>();
for (const [trackIndex, track] of tracks.entries()) {
if (context.ownerId !== undefined && track.ownerId !== context.ownerId) issues.push({ code: "NLA_PATH_INCOMPATIBLE", message: `NLA track owner does not match target: ${track.ownerId}`, path: `nlaTracks[${trackIndex}].ownerId` });
for (let index = 1; index < track.strips.length; index++) {
const previous = track.strips[index - 1];
const current = track.strips[index];
if (previous.frameStart > current.frameStart || (previous.frameStart === current.frameStart && previous.id.localeCompare(current.id) > 0)) {
issues.push({ code: "NLA_INVALID_STACK", message: "NLA strips must be deterministically sorted by frameStart and id", path: `nlaTracks[${trackIndex}].strips` });
break;
}
if (previous.frameEnd > current.frameStart) issues.push({ code: "NLA_INVALID_STACK", message: "NLA strips on one track cannot overlap", path: `nlaTracks[${trackIndex}].strips` });
}
for (const [stripIndex, strip] of track.strips.entries()) {
const path = `nlaTracks[${trackIndex}].strips[${stripIndex}]`;
if (stripIds.has(strip.id)) issues.push({ code: "NLA_INVALID_STACK", message: `duplicate strip ID: ${strip.id}`, path });
stripIds.add(strip.id);
if (!context.actionIds.has(strip.actionId)) issues.push({ code: "NLA_ACTION_MISSING", message: `NLA strip references missing Action: ${strip.actionId}`, path });
if (strip.useTimeWarp) issues.push({ code: "NLA_TIME_WARP_UNSUPPORTED", message: "time warp is outside the finite NLA subset", path });
if (strip.stripType !== undefined && strip.stripType !== "CLIP") issues.push({ code: "NLA_STRIP_UNSUPPORTED", message: `${strip.stripType} strips are outside the finite Action Clip subset`, path });
if (strip.unsupportedReason) issues.push({ code: "NLA_STRIP_UNSUPPORTED", message: strip.unsupportedReason, path });
const expectedDuration = (strip.actionFrameEnd - strip.actionFrameStart) * strip.scale * strip.repeat;
if (Math.abs(expectedDuration - (strip.frameEnd - strip.frameStart)) > 1e-4) issues.push({ code: "NLA_INVALID_STACK", message: "NLA strip time mapping is inconsistent with Action range, scale and repeat", path });
if (strip.blendIn + strip.blendOut > strip.frameEnd - strip.frameStart) issues.push({ code: "NLA_INVALID_STACK", message: "NLA strip blend ranges exceed its duration", path });
const paths = context.actionChannelPaths?.get(strip.actionId);
if (paths && paths.size === 0) issues.push({ code: "NLA_PATH_INCOMPATIBLE", message: `Action has no compatible FCurve paths: ${strip.actionId}`, path });
if (paths && context.targetChannelPaths && ![...paths].some((channelPath) => context.targetChannelPaths?.has(channelPath))) issues.push({ code: "NLA_PATH_INCOMPATIBLE", message: `Action paths do not target the NLA owner: ${strip.actionId}`, path });
const references = context.actionNlaReferences?.get(strip.actionId);
if (references?.has(strip.actionId)) issues.push({ code: "NLA_INVALID_STACK", message: `Action references itself through NLA: ${strip.actionId}`, path });
}
}
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues };
}
export function gateNlaTracks(value: unknown, context: NlaValidationContext): CapabilityGateResult {
try {
const tracks = parseNlaTracks(value);
const result = validateNlaTracks(tracks, context);
if (result.status === "SUPPORTED") return readyGate("N-014", "NLA_STRIP_STACK");
return blockedGate("N-014", "NLA_STRIP_STACK", result.issues.map((issue) => capabilityIssue(issue.code, issue.message, issue.path)));
}
catch (error) {
const issue = error as NlaValidationError;
return blockedGate("N-014", "NLA_STRIP_STACK", [capabilityIssue(issue.code ?? "NLA_INVALID_STACK", issue.message, issue.path)]);
}
}

17
web/protocol/non-mesh.ts Normal file
View File

@@ -0,0 +1,17 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { NonMeshDataIR } from "./scene-ir";
const previewableTypes = new Set<NonMeshDataIR["type"]>(["CURVE", "SURFACE", "FONT", "METABALL", "POINT_CLOUD", "CURVES", "HAIR"]);
export function gateNonMeshData(data: NonMeshDataIR): CapabilityGateResult {
const hasSourceGeometry = data.geometryStatus === "available" || data.geometryStatus === "binary";
if ((hasSourceGeometry || (data.evaluatedGeometry?.length ?? 0) > 0) && previewableTypes.has(data.type)) {
const representation = (data.evaluatedGeometry?.length ?? 0) > 0 ? "EVALUATED" : data.geometryStatus === "binary" ? "BINARY" : "PREVIEW";
return readyGate("N-015", `NON_MESH_${data.type}_${representation}`);
}
const code = data.errorCode ?? (data.type === "VOLUME" ? "NON_MESH_RESOURCE_MISSING" : "NON_MESH_DATA_UNSUPPORTED");
const message = data.geometryStatus === "blocked"
? `${data.type} data is blocked: ${code}`
: `${data.type} data is available only as a summary; editable/evaluated preview is not supported`;
return blockedGate("N-015", `NON_MESH_${data.type}`, [capabilityIssue(code, message, `nonMeshData.${data.id}`)]);
}

View File

@@ -0,0 +1,413 @@
import type {
NonMeshAttributeDataType,
NonMeshAttributeDomain,
NonMeshAttributeIR,
} from "./scene-ir";
export const NON_MESH_BINARY_SCHEMA = 1 as const;
export const NON_MESH_MAX_POINTS = 1_000_000;
export const NON_MESH_DEFAULT_CHUNK_POINTS = 65_536;
export const NON_MESH_MAX_SCENE_BYTES = 256 * 1024 * 1024;
export type NonMeshAttributeArray = Float32Array | Int32Array | Uint8Array;
export interface NonMeshAttributeSource extends NonMeshAttributeIR {
values: NonMeshAttributeArray;
}
export interface NonMeshGeometrySource {
dataId: string;
positions: Float32Array;
radii?: Float32Array;
curveOffsets?: Uint32Array;
attributes?: NonMeshAttributeSource[];
}
export interface NonMeshAttributeChunk extends NonMeshAttributeIR {
storage: "FLOAT32" | "INT32" | "UINT8";
elementOffset: number;
elementCount: number;
data: ArrayBuffer;
}
export interface NonMeshGeometryChunk {
schemaVersion: typeof NON_MESH_BINARY_SCHEMA;
dataId: string;
chunkIndex: number;
chunkCount: number;
pointOffset: number;
pointCount: number;
totalPointCount: number;
byteLength: number;
sha256: string;
positions: ArrayBuffer;
radii?: ArrayBuffer;
curveOffsets?: ArrayBuffer;
attributes: NonMeshAttributeChunk[];
}
export interface NonMeshBinaryBudget {
maxPoints: number;
maxChunkPoints: number;
maxSceneBytes: number;
}
export interface NonMeshPerformanceMetrics {
pointCount: number;
chunkCount: number;
transferredBytes: number;
elapsedMs: number;
peakBytes: number;
}
export interface NonMeshPerformanceGate {
status: "READY" | "BLOCKED";
code?: "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_PERFORMANCE_BUDGET_EXCEEDED";
metrics: NonMeshPerformanceMetrics;
}
export interface ReassembledNonMeshAttribute extends NonMeshAttributeIR {
storage: NonMeshAttributeChunk["storage"];
values: NonMeshAttributeArray;
}
export interface ReassembledNonMeshGeometry {
dataId: string;
positions: Float32Array;
radii?: Float32Array;
curveOffsets?: Uint32Array;
attributes: ReassembledNonMeshAttribute[];
}
const defaultBudget: NonMeshBinaryBudget = {
maxPoints: NON_MESH_MAX_POINTS,
maxChunkPoints: NON_MESH_DEFAULT_CHUNK_POINTS,
maxSceneBytes: NON_MESH_MAX_SCENE_BYTES,
};
function fail(message: string): never {
throw new Error(`NON_MESH_BINARY_INVALID: ${message}`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isSafeNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function isArrayBuffer(value: unknown): value is ArrayBuffer {
return value instanceof ArrayBuffer;
}
function storageFor(dataType: NonMeshAttributeDataType): NonMeshAttributeChunk["storage"] {
if (dataType === "BOOL" || dataType === "BYTE_COLOR") return "UINT8";
if (dataType === "INT") return "INT32";
return "FLOAT32";
}
function bytesPerScalar(storage: NonMeshAttributeChunk["storage"]): number {
return storage === "UINT8" ? 1 : 4;
}
function domainSize(domain: NonMeshAttributeDomain, pointCount: number, curveCount: number): number {
if (domain === "POINT") return pointCount;
if (domain === "CURVE") return curveCount;
return 1;
}
function exactArrayType(source: NonMeshAttributeSource): boolean {
const storage = storageFor(source.dataType);
return (storage === "FLOAT32" && source.values instanceof Float32Array) ||
(storage === "INT32" && source.values instanceof Int32Array) ||
(storage === "UINT8" && source.values instanceof Uint8Array);
}
function validateSource(source: NonMeshGeometrySource, budget: NonMeshBinaryBudget): number {
if (!source.dataId) fail("dataId is required");
if (!(source.positions instanceof Float32Array) || source.positions.length % 3 !== 0) fail(`${source.dataId} positions must be float32 vec3 values`);
const pointCount = source.positions.length / 3;
if (pointCount > budget.maxPoints) fail(`${source.dataId} exceeds the ${budget.maxPoints} point budget`);
if (source.positions.some((value) => !Number.isFinite(value))) fail(`${source.dataId} positions contain NaN or Infinity`);
if (source.radii && (!(source.radii instanceof Float32Array) || source.radii.length !== pointCount || source.radii.some((value) => !Number.isFinite(value) || value < 0))) fail(`${source.dataId} radii do not match the point domain`);
let curveCount = 0;
if (source.curveOffsets) {
const offsets = source.curveOffsets;
curveCount = Math.max(0, offsets.length - 1);
if (!(offsets instanceof Uint32Array) || offsets.length < 2 || offsets[0] !== 0 || offsets[offsets.length - 1] !== pointCount) fail(`${source.dataId} curve offsets do not cover the point domain`);
for (let index = 1; index < offsets.length; index++) if (offsets[index] <= offsets[index - 1]) fail(`${source.dataId} curve offsets must be strictly increasing`);
}
const names = new Set<string>();
for (const attribute of source.attributes ?? []) {
if (!attribute.name || names.has(attribute.name)) fail(`${source.dataId} has a missing or duplicate attribute name`);
names.add(attribute.name);
if (![1, 2, 3, 4].includes(attribute.components)) fail(`${source.dataId}.${attribute.name} has an invalid component count`);
if (!exactArrayType(attribute)) fail(`${source.dataId}.${attribute.name} does not use the required scalar storage`);
const elements = domainSize(attribute.domain, pointCount, curveCount);
if (attribute.values.length !== elements * attribute.components) fail(`${source.dataId}.${attribute.name} does not match its ${attribute.domain} domain`);
if (attribute.values.some((value) => !Number.isFinite(value))) fail(`${source.dataId}.${attribute.name} contains NaN or Infinity`);
}
return pointCount;
}
function copyRange(values: NonMeshAttributeArray | Float32Array | Uint32Array, scalarStart: number, scalarCount: number): ArrayBuffer {
const bytesPerValue = values.BYTES_PER_ELEMENT;
const byteStart = values.byteOffset + scalarStart * bytesPerValue;
const output = new Uint8Array(scalarCount * bytesPerValue);
output.set(new Uint8Array(values.buffer, byteStart, output.byteLength));
return output.buffer;
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
}
async function digestChunk(chunk: Omit<NonMeshGeometryChunk, "sha256">): Promise<string> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) throw new Error("NON_MESH_BINARY_INVALID: SHA-256 is unavailable");
const header = new TextEncoder().encode(JSON.stringify({
schemaVersion: chunk.schemaVersion,
dataId: chunk.dataId,
chunkIndex: chunk.chunkIndex,
chunkCount: chunk.chunkCount,
pointOffset: chunk.pointOffset,
pointCount: chunk.pointCount,
totalPointCount: chunk.totalPointCount,
attributes: chunk.attributes.map(({ name, domain, dataType, components, storage, elementOffset, elementCount }) => ({ name, domain, dataType, components, storage, elementOffset, elementCount })),
}));
const buffers = [chunk.positions, chunk.radii, chunk.curveOffsets, ...chunk.attributes.map((attribute) => attribute.data)].filter((value): value is ArrayBuffer => value instanceof ArrayBuffer);
const bytes = new Uint8Array(header.byteLength + buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
bytes.set(header);
let offset = header.byteLength;
for (const buffer of buffers) {
bytes.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
}
return hex(new Uint8Array(await subtle.digest("SHA-256", bytes)));
}
export async function chunkNonMeshGeometry(
source: NonMeshGeometrySource,
options: Partial<NonMeshBinaryBudget> = {},
): Promise<NonMeshGeometryChunk[]> {
const budget = { ...defaultBudget, ...options };
if (!Number.isSafeInteger(budget.maxPoints) || budget.maxPoints <= 0 || !Number.isSafeInteger(budget.maxChunkPoints) || budget.maxChunkPoints <= 0 || !Number.isSafeInteger(budget.maxSceneBytes) || budget.maxSceneBytes <= 0) fail("binary budget is invalid");
const totalPointCount = validateSource(source, budget);
const chunkCount = Math.max(1, Math.ceil(totalPointCount / budget.maxChunkPoints));
const curveCount = Math.max(0, (source.curveOffsets?.length ?? 1) - 1);
const chunks: NonMeshGeometryChunk[] = [];
let totalBytes = 0;
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
const pointOffset = chunkIndex * budget.maxChunkPoints;
const pointCount = Math.min(budget.maxChunkPoints, totalPointCount - pointOffset);
const positions = copyRange(source.positions, pointOffset * 3, pointCount * 3);
const radii = source.radii ? copyRange(source.radii, pointOffset, pointCount) : undefined;
const curveOffsets = chunkIndex === 0 && source.curveOffsets ? copyRange(source.curveOffsets, 0, source.curveOffsets.length) : undefined;
const attributes = (source.attributes ?? []).flatMap((attribute): NonMeshAttributeChunk[] => {
const storage = storageFor(attribute.dataType);
const isPoint = attribute.domain === "POINT";
if (!isPoint && chunkIndex !== 0) return [];
const elementOffset = isPoint ? pointOffset : 0;
const elementCount = isPoint ? pointCount : domainSize(attribute.domain, totalPointCount, curveCount);
return [{
name: attribute.name,
domain: attribute.domain,
dataType: attribute.dataType,
components: attribute.components,
storage,
elementOffset,
elementCount,
data: copyRange(attribute.values, elementOffset * attribute.components, elementCount * attribute.components),
}];
});
const byteLength = positions.byteLength + (radii?.byteLength ?? 0) + (curveOffsets?.byteLength ?? 0) + attributes.reduce((total, attribute) => total + attribute.data.byteLength, 0);
totalBytes += byteLength;
if (totalBytes > budget.maxSceneBytes) fail(`${source.dataId} exceeds the ${budget.maxSceneBytes} byte scene budget`);
const unsigned = { schemaVersion: NON_MESH_BINARY_SCHEMA, dataId: source.dataId, chunkIndex, chunkCount, pointOffset, pointCount, totalPointCount, byteLength, positions, radii, curveOffsets, attributes };
chunks.push({ ...unsigned, sha256: await digestChunk(unsigned) });
}
return chunks;
}
export async function validateNonMeshGeometryChunks(
chunks: readonly unknown[],
options: Partial<NonMeshBinaryBudget> = {},
): Promise<void> {
const budget = { ...defaultBudget, ...options };
if (!Array.isArray(chunks) || chunks.length === 0) fail("at least one chunk is required");
if (!Number.isSafeInteger(budget.maxPoints) || budget.maxPoints <= 0 ||
!Number.isSafeInteger(budget.maxChunkPoints) || budget.maxChunkPoints <= 0 ||
!Number.isSafeInteger(budget.maxSceneBytes) || budget.maxSceneBytes <= 0) fail("binary budget is invalid");
const first = chunks[0];
if (!isRecord(first) || typeof first.dataId !== "string" || first.dataId.length === 0 || first.dataId.length > 256) fail("chunk dataId is invalid");
const dataId = first.dataId;
const totalPointCount = first.totalPointCount;
if (!isSafeNonNegativeInteger(totalPointCount) || totalPointCount > budget.maxPoints) fail(`${dataId} total point count exceeds its declared budget`);
let expectedPointOffset = 0;
let totalBytes = 0;
for (const [index, rawChunk] of chunks.entries()) {
if (!isRecord(rawChunk)) fail(`${dataId} chunk ${index} is invalid`);
const chunk = rawChunk;
if (chunk.schemaVersion !== NON_MESH_BINARY_SCHEMA || chunk.dataId !== dataId ||
!isSafeNonNegativeInteger(chunk.chunkIndex) || chunk.chunkIndex !== index ||
!isSafeNonNegativeInteger(chunk.chunkCount) || chunk.chunkCount !== chunks.length ||
chunk.chunkCount === 0 || !isSafeNonNegativeInteger(chunk.totalPointCount) ||
chunk.totalPointCount !== totalPointCount || !isSafeNonNegativeInteger(chunk.pointOffset) ||
chunk.pointOffset !== expectedPointOffset) fail(`${dataId} chunk sequence is inconsistent`);
if (!isSafeNonNegativeInteger(chunk.pointCount) || chunk.pointCount > budget.maxChunkPoints ||
chunk.pointOffset > Number.MAX_SAFE_INTEGER - chunk.pointCount ||
!isArrayBuffer(chunk.positions) || chunk.positions.byteLength !== chunk.pointCount * 3 * 4) fail(`${dataId} chunk ${index} point payload is invalid`);
const positions = chunk.positions;
const radii = chunk.radii;
if (radii !== undefined && (!isArrayBuffer(radii) || radii.byteLength !== chunk.pointCount * 4)) fail(`${dataId} chunk ${index} radius payload is invalid`);
const curveOffsets = chunk.curveOffsets;
if (curveOffsets !== undefined) {
if (!isArrayBuffer(curveOffsets) || index !== 0 || curveOffsets.byteLength < 8 || curveOffsets.byteLength % 4 !== 0) fail(`${dataId} curve offsets are only allowed in the first chunk`);
const offsets = new Uint32Array(curveOffsets);
if (offsets[0] !== 0 || offsets[offsets.length - 1] !== totalPointCount) fail(`${dataId} curve offsets do not cover the point domain`);
for (let offsetIndex = 1; offsetIndex < offsets.length; offsetIndex++) {
if (offsets[offsetIndex] <= offsets[offsetIndex - 1] || offsets[offsetIndex] > totalPointCount) fail(`${dataId} curve offsets are invalid`);
}
}
if (!Array.isArray(chunk.attributes)) fail(`${dataId} chunk ${index} attributes must be an array`);
const attributes = chunk.attributes;
const attributeNames = new Set<string>();
for (const rawAttribute of attributes) {
if (!isRecord(rawAttribute)) fail(`${dataId} chunk ${index} has an invalid attribute`);
const attribute = rawAttribute;
const attributeName = attribute.name;
if (typeof attributeName !== "string" || attributeName.length === 0 || attributeNames.has(attributeName)) fail(`${dataId} chunk ${index} has a duplicate or invalid attribute`);
attributeNames.add(attributeName);
if (!(["POINT", "CURVE", "INSTANCE"].includes(attribute.domain as string)) ||
!(["FLOAT", "FLOAT2", "FLOAT3", "FLOAT_COLOR", "INT", "BOOL", "BYTE_COLOR"].includes(attribute.dataType as string)) ||
!(["FLOAT32", "INT32", "UINT8"].includes(attribute.storage as string)) ||
attribute.storage !== storageFor(attribute.dataType as NonMeshAttributeDataType) ||
!isSafeNonNegativeInteger(attribute.components) || attribute.components < 1 || attribute.components > 4 ||
!isSafeNonNegativeInteger(attribute.elementOffset) || !isSafeNonNegativeInteger(attribute.elementCount) ||
!isArrayBuffer(attribute.data)) fail(`${dataId}.${attributeName} chunk metadata is invalid`);
const scalarBytes = bytesPerScalar(attribute.storage as NonMeshAttributeChunk["storage"]);
if (attribute.elementCount > Math.floor(Number.MAX_SAFE_INTEGER / attribute.components / scalarBytes)) fail(`${dataId}.${attributeName} chunk scalar count overflows the safe integer range`);
const expectedBytes = attribute.elementCount * attribute.components * scalarBytes;
if (attribute.data.byteLength !== expectedBytes) fail(`${dataId}.${attributeName} chunk payload is invalid`);
if (attribute.domain === "POINT" && (attribute.elementOffset !== chunk.pointOffset || attribute.elementCount !== chunk.pointCount)) fail(`${dataId}.${attributeName} point attribute range is invalid`);
if (attribute.domain !== "POINT" && index !== 0) fail(`${dataId}.${attributeName} curve/instance attributes must be transferred once`);
}
const measuredBytes = positions.byteLength + (radii?.byteLength ?? 0) + (curveOffsets?.byteLength ?? 0) + attributes.reduce((total, attribute) => total + (attribute as Record<string, ArrayBuffer>).data.byteLength, 0);
if (!isSafeNonNegativeInteger(chunk.byteLength) || !isSafeNonNegativeInteger(measuredBytes) || chunk.byteLength !== measuredBytes || typeof chunk.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(chunk.sha256) || await digestChunk(chunk as unknown as NonMeshGeometryChunk) !== chunk.sha256) fail(`${dataId} chunk ${index} hash or length is invalid`);
totalBytes += measuredBytes;
if (!Number.isSafeInteger(totalBytes)) fail(`${dataId} chunk bytes overflow the safe integer range`);
expectedPointOffset += chunk.pointCount;
}
if (expectedPointOffset !== totalPointCount || !Number.isSafeInteger(expectedPointOffset) || totalBytes > budget.maxSceneBytes) fail(`${dataId} chunk set exceeds its declared budget`);
}
export async function chunkNonMeshScene(
sources: readonly NonMeshGeometrySource[],
options: Partial<NonMeshBinaryBudget> = {},
): Promise<NonMeshGeometryChunk[]> {
const budget = { ...defaultBudget, ...options };
const ids = new Set<string>();
let sceneBytes = 0;
const chunks: NonMeshGeometryChunk[] = [];
for (const source of sources) {
if (ids.has(source.dataId)) fail(`duplicate dataId ${source.dataId}`);
ids.add(source.dataId);
const next = await chunkNonMeshGeometry(source, budget);
await validateNonMeshGeometryChunks(next, budget);
sceneBytes += next.reduce((total, chunk) => total + chunk.byteLength, 0);
if (sceneBytes > budget.maxSceneBytes) fail(`scene exceeds the ${budget.maxSceneBytes} byte scene budget`);
chunks.push(...next);
}
return chunks;
}
export async function benchmarkNonMeshTransfer(
source: NonMeshGeometrySource,
options: Partial<NonMeshBinaryBudget> = {},
): Promise<NonMeshPerformanceGate> {
const start = globalThis.performance?.now() ?? Date.now();
const chunks = await chunkNonMeshGeometry(source, options);
await validateNonMeshGeometryChunks(chunks, options);
const elapsedMs = (globalThis.performance?.now() ?? Date.now()) - start;
const transferredBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
const sourceBytes = source.positions.byteLength + (source.radii?.byteLength ?? 0) + (source.curveOffsets?.byteLength ?? 0) +
(source.attributes ?? []).reduce((total, attribute) => total + attribute.values.byteLength, 0);
return evaluateNonMeshPerformanceGate({
pointCount: source.positions.length / 3,
chunkCount: chunks.length,
transferredBytes,
elapsedMs,
peakBytes: sourceBytes + transferredBytes,
});
}
export function nonMeshChunkTransferables(chunks: readonly NonMeshGeometryChunk[]): Transferable[] {
return chunks.flatMap((chunk) => [chunk.positions, chunk.radii, chunk.curveOffsets, ...chunk.attributes.map((attribute) => attribute.data)].filter((value): value is ArrayBuffer => value instanceof ArrayBuffer));
}
function attributeArray(storage: NonMeshAttributeChunk["storage"], length: number): NonMeshAttributeArray {
return storage === "FLOAT32" ? new Float32Array(length) : storage === "INT32" ? new Int32Array(length) : new Uint8Array(length);
}
function chunkAttributeValues(attribute: NonMeshAttributeChunk): NonMeshAttributeArray {
return attribute.storage === "FLOAT32" ? new Float32Array(attribute.data) : attribute.storage === "INT32" ? new Int32Array(attribute.data) : new Uint8Array(attribute.data);
}
/** Reassembles already-transferred WNM chunks while rechecking all cross-chunk invariants synchronously. */
export function reassembleNonMeshGeometry(dataId: string, chunks: readonly NonMeshGeometryChunk[]): ReassembledNonMeshGeometry {
const matching = chunks.filter((chunk) => chunk.dataId === dataId).sort((left, right) => left.chunkIndex - right.chunkIndex);
if (matching.length === 0) fail(`${dataId} has no geometry chunks`);
const first = matching[0];
if (first.totalPointCount > NON_MESH_MAX_POINTS || matching.length !== first.chunkCount) fail(`${dataId} chunk set is incomplete`);
const positions = new Float32Array(first.totalPointCount * 3);
const radii = matching.some((chunk) => chunk.radii) ? new Float32Array(first.totalPointCount) : undefined;
const descriptors = new Map<string, ReassembledNonMeshAttribute>();
let expectedPointAttributes: Set<string> | undefined;
let pointOffset = 0;
let totalBytes = 0;
for (const [chunkIndex, chunk] of matching.entries()) {
if (chunk.schemaVersion !== NON_MESH_BINARY_SCHEMA || chunk.chunkIndex !== chunkIndex || chunk.chunkCount !== matching.length ||
chunk.totalPointCount !== first.totalPointCount || chunk.pointOffset !== pointOffset || chunk.positions.byteLength !== chunk.pointCount * 12) fail(`${dataId} chunk sequence is inconsistent`);
positions.set(new Float32Array(chunk.positions), chunk.pointOffset * 3);
if (radii) {
if (!chunk.radii || chunk.radii.byteLength !== chunk.pointCount * 4) fail(`${dataId} radius chunks are incomplete`);
radii.set(new Float32Array(chunk.radii), chunk.pointOffset);
}
const pointAttributes = new Set(chunk.attributes.filter((attribute) => attribute.domain === "POINT").map((attribute) => `${attribute.domain}\0${attribute.name}`));
if (!expectedPointAttributes) expectedPointAttributes = pointAttributes;
else if (pointAttributes.size !== expectedPointAttributes.size || [...expectedPointAttributes].some((key) => !pointAttributes.has(key))) fail(`${dataId} point attribute chunks are incomplete`);
for (const attribute of chunk.attributes) {
const key = `${attribute.domain}\0${attribute.name}`;
let target = descriptors.get(key);
const scalarCount = attribute.elementCount * attribute.components;
if (!target) {
const totalElements = attribute.domain === "POINT" ? first.totalPointCount : attribute.elementCount;
target = { name: attribute.name, domain: attribute.domain, dataType: attribute.dataType, components: attribute.components, storage: attribute.storage, values: attributeArray(attribute.storage, totalElements * attribute.components) };
descriptors.set(key, target);
}
if (target.storage !== attribute.storage || target.dataType !== attribute.dataType || target.components !== attribute.components ||
attribute.data.byteLength !== scalarCount * bytesPerScalar(attribute.storage)) fail(`${dataId}.${attribute.name} chunks are inconsistent`);
target.values.set(chunkAttributeValues(attribute), attribute.elementOffset * attribute.components);
}
totalBytes += chunk.byteLength;
pointOffset += chunk.pointCount;
}
if (pointOffset !== first.totalPointCount || totalBytes > NON_MESH_MAX_SCENE_BYTES || positions.some((value) => !Number.isFinite(value)) || radii?.some((value) => !Number.isFinite(value) || value < 0)) fail(`${dataId} reassembled geometry is invalid`);
const curveOffsets = first.curveOffsets ? new Uint32Array(first.curveOffsets.slice(0)) : undefined;
if (curveOffsets) {
if (curveOffsets.length < 2 || curveOffsets[0] !== 0 || curveOffsets.at(-1) !== first.totalPointCount) fail(`${dataId} curve offsets do not cover the point domain`);
for (let index = 1; index < curveOffsets.length; index++) if (curveOffsets[index] <= curveOffsets[index - 1]) fail(`${dataId} curve offsets are invalid`);
}
return { dataId, positions, radii, curveOffsets, attributes: [...descriptors.values()] };
}
export function evaluateNonMeshPerformanceGate(metrics: NonMeshPerformanceMetrics): NonMeshPerformanceGate {
const valid = Object.values(metrics).every((value) => Number.isFinite(value) && value >= 0) &&
Number.isSafeInteger(metrics.pointCount) && Number.isSafeInteger(metrics.chunkCount) && Number.isSafeInteger(metrics.transferredBytes) && Number.isSafeInteger(metrics.peakBytes);
if (!valid || metrics.pointCount > NON_MESH_MAX_POINTS || metrics.transferredBytes > NON_MESH_MAX_SCENE_BYTES) return { status: "BLOCKED", code: "NON_MESH_DATA_BUDGET_EXCEEDED", metrics };
if (metrics.pointCount === NON_MESH_MAX_POINTS && (metrics.elapsedMs > 2_000 || metrics.peakBytes > 512 * 1024 * 1024 || metrics.chunkCount > Math.ceil(NON_MESH_MAX_POINTS / NON_MESH_DEFAULT_CHUNK_POINTS))) {
return { status: "BLOCKED", code: "NON_MESH_PERFORMANCE_BUDGET_EXCEEDED", metrics };
}
return { status: "READY", metrics };
}

View File

@@ -0,0 +1,233 @@
import type { DepsgraphEvaluationIR, DepsgraphNonMeshGeometryIR } from "./depsgraph";
import type { MeshSummaryIR, NonMeshDataIR, SceneSnapshotIR } from "./scene-ir";
import type { MeshGeometryBuffer } from "./web-engine";
import { reassembleNonMeshGeometry, type NonMeshGeometryChunk, type ReassembledNonMeshGeometry } from "./nonmesh-binary";
export type NonMeshExportStatus = "MAPPED" | "LOSSY" | "BLOCKED";
export interface NonMeshExportMapping {
dataId: string;
type: NonMeshDataIR["type"];
glbTarget: "MESH" | "NONE";
usdTarget: "UsdGeomMesh" | "UsdGeomPoints" | "UsdGeomBasisCurves" | "OpenVDBAsset" | "NONE";
status: NonMeshExportStatus;
losses: string[];
errorCode?: "NON_MESH_EVALUATION_REQUIRED" | "GLB_NON_MESH_UNMAPPED" | "GLB_VOLUME_UNSUPPORTED" | "NON_MESH_RESOURCE_MISSING";
}
export interface NonMeshExportReport {
schemaVersion: 1;
mappings: NonMeshExportMapping[];
canExportGLB: boolean;
canExportUSD: boolean;
}
export interface EvaluatedNonMeshExportScene {
snapshot: SceneSnapshotIR;
geometryBuffers: MeshGeometryBuffer[];
report: NonMeshExportReport;
}
export interface BinaryNonMeshExportScene {
snapshot: SceneSnapshotIR;
geometryBuffers: MeshGeometryBuffer[];
geometryByMeshId: Map<string, ReassembledNonMeshGeometry>;
losses: Array<{ dataId: string; message: string }>;
}
function evaluatedByData(depsgraph?: DepsgraphEvaluationIR): Map<string, DepsgraphNonMeshGeometryIR[]> {
const result = new Map<string, DepsgraphNonMeshGeometryIR[]>();
for (const geometry of depsgraph?.nonMeshGeometries ?? []) {
const list = result.get(geometry.sourceDataId) ?? [];
list.push(geometry);
result.set(geometry.sourceDataId, list);
}
return result;
}
function hasBinaryGeometry(data: NonMeshDataIR): boolean {
return data.geometryStatus === "binary" && Boolean(data.geometryBufferId);
}
function mappingFor(data: NonMeshDataIR, evaluations: readonly DepsgraphNonMeshGeometryIR[]): NonMeshExportMapping {
const evaluated = evaluations.filter((geometry) => geometry.status === "EVALUATED");
if (["CURVE", "SURFACE", "FONT", "METABALL"].includes(data.type)) {
const hasTriangles = evaluated.some((geometry) => geometry.triangleCount > 0);
const hasEdges = evaluated.some((geometry) => geometry.edgeCount > 0);
if (hasTriangles) return { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomMesh", status: "LOSSY", losses: ["source control topology is baked to the evaluated triangle mesh"] };
if (hasEdges) return { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomBasisCurves", status: "LOSSY", losses: ["source control topology is baked to evaluated line segments", "surface faces and curve width are not represented"] };
return { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: evaluated.length > 0 ? "GLB_NON_MESH_UNMAPPED" : "NON_MESH_EVALUATION_REQUIRED" };
}
if (data.type === "POINT_CLOUD") {
return hasBinaryGeometry(data)
? { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomPoints", status: "LOSSY", losses: ["GLB uses a POINTS primitive and omits radius and arbitrary WNM attributes"] }
: { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: "NON_MESH_EVALUATION_REQUIRED" };
}
if (data.type === "CURVES" || data.type === "HAIR") {
return hasBinaryGeometry(data)
? { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomBasisCurves", status: "LOSSY", losses: ["GLB uses line segments and omits radius and arbitrary WNM attributes", "USD maps typed WNM attributes to primvars but Blender-only semantics may be lost"] }
: { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: "NON_MESH_EVALUATION_REQUIRED" };
}
const volumeReady = data.type === "VOLUME" && data.resourceKind === "OPENVDB" && Boolean(data.sourcePath) && (data.volumeGrids?.length ?? 0) > 0;
return volumeReady
? { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "OpenVDBAsset", status: "LOSSY", losses: ["GLB cannot represent OpenVDB volumes"], errorCode: "GLB_VOLUME_UNSUPPORTED" }
: { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: "NON_MESH_RESOURCE_MISSING" };
}
export function analyzeNonMeshExport(snapshot: SceneSnapshotIR, depsgraph?: DepsgraphEvaluationIR): NonMeshExportReport {
const byData = evaluatedByData(depsgraph);
const mappings = (snapshot.nonMeshData ?? []).map((data) => mappingFor(data, byData.get(data.id) ?? []));
return {
schemaVersion: 1,
mappings,
canExportGLB: mappings.every((mapping) => mapping.glbTarget !== "NONE"),
canExportUSD: mappings.every((mapping) => mapping.usdTarget !== "NONE"),
};
}
function geometryBuffer(evaluation: DepsgraphNonMeshGeometryIR): MeshGeometryBuffer {
const positions = Float32Array.from(evaluation.positions ?? []).buffer;
const indices = Uint32Array.from(evaluation.indices ?? []).buffer;
const edgeVertexIndices = evaluation.edgeVertexIndices?.length ? Uint32Array.from(evaluation.edgeVertexIndices).buffer : undefined;
const normals = evaluation.normals?.length ? Float32Array.from(evaluation.normals).buffer : undefined;
const uvs = evaluation.uvs?.length ? Float32Array.from(evaluation.uvs).buffer : undefined;
const triangleMaterialIndices = evaluation.triangleMaterialIndices?.length ? Uint32Array.from(evaluation.triangleMaterialIndices).buffer : undefined;
const triangleFaceIndices = evaluation.sourceElementIndices?.length ? Uint32Array.from(evaluation.sourceElementIndices).buffer : undefined;
return {
schemaVersion: 1,
meshId: evaluation.meshId,
byteLength: positions.byteLength + indices.byteLength + (edgeVertexIndices?.byteLength ?? 0) + (normals?.byteLength ?? 0) + (uvs?.byteLength ?? 0) + (triangleMaterialIndices?.byteLength ?? 0) + (triangleFaceIndices?.byteLength ?? 0),
positions,
indices,
...(edgeVertexIndices ? { edgeVertexIndices } : {}),
normals,
uvs,
...(triangleMaterialIndices ? { triangleMaterialIndices } : {}),
...(triangleFaceIndices ? { triangleFaceIndices } : {}),
};
}
function meshSummary(evaluation: DepsgraphNonMeshGeometryIR, source?: NonMeshDataIR): MeshSummaryIR {
const topology = evaluation.triangleCount > 0 ? "triangles" : "lines";
return {
id: evaluation.meshId,
name: source?.name ?? evaluation.meshId,
vertexCount: evaluation.vertexCount,
edgeCount: evaluation.edgeCount,
faceCount: evaluation.triangleCount,
cornerCount: evaluation.triangleCount * 3,
triangleCount: evaluation.triangleCount,
geometryStatus: "binary",
geometryBufferId: evaluation.meshId,
topology,
materialSlotIds: evaluation.materialSlotIds,
};
}
export function mapEvaluatedNonMeshForExport(
snapshot: SceneSnapshotIR,
geometryBuffers: readonly MeshGeometryBuffer[],
depsgraph: DepsgraphEvaluationIR,
): EvaluatedNonMeshExportScene {
const sourceById = new Map((snapshot.nonMeshData ?? []).map((data) => [data.id, data]));
const successful = (depsgraph.nonMeshGeometries ?? []).filter((geometry) => geometry.status === "EVALUATED" && sourceById.has(geometry.sourceDataId));
const byObject = new Map(successful.map((geometry) => [geometry.objectId, geometry]));
const evaluatedBySource = evaluatedByData(depsgraph);
const nonMeshData = (snapshot.nonMeshData ?? []).map((data): NonMeshDataIR => ({
...data,
evaluatedGeometry: (evaluatedBySource.get(data.id) ?? []).map((geometry) => ({
objectId: geometry.objectId,
meshId: geometry.meshId,
vertexCount: geometry.vertexCount,
edgeCount: geometry.edgeCount,
triangleCount: geometry.triangleCount,
status: geometry.status,
...(geometry.errorCode ? { errorCode: geometry.errorCode } : {}),
})),
}));
return {
snapshot: {
...snapshot,
nodes: snapshot.nodes.map((node) => {
const evaluation = byObject.get(node.id);
return evaluation ? { ...node, dataId: evaluation.meshId } : node;
}),
meshes: [...snapshot.meshes, ...successful.map((geometry) => meshSummary(geometry, sourceById.get(geometry.sourceDataId)))],
nonMeshData,
},
geometryBuffers: [...geometryBuffers, ...successful.map(geometryBuffer)],
report: analyzeNonMeshExport(snapshot, depsgraph),
};
}
function lineEdges(offsets: Uint32Array): Uint32Array {
let edgeCount = 0;
for (let curve = 0; curve < offsets.length - 1; curve++) edgeCount += Math.max(0, offsets[curve + 1] - offsets[curve] - 1);
const edges = new Uint32Array(edgeCount * 2);
let cursor = 0;
for (let curve = 0; curve < offsets.length - 1; curve++) {
for (let point = offsets[curve]; point + 1 < offsets[curve + 1]; point++) {
edges[cursor++] = point;
edges[cursor++] = point + 1;
}
}
return edges;
}
export function mapBinaryNonMeshForExport(
snapshot: SceneSnapshotIR,
geometryBuffers: readonly MeshGeometryBuffer[],
chunks: readonly NonMeshGeometryChunk[],
): BinaryNonMeshExportScene {
const mappedData = new Map<string, { mesh: MeshSummaryIR; buffer: MeshGeometryBuffer; geometry: ReassembledNonMeshGeometry }>();
const losses: BinaryNonMeshExportScene["losses"] = [];
for (const data of snapshot.nonMeshData ?? []) {
if (!["POINT_CLOUD", "CURVES", "HAIR"].includes(data.type) || data.geometryStatus !== "binary") continue;
const geometry = reassembleNonMeshGeometry(data.id, chunks);
if (geometry.positions.length !== data.pointCount * 3) throw new Error(`NON_MESH_BINARY_INVALID: ${data.id} point count does not match SceneIR`);
const meshId = `wnm:${data.id}`;
const pointTopology = data.type === "POINT_CLOUD";
const offsets = geometry.curveOffsets;
if (!pointTopology && !offsets) throw new Error(`NON_MESH_BINARY_INVALID: ${data.id} has no curve offsets`);
const indices = pointTopology ? Uint32Array.from({ length: data.pointCount }, (_, index) => index) : lineEdges(offsets!);
if (!pointTopology && indices.length === 0) throw new Error(`NON_MESH_BINARY_INVALID: ${data.id} contains no exportable curve segments`);
const positions = geometry.positions.slice().buffer;
const indexBuffer = indices.buffer.slice(indices.byteOffset, indices.byteOffset + indices.byteLength) as ArrayBuffer;
const buffer: MeshGeometryBuffer = {
schemaVersion: 1,
meshId,
byteLength: positions.byteLength + indexBuffer.byteLength,
positions,
indices: pointTopology ? indexBuffer : new ArrayBuffer(0),
...(pointTopology ? {} : { edgeVertexIndices: indexBuffer }),
};
const mesh: MeshSummaryIR = {
id: meshId,
name: data.name,
vertexCount: data.pointCount,
edgeCount: pointTopology ? 0 : indices.length / 2,
faceCount: 0,
cornerCount: 0,
triangleCount: 0,
geometryStatus: "binary",
geometryBufferId: meshId,
topology: pointTopology ? "points" : "lines",
};
mappedData.set(data.id, { mesh, buffer, geometry });
const lost = [geometry.radii ? "radius" : "", ...geometry.attributes.map((attribute) => attribute.name)].filter(Boolean);
if (lost.length > 0) losses.push({ dataId: data.id, message: `GLB primitive preserves topology but omits WNM attributes: ${lost.join(", ")}` });
}
const geometryByMeshId = new Map<string, ReassembledNonMeshGeometry>();
for (const { mesh, geometry } of mappedData.values()) geometryByMeshId.set(mesh.id, geometry);
return {
snapshot: {
...snapshot,
nodes: snapshot.nodes.map((node) => mappedData.has(node.dataId ?? "") ? { ...node, dataId: mappedData.get(node.dataId!)!.mesh.id } : node),
meshes: [...snapshot.meshes, ...[...mappedData.values()].map((entry) => entry.mesh)],
nonMeshData: (snapshot.nonMeshData ?? []).filter((data) => !mappedData.has(data.id)),
},
geometryBuffers: [...geometryBuffers, ...[...mappedData.values()].map((entry) => entry.buffer)],
geometryByMeshId,
losses,
};
}

148
web/protocol/paint.ts Normal file
View File

@@ -0,0 +1,148 @@
export const PAINT_BUDGET = {
maxSamples: 100_000,
maxWeightEntries: 1_000_000,
maxTextureTileBytes: 256 * 1024 * 1024,
maxStrokeBytes: 64 * 1024 * 1024,
} as const;
export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
export interface PaintHitIR {
position: [number, number, number];
normal?: [number, number, number];
faceIndex?: number;
barycentric?: [number, number, number];
uv?: [number, number];
pressure?: number;
}
export interface PaintStrokeIR {
schemaVersion: 1;
mode: PaintMode;
objectId: string;
revision: number;
radius: number;
strength: number;
samples: PaintHitIR[];
color?: [number, number, number, number];
vertexGroup?: string;
textureAssetId?: string;
textureTile?: number;
spacing?: number;
}
export interface WeightPatchIR {
schemaVersion: 1;
objectId: string;
revision: number;
vertexGroup: string;
indices: number[];
values: number[];
normalize?: boolean;
mirror?: boolean;
}
function fail(path: string, message: string, budget = false): never {
throw new Error(`${budget ? "PAINT_BUDGET_EXCEEDED" : "PAINT_SCHEMA_INVALID"}: ${path} ${message}`);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
return value as Record<string, unknown>;
}
function finite(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) fail(path, "must be finite");
return value;
}
function integer(value: unknown, path: string): number {
const result = finite(value, path);
if (!Number.isSafeInteger(result) || result < 0) fail(path, "must be a non-negative safe integer");
return result;
}
function tuple(value: unknown, length: number, path: string): number[] {
if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`);
if (value.some((item) => typeof item !== "number" || !Number.isFinite(item))) fail(path, "must contain finite numbers");
return value as number[];
}
function string(value: unknown, path: string, allowEmpty = false): string {
if (typeof value !== "string" || (!allowEmpty && value.length === 0) || value.length > 255) fail(path, "is outside the bounded string range");
return value;
}
function parseHit(value: unknown, path: string): PaintHitIR {
const hit = record(value, path);
const position = tuple(hit.position, 3, `${path}.position`) as [number, number, number];
const result: PaintHitIR = { position };
if (hit.normal !== undefined) result.normal = tuple(hit.normal, 3, `${path}.normal`) as [number, number, number];
if (hit.faceIndex !== undefined) result.faceIndex = integer(hit.faceIndex, `${path}.faceIndex`);
if (hit.barycentric !== undefined) {
result.barycentric = tuple(hit.barycentric, 3, `${path}.barycentric`) as [number, number, number];
if (result.barycentric.some((item) => item < 0 || item > 1) || Math.abs(result.barycentric.reduce((sum, item) => sum + item, 0) - 1) > 1e-4) fail(`${path}.barycentric`, "must be non-negative and sum to one");
}
if (hit.uv !== undefined) result.uv = tuple(hit.uv, 2, `${path}.uv`) as [number, number];
if (hit.pressure !== undefined) {
const pressure = finite(hit.pressure, `${path}.pressure`);
if (pressure < 0 || pressure > 1) fail(`${path}.pressure`, "must be in [0,1]");
result.pressure = pressure;
}
return result;
}
export function parsePaintStroke(value: unknown): PaintStrokeIR {
const stroke = record(value, "paintStroke");
if (stroke.schemaVersion !== 1) fail("paintStroke.schemaVersion", "is unsupported");
const mode = stroke.mode;
if (mode !== "VERTEX_COLOR" && mode !== "WEIGHT" && mode !== "TEXTURE") fail("paintStroke.mode", "is invalid");
const samples = Array.isArray(stroke.samples) ? stroke.samples : fail("paintStroke.samples", "must be an array");
if (samples.length === 0 || samples.length > PAINT_BUDGET.maxSamples) fail("paintStroke.samples", "exceeds the sample budget", samples.length > PAINT_BUDGET.maxSamples);
const radius = finite(stroke.radius, "paintStroke.radius");
const strength = finite(stroke.strength, "paintStroke.strength");
if (radius <= 0 || radius > 100_000) fail("paintStroke.radius", "is outside the bounded range");
if (strength < -1 || strength > 1) fail("paintStroke.strength", "must be in [-1,1]");
const revision = integer(stroke.revision, "paintStroke.revision");
const result: PaintStrokeIR = { schemaVersion: 1, mode, objectId: string(stroke.objectId, "paintStroke.objectId"), revision, radius, strength, samples: samples.map((sample, index) => parseHit(sample, `paintStroke.samples[${index}]`)) };
if (stroke.color !== undefined) {
result.color = tuple(stroke.color, 4, "paintStroke.color") as [number, number, number, number];
if (result.color.some((item) => item < 0 || item > 1)) fail("paintStroke.color", "must be in [0,1]");
}
if (stroke.vertexGroup !== undefined) result.vertexGroup = string(stroke.vertexGroup, "paintStroke.vertexGroup");
if (stroke.textureAssetId !== undefined) result.textureAssetId = string(stroke.textureAssetId, "paintStroke.textureAssetId");
if (stroke.textureTile !== undefined) result.textureTile = integer(stroke.textureTile, "paintStroke.textureTile");
if (stroke.spacing !== undefined) {
result.spacing = finite(stroke.spacing, "paintStroke.spacing");
if (result.spacing < 0 || result.spacing > 100_000) fail("paintStroke.spacing", "is outside the bounded range");
}
if (mode === "WEIGHT" && !result.vertexGroup) fail("paintStroke.vertexGroup", "is required for weight paint");
if (mode === "TEXTURE" && !result.textureAssetId) fail("paintStroke.textureAssetId", "is required for texture paint");
const estimatedBytes = samples.length * 64;
if (estimatedBytes > PAINT_BUDGET.maxStrokeBytes) fail("paintStroke", "exceeds the byte budget", true);
return result;
}
export function parseWeightPatch(value: unknown): WeightPatchIR {
const patch = record(value, "weightPatch");
if (patch.schemaVersion !== 1) fail("weightPatch.schemaVersion", "is unsupported");
if (!Array.isArray(patch.indices) || !Array.isArray(patch.values) || patch.indices.length !== patch.values.length) fail("weightPatch", "indices and values must have equal lengths");
if (patch.indices.length > PAINT_BUDGET.maxWeightEntries) fail("weightPatch", "exceeds the weight entry budget", true);
const indices = patch.indices.map((item, index) => integer(item, `weightPatch.indices[${index}]`));
const values = patch.values.map((item, index) => {
const result = finite(item, `weightPatch.values[${index}]`);
if (result < 0 || result > 1) fail(`weightPatch.values[${index}]`, "must be in [0,1]");
return result;
});
const result: WeightPatchIR = { schemaVersion: 1, objectId: string(patch.objectId, "weightPatch.objectId"), revision: integer(patch.revision, "weightPatch.revision"), vertexGroup: string(patch.vertexGroup, "weightPatch.vertexGroup"), indices, values };
if (patch.normalize !== undefined) {
if (typeof patch.normalize !== "boolean") fail("weightPatch.normalize", "must be boolean");
result.normalize = patch.normalize;
}
if (patch.mirror !== undefined) {
if (typeof patch.mirror !== "boolean") fail("weightPatch.mirror", "must be boolean");
result.mirror = patch.mirror;
}
return result;
}

View File

@@ -0,0 +1,239 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const PHYSICS_SIMULATION_SCHEMA = 1 as const;
export const PHYSICS_SIMULATION_BUDGET = {
maxSystems: 4_096,
maxDependenciesPerSystem: 1_024,
maxSettings: 256,
maxSettingsBytes: 64 * 1024,
maxFrames: 100_000,
} as const;
export const PHYSICS_FAMILIES = [
"RIGID_BODY",
"SOFT_BODY",
"CLOTH",
"FLUID",
"DYNAMIC_PAINT",
"PARTICLE",
"HAIR",
] as const;
export type PhysicsFamily = typeof PHYSICS_FAMILIES[number];
export type PhysicsExecutionRequest = "METADATA" | "CACHE_MANIFEST" | "CACHE_PLAYBACK" | "LOCAL_SOLVER" | "SERVER_JOB";
export type PhysicsSettingValue = boolean | number | string | null;
export interface PhysicsCacheBindingIR {
cacheKey: string;
source: "BLENDER_DESKTOP_BAKE";
sourceBlendSha256: string;
settingsHash: string;
inputHash: string;
cacheSha256: string;
frameStart: number;
frameEnd: number;
cachedFrames: number[];
status: "COMPLETE" | "PARTIAL";
}
export interface PhysicsSystemIR {
id: string;
family: PhysicsFamily;
ownerObjectId: string;
settingsHash: string;
settings: Record<string, PhysicsSettingValue>;
dependencyIds: string[];
cache?: PhysicsCacheBindingIR;
}
export interface PhysicsSimulationManifestIR {
schemaVersion: typeof PHYSICS_SIMULATION_SCHEMA;
systems: PhysicsSystemIR[];
}
export interface PhysicsFamilyCapabilityIR {
family: PhysicsFamily;
metadata: "LOCAL_BOUNDED";
cacheManifest: "LOCAL_BOUNDED";
cachePlayback: "BLOCKED";
localSolver: "BLOCKED";
serverJob: "BLOCKED";
}
export class PhysicsSimulationValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "PhysicsSimulationValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const CACHE_KEY = /^[A-Za-z0-9._:-]{1,256}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string, prefix?: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256 || (prefix && !value.startsWith(prefix))) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is invalid`);
}
return value;
}
function digest(value: unknown, name: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`);
}
return value;
}
function frame(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside the supported frame range`);
}
return value;
}
function parseSettings(value: unknown, systemIndex: number): Record<string, PhysicsSettingValue> {
if (!record(value)) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].settings must be an object`);
const entries = Object.entries(value);
if (entries.length > PHYSICS_SIMULATION_BUDGET.maxSettings) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings exceeds the entry budget`);
}
const settings: Record<string, PhysicsSettingValue> = {};
for (const [name, item] of entries) {
if (name.length === 0 || name.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(name) ||
!(item === null || typeof item === "boolean" || typeof item === "string" || (typeof item === "number" && Number.isFinite(item)))) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].settings.${name} is invalid`);
}
if (typeof item === "string" && item.length > 1_024) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings.${name} exceeds the string budget`);
}
settings[name] = item;
}
if (new TextEncoder().encode(JSON.stringify(settings)).byteLength > PHYSICS_SIMULATION_BUDGET.maxSettingsBytes) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings exceeds the byte budget`);
}
return settings;
}
function parseCache(value: unknown, settingsHash: string, systemIndex: number): PhysicsCacheBindingIR | undefined {
if (value === undefined) return undefined;
if (!record(value) || value.source !== "BLENDER_DESKTOP_BAKE" || !CACHE_KEY.test(String(value.cacheKey ?? "")) ||
(value.status !== "COMPLETE" && value.status !== "PARTIAL")) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache is invalid`);
}
const frameStart = frame(value.frameStart, `systems[${systemIndex}].cache.frameStart`);
const frameEnd = frame(value.frameEnd, `systems[${systemIndex}].cache.frameEnd`);
if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.cachedFrames)) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache frame range exceeds the budget`);
}
const cachedFrames = value.cachedFrames.map((item, frameIndex) => frame(item, `systems[${systemIndex}].cache.cachedFrames[${frameIndex}]`));
if (cachedFrames.length === 0 || cachedFrames.length > PHYSICS_SIMULATION_BUDGET.maxFrames ||
cachedFrames.some((item, index) => item < frameStart || item > frameEnd || (index > 0 && item <= cachedFrames[index - 1]))) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames must be unique, ordered and in range`);
}
if (value.status === "COMPLETE" && (cachedFrames.length !== frameEnd - frameStart + 1 || cachedFrames.some((item, index) => item !== frameStart + index))) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] declares an incomplete cache as COMPLETE`);
}
const cacheSettingsHash = digest(value.settingsHash, `systems[${systemIndex}].cache.settingsHash`);
if (cacheSettingsHash !== settingsHash) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] cache settings do not match the current system`);
}
return {
cacheKey: value.cacheKey as string,
source: "BLENDER_DESKTOP_BAKE",
sourceBlendSha256: digest(value.sourceBlendSha256, `systems[${systemIndex}].cache.sourceBlendSha256`),
settingsHash: cacheSettingsHash,
inputHash: digest(value.inputHash, `systems[${systemIndex}].cache.inputHash`),
cacheSha256: digest(value.cacheSha256, `systems[${systemIndex}].cache.cacheSha256`),
frameStart,
frameEnd,
cachedFrames,
status: value.status,
};
}
export function parsePhysicsSimulationManifest(value: unknown): PhysicsSimulationManifestIR {
if (!record(value) || value.schemaVersion !== PHYSICS_SIMULATION_SCHEMA || !Array.isArray(value.systems)) {
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported PhysicsSimulation manifest schema");
}
if (value.systems.length > PHYSICS_SIMULATION_BUDGET.maxSystems) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", "Physics system count exceeds the budget");
}
const ids = new Set<string>();
const systems = value.systems.map((item, index): PhysicsSystemIR => {
if (!record(item) || !PHYSICS_FAMILIES.includes(item.family as PhysicsFamily) || !Array.isArray(item.dependencyIds)) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] is invalid`);
}
const id = text(item.id, `systems[${index}].id`, "physics:");
if (ids.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `Duplicate physics system ${id}`);
ids.add(id);
if (item.dependencyIds.length > PHYSICS_SIMULATION_BUDGET.maxDependenciesPerSystem) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${index}].dependencyIds exceeds the budget`);
}
const dependencyIds = item.dependencyIds.map((dependency, dependencyIndex) =>
text(dependency, `systems[${index}].dependencyIds[${dependencyIndex}]`));
if (new Set(dependencyIds).size !== dependencyIds.length) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] contains duplicate dependencies`);
}
const settingsHash = digest(item.settingsHash, `systems[${index}].settingsHash`);
return {
id,
family: item.family as PhysicsFamily,
ownerObjectId: text(item.ownerObjectId, `systems[${index}].ownerObjectId`, "object:"),
settingsHash,
settings: parseSettings(item.settings, index),
dependencyIds,
cache: parseCache(item.cache, settingsHash, index),
};
});
const byId = new Map(systems.map((system) => [system.id, system]));
const active = new Set<string>();
const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_DEPENDENCY_CYCLE", `Physics dependency cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
for (const dependency of byId.get(id)?.dependencyIds ?? []) if (byId.has(dependency)) visit(dependency);
active.delete(id);
complete.add(id);
};
for (const system of systems) visit(system.id);
return { schemaVersion: PHYSICS_SIMULATION_SCHEMA, systems };
}
export function physicsCapabilityInventory(): PhysicsFamilyCapabilityIR[] {
return PHYSICS_FAMILIES.map((family) => ({
family,
metadata: "LOCAL_BOUNDED",
cacheManifest: "LOCAL_BOUNDED",
cachePlayback: "BLOCKED",
localSolver: "BLOCKED",
serverJob: "BLOCKED",
}));
}
export function gatePhysicsExecution(family: PhysicsFamily, request: PhysicsExecutionRequest): CapabilityGateResult {
if (request === "METADATA" || request === "CACHE_MANIFEST") return readyGate("N-018", `${family}_${request}`);
const issue = request === "CACHE_PLAYBACK" ?
capabilityIssue("PHYSICS_CACHE_PLAYBACK_UNAVAILABLE", `${family} cache playback is not connected to frame evaluation`) :
request === "LOCAL_SOLVER" ?
capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} has no verified local WASM solver`) :
capabilityIssue("PHYSICS_SERVER_UNAVAILABLE", `${family} server job execution is not configured`);
return blockedGate("N-018", `${family}_${request}`, [issue]);
}
export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame: number): { cacheKey: string; frame: number } {
const cache = system.cache;
if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.cachedFrames.includes(requestedFrame)) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Physics cache has no verified frame ${requestedFrame}`);
}
return { cacheKey: cache.cacheKey, frame: requestedFrame };
}

11
web/protocol/progress.ts Normal file
View File

@@ -0,0 +1,11 @@
export type ProgressPhase = "started" | "progress" | "completed" | "failed" | "cancelled";
export interface ProgressEvent {
requestId: string;
operation: string;
phase: ProgressPhase;
fraction?: number;
message?: string;
revision?: number;
errorCode?: string;
}

View File

@@ -0,0 +1,64 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const RELEASE_GATE_SCHEMA = 2 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 ReleaseEvidenceIR {
browser: { chromium: boolean };
runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean };
performance: { geometry1M: boolean; geometry10M: boolean; texture4K: boolean; texture8K: boolean; longMedia: boolean; simulationCache: boolean };
faults: { oom: boolean; deviceLoss: boolean; networkInterrupt: boolean; malformedBlend: boolean; zipBomb: boolean };
provenance: { license: boolean; sbom: boolean; sourceOffer: boolean; deterministicPackage: boolean };
}
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR }
export interface ReleaseGateEvaluationIR { status: "READY" | "BLOCKED"; issueCodes: ErrorCode[]; missing: string[] }
export class ReleaseGateValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ReleaseGateValidationError"; this.code = code; }
}
const STATUSES = new Set<ParityStatus>(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"]);
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
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 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");
const group = (name: string, keys: readonly string[]): Record<string, boolean> => { const item = value[name]; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `evidence.${name} is invalid`); return Object.fromEntries(keys.map((key) => [key, bool(item[key], `evidence.${name}.${key}`)])); };
return { 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"] };
}
function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
const byId = new Map(families.map((family) => [family.id, family])); const active = new Set<string>(); const complete = new Set<string>();
const visit = (id: string): void => { if (active.has(id)) throw new ReleaseGateValidationError("RELEASE_DEPENDENCY_CYCLE", `Release dependency cycle includes ${id}`); if (complete.has(id)) return; const family = byId.get(id); if (!family) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Missing release dependency ${id}`); active.add(id); family.dependencies.forEach(visit); active.delete(id); complete.add(id); };
families.forEach((family) => visit(family.id));
}
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`) }; });
assertDependencies(families);
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
}
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {
const manifest = parseReleaseManifest(value); const missing: string[] = []; const issueCodes: ErrorCode[] = [];
const add = (path: string, code: ErrorCode): void => { missing.push(path); if (!issueCodes.includes(code)) issueCodes.push(code); };
manifest.families.forEach((family) => { if (family.status === "BLOCKED") add(`family.${family.id}`, "RELEASE_EVIDENCE_MISSING"); if (family.status !== "BLOCKED" && family.acceptance.length === 0) add(`family.${family.id}.acceptance`, "RELEASE_EVIDENCE_MISSING"); });
(Object.entries(manifest.evidence.browser) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`browser.${key}`, "RELEASE_TEST_CHANNEL_MISSING"); });
(Object.entries(manifest.evidence.runtime) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`runtime.${key}`, "RELEASE_EVIDENCE_MISSING"); });
(Object.entries(manifest.evidence.performance) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`performance.${key}`, "RELEASE_PERFORMANCE_MISSING"); });
(Object.entries(manifest.evidence.faults) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`faults.${key}`, "RELEASE_FAULT_EVIDENCE_MISSING"); });
(Object.entries(manifest.evidence.provenance) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`provenance.${key}`, "RELEASE_PROVENANCE_MISSING"); });
return { status: missing.length === 0 ? "READY" : "BLOCKED", issueCodes, missing: missing.sort() };
}
export function gateRelease(value: unknown): CapabilityGateResult {
const evaluation = evaluateReleaseManifest(value); if (evaluation.status === "READY") return readyGate("N-026", "RELEASE");
return blockedGate("N-026", "RELEASE", evaluation.issueCodes.map((code) => capabilityIssue(code, `Release evidence is missing: ${evaluation.missing.filter((path) => path.includes(code === "RELEASE_TEST_CHANNEL_MISSING" ? "browser" : code === "RELEASE_PERFORMANCE_MISSING" ? "performance" : code === "RELEASE_FAULT_EVIDENCE_MISSING" ? "faults" : code === "RELEASE_PROVENANCE_MISSING" ? "provenance" : "family" )).join(", ") || code}`)));
}
export function serializeReleaseManifest(value: unknown): string { const manifest = parseReleaseManifest(value); return JSON.stringify({ ...manifest, families: [...manifest.families].sort((a, b) => a.id.localeCompare(b.id)), generatedAt: manifest.generatedAt }, null, 2); }

View File

@@ -0,0 +1,197 @@
import type { CapabilityGateResult } from "./capability-gates";
import { blockedGate, capabilityIssue, readyGate } from "./capability-gates";
import type { ErrorCode } from "./error";
import type { ImageIR, MaterialIR, SceneSnapshotIR } from "./scene-ir";
export const GPU_TEXTURE_PAYLOAD_SCHEMA = 1 as const;
export const MAX_GPU_TEXTURE_BYTES = 64 * 1024 * 1024;
export const MAX_GPU_TEXTURE_DIMENSION = 16_384;
export const MAX_GPU_TEXTURE_ASSETS = 256;
export const MAX_UDIM_TILES = 64;
export type GPUTextureUsage = "BASE_COLOR" | "NORMAL" | "EMISSIVE" | "DATA" | "ENVIRONMENT" | "UDIM_TILE";
export type GPUTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR";
export interface GPUTextureAssetRequest {
assetId: string;
imageId: string;
mimeType: string;
width: number;
height: number;
usage: GPUTextureUsage;
colorSpace: GPUTextureColorSpace;
tileNumber?: number;
}
export interface GPUTextureAsset extends GPUTextureAssetRequest {
schemaVersion: typeof GPU_TEXTURE_PAYLOAD_SCHEMA;
sha256: string;
byteLength: number;
data: ArrayBuffer;
}
export interface UDIMTileManifestEntry {
number: number;
u: number;
v: number;
assetId: string;
width: number;
height: number;
packed: boolean;
}
export interface UDIMManifest {
schemaVersion: 1;
imageId: string;
tiles: UDIMTileManifestEntry[];
}
export class RenderAssetValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(message);
this.name = "RenderAssetValidationError";
this.code = code;
}
}
function imageMimeType(value: string | undefined): string {
return (value ?? "application/octet-stream").toLowerCase();
}
function isRasterMimeType(value: string): boolean {
return value === "image/png" || value === "image/jpeg" || value === "image/webp";
}
export async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function createGPUTextureAsset(
request: GPUTextureAssetRequest,
data: ArrayBuffer,
): Promise<GPUTextureAsset> {
return {
schemaVersion: GPU_TEXTURE_PAYLOAD_SCHEMA,
...request,
byteLength: data.byteLength,
sha256: await sha256Hex(data),
data,
};
}
export async function validateGPUTextureAsset(asset: GPUTextureAsset): Promise<void> {
if (asset.schemaVersion !== GPU_TEXTURE_PAYLOAD_SCHEMA || !asset.assetId || !asset.imageId || !isRasterMimeType(imageMimeType(asset.mimeType))) {
throw new RenderAssetValidationError("GPU_TEXTURE_INVALID", `Invalid GPU texture metadata for ${asset.assetId || "unknown asset"}`);
}
if (!Number.isInteger(asset.width) || !Number.isInteger(asset.height) || asset.width < 1 || asset.height < 1 || asset.width > MAX_GPU_TEXTURE_DIMENSION || asset.height > MAX_GPU_TEXTURE_DIMENSION) {
throw new RenderAssetValidationError("GPU_TEXTURE_BUDGET_EXCEEDED", `Texture dimensions exceed the PBR-007 limit: ${asset.width}x${asset.height}`);
}
if (asset.byteLength !== asset.data.byteLength || asset.byteLength < 1 || asset.byteLength > MAX_GPU_TEXTURE_BYTES) {
throw new RenderAssetValidationError("GPU_TEXTURE_BUDGET_EXCEEDED", `Texture payload exceeds the PBR-007 limit: ${asset.byteLength} bytes`);
}
if (!/^[a-f0-9]{64}$/.test(asset.sha256) || await sha256Hex(asset.data) !== asset.sha256) {
throw new RenderAssetValidationError("GPU_TEXTURE_HASH_MISMATCH", `Texture payload checksum mismatch: ${asset.assetId}`);
}
if (asset.tileNumber !== undefined && (!Number.isInteger(asset.tileNumber) || asset.tileNumber < 1001 || asset.tileNumber > 1999)) {
throw new RenderAssetValidationError("UDIM_MANIFEST_INVALID", `Invalid UDIM tile number: ${asset.tileNumber}`);
}
}
function materialUsages(materials: MaterialIR[]): Map<string, Set<GPUTextureUsage>> {
const usages = new Map<string, Set<GPUTextureUsage>>();
const add = (imageId: string, usage: GPUTextureUsage): void => {
const current = usages.get(imageId) ?? new Set<GPUTextureUsage>();
current.add(usage);
usages.set(imageId, current);
};
for (const material of materials) {
if (material.normalImageId) add(material.normalImageId, "NORMAL");
for (const imageId of material.imageIds ?? []) add(imageId, imageId === material.normalImageId ? "NORMAL" : "BASE_COLOR");
for (const node of material.nodes ?? []) {
if (node.type === "IMAGE_TEXTURE" && node.imageId) add(node.imageId, node.imageId === material.normalImageId ? "NORMAL" : "BASE_COLOR");
}
}
return usages;
}
function requestForImage(image: ImageIR, usage: GPUTextureUsage): GPUTextureAssetRequest[] {
const colorSpace: GPUTextureColorSpace = usage === "BASE_COLOR" || usage === "EMISSIVE" ? "SRGB" : usage === "ENVIRONMENT" ? "LINEAR" : "NON_COLOR";
if (image.tiles?.length) {
return image.tiles.filter((tile) => tile.packed).map((tile) => ({
assetId: tile.assetId,
imageId: image.id,
mimeType: imageMimeType(tile.mimeType),
width: tile.width,
height: tile.height,
usage: "UDIM_TILE",
colorSpace,
tileNumber: tile.number,
}));
}
if (!image.packed) return [];
return [{
assetId: image.assetId,
imageId: image.id,
mimeType: imageMimeType(image.mimeType),
width: image.width ?? 0,
height: image.height ?? 0,
usage,
colorSpace,
}];
}
export function collectGPUTextureAssetRequests(snapshot: SceneSnapshotIR): GPUTextureAssetRequest[] {
const usages = materialUsages(snapshot.materials);
for (const world of snapshot.worlds) {
if (world.environmentImageId) {
const current = usages.get(world.environmentImageId) ?? new Set<GPUTextureUsage>();
current.add("ENVIRONMENT");
usages.set(world.environmentImageId, current);
}
}
const requests = snapshot.images.flatMap((image) => [...(usages.get(image.id) ?? [])].flatMap((usage) => requestForImage(image, usage)));
const unique = new Map<string, GPUTextureAssetRequest>();
for (const request of requests) unique.set(`${request.assetId}:${request.usage}:${request.colorSpace}`, request);
if (unique.size > MAX_GPU_TEXTURE_ASSETS) throw new RenderAssetValidationError("GPU_TEXTURE_BUDGET_EXCEEDED", `Scene requests ${unique.size} textures; limit is ${MAX_GPU_TEXTURE_ASSETS}`);
return [...unique.values()];
}
export function createUDIMManifest(image: ImageIR): UDIMManifest {
if (image.sourceKind !== "TILED" || !image.tiles?.length || image.tiles.length > MAX_UDIM_TILES) {
throw new RenderAssetValidationError("UDIM_MANIFEST_INVALID", `Image ${image.id} does not have a bounded UDIM tile set`);
}
const seen = new Set<number>();
const tiles = image.tiles.map((tile) => {
if (!Number.isInteger(tile.number) || tile.number < 1001 || tile.number > 1999 || seen.has(tile.number) || tile.width < 1 || tile.height < 1) {
throw new RenderAssetValidationError("UDIM_MANIFEST_INVALID", `Invalid or duplicate UDIM tile ${tile.number}`);
}
seen.add(tile.number);
const offset = tile.number - 1001;
return { number: tile.number, u: offset % 10, v: Math.floor(offset / 10), assetId: tile.assetId, width: tile.width, height: tile.height, packed: tile.packed };
}).sort((a, b) => a.number - b.number);
return { schemaVersion: 1, imageId: image.id, tiles };
}
export function gateUDIMImage(image: ImageIR, multiTileRendererAvailable = false): CapabilityGateResult {
try {
const manifest = createUDIMManifest(image);
const missing = manifest.tiles.filter((tile) => !tile.packed);
if (missing.length) return blockedGate("PBR-008", "UDIM", [capabilityIssue("UDIM_TILE_MISSING", `UDIM tiles are not packed: ${missing.map((tile) => tile.number).join(", ")}`, "tiles")]);
if (manifest.tiles.length > 1 && !multiTileRendererAvailable) return blockedGate("PBR-008", "UDIM", [capabilityIssue("UDIM_MULTI_TILE_UNAVAILABLE", "Multi-tile UDIM sampling is not available in the current renderer", "tiles")]);
return readyGate("PBR-008", "UDIM");
}
catch (error) {
const issue = error as RenderAssetValidationError;
return blockedGate("PBR-008", "UDIM", [capabilityIssue(issue.code ?? "UDIM_MANIFEST_INVALID", issue.message)]);
}
}
export function gateEnvironmentImage(image: ImageIR | undefined): CapabilityGateResult {
if (!image) return blockedGate("PBR-009", "HDRI_IBL", [capabilityIssue("IBL_ENVIRONMENT_MISSING", "World environment image is missing")]);
if (!image.packed) return blockedGate("PBR-009", "HDRI_IBL", [capabilityIssue("IBL_ENVIRONMENT_MISSING", "World environment image is not packed", "environmentImageId")]);
if (!isRasterMimeType(imageMimeType(image.mimeType))) return blockedGate("PBR-009", "HDRI_IBL", [capabilityIssue("IBL_FORMAT_UNSUPPORTED", `Environment format is not decoded by the Web renderer: ${image.mimeType ?? "unknown"}`, "mimeType")]);
return readyGate("PBR-009", "HDRI_IBL");
}

View File

@@ -0,0 +1,65 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ShaderNodeType } from "./shader-graph";
export type RenderBackend = "WEBGL2" | "WEBGPU";
export type PostProcessPass = "FXAA" | "BLOOM" | "SSAO" | "SSR" | "TAA" | "DOF" | "MOTION_BLUR";
export type RenderCapabilityRequest =
| { kind: "ARBITRARY_SHADER"; nodeTypes: Array<ShaderNodeType | "UNSUPPORTED"> }
| { kind: "VOLUME" }
| { kind: "SUBSURFACE" }
| { kind: "WEBGPU_BACKEND" }
| { kind: "POSTPROCESS"; passes: PostProcessPass[] };
export interface RenderCapabilityContext {
webgpuAvailable?: boolean;
webgpuRendererBundled?: boolean;
supportedShaderNodes?: ReadonlySet<ShaderNodeType>;
supportedPostProcessPasses?: ReadonlySet<PostProcessPass>;
volumeRendererAvailable?: boolean;
subsurfaceRendererAvailable?: boolean;
}
const boundedShaderNodes = new Set<ShaderNodeType>([
"RGB",
"VALUE",
"MATH",
"IMAGE_TEXTURE",
"NORMAL_MAP",
"PRINCIPLED",
"MATERIAL_OUTPUT",
]);
export function gateRenderCapability(
request: RenderCapabilityRequest,
context: RenderCapabilityContext = {},
): CapabilityGateResult {
if (request.kind === "ARBITRARY_SHADER") {
const supported = context.supportedShaderNodes ?? boundedShaderNodes;
const unsupported = [...new Set(request.nodeTypes.filter((type) => type === "UNSUPPORTED" || !supported.has(type as ShaderNodeType)))];
if (unsupported.length === 0) return readyGate("PBR-012", "BOUNDED_SHADER_GRAPH");
return blockedGate("PBR-012", "ARBITRARY_SHADER", [
capabilityIssue("SHADER_NODE_UNSUPPORTED", `Web shader compiler does not support: ${unsupported.join(", ")}`, "nodeTypes"),
]);
}
if (request.kind === "VOLUME") {
return context.volumeRendererAvailable
? readyGate("PBR-012", "VOLUME")
: blockedGate("PBR-012", "VOLUME", [capabilityIssue("VOLUME_SHADER_UNAVAILABLE", "Volume transport is not available in the Web renderer")]);
}
if (request.kind === "SUBSURFACE") {
return context.subsurfaceRendererAvailable
? readyGate("PBR-012", "SUBSURFACE")
: blockedGate("PBR-012", "SUBSURFACE", [capabilityIssue("SUBSURFACE_SHADER_UNAVAILABLE", "Blender-compatible subsurface scattering is not available in the Web renderer")]);
}
if (request.kind === "WEBGPU_BACKEND") {
if (context.webgpuAvailable && context.webgpuRendererBundled) return readyGate("PBR-012", "WEBGPU_BACKEND");
const reason = context.webgpuAvailable ? "The WebGPU renderer bundle is not installed" : "WebGPU is unavailable in this browser or device";
return blockedGate("PBR-012", "WEBGPU_BACKEND", [capabilityIssue("WEBGPU_RENDERER_UNAVAILABLE", reason)]);
}
const supported = context.supportedPostProcessPasses ?? new Set<PostProcessPass>();
const unsupported = [...new Set(request.passes.filter((pass) => !supported.has(pass)))];
return unsupported.length === 0
? readyGate("PBR-012", "POSTPROCESS")
: blockedGate("PBR-012", "POSTPROCESS", [capabilityIssue("POSTPROCESS_PASS_UNAVAILABLE", `Unsupported post-process passes: ${unsupported.join(", ")}`, "passes")]);
}

159
web/protocol/scene-delta.ts Normal file
View File

@@ -0,0 +1,159 @@
import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
export interface SceneCollectionDelta<T extends { id: string }> {
updated: Array<Pick<T, "id"> & Partial<T>>;
added?: T[];
removed?: string[];
}
export interface SceneDelta {
schemaVersion: 1;
baseRevision: number;
nextRevision: number;
nodes?: {
updated: Array<Pick<SceneNodeIR, "id"> & Partial<Pick<SceneNodeIR, "visible" | "parentId" | "localMatrix" | "transform">>>;
added?: SceneNodeIR[];
removed?: string[];
};
meshes?: SceneCollectionDelta<MeshSummaryIR>;
materials?: SceneCollectionDelta<MaterialIR>;
animations?: SceneCollectionDelta<AnimationIR>;
cameras?: SceneCollectionDelta<CameraIR>;
lights?: SceneCollectionDelta<LightIR>;
activeObjectId?: string | null;
frame?: SceneSnapshotIR["frame"];
}
const collectionFields = ["meshes", "materials", "animations", "cameras", "lights"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function parseSceneDelta(value: unknown): SceneDelta {
if (!isRecord(value) || value.schemaVersion !== 1) throw new Error("Unsupported SceneDelta schema");
if (typeof value.baseRevision !== "number" || !Number.isInteger(value.baseRevision) ||
typeof value.nextRevision !== "number" || !Number.isInteger(value.nextRevision)) {
throw new Error("SceneDelta revisions must be integers");
}
if (value.nodes !== undefined) {
if (!isRecord(value.nodes) || !Array.isArray(value.nodes.updated)) throw new Error("SceneDelta.nodes is invalid");
const added = value.nodes.added ?? [];
const removed = value.nodes.removed ?? [];
if (!Array.isArray(added) || !Array.isArray(removed) || removed.some((id) => typeof id !== "string")) {
throw new Error("SceneDelta node additions/removals are invalid");
}
for (const change of value.nodes.updated) {
if (!isRecord(change) || typeof change.id !== "string") throw new Error("SceneDelta updated node is invalid");
}
for (const node of added) {
if (!isRecord(node) || typeof node.id !== "string") throw new Error("SceneDelta added node is invalid");
}
}
for (const field of collectionFields) {
const collection = value[field];
if (collection === undefined) continue;
if (!isRecord(collection) || !Array.isArray(collection.updated) ||
(collection.added !== undefined && !Array.isArray(collection.added)) ||
(collection.removed !== undefined && (!Array.isArray(collection.removed) || collection.removed.some((id) => typeof id !== "string")))) {
throw new Error(`SceneDelta.${field} is invalid`);
}
}
if (value.activeObjectId !== undefined && value.activeObjectId !== null && typeof value.activeObjectId !== "string") {
throw new Error("SceneDelta.activeObjectId is invalid");
}
if (value.frame !== undefined) {
if (!isRecord(value.frame) || typeof value.frame.current !== "number" || !Number.isFinite(value.frame.current) ||
typeof value.frame.start !== "number" || !Number.isFinite(value.frame.start) ||
typeof value.frame.end !== "number" || !Number.isFinite(value.frame.end)) {
throw new Error("SceneDelta.frame is invalid");
}
}
return value as unknown as SceneDelta;
}
function diffCollection<T extends { id: string }>(before: readonly T[], after: readonly T[]): SceneCollectionDelta<T> | undefined {
const oldItems = new Map(before.map((item) => [item.id, item]));
const newItems = new Map(after.map((item) => [item.id, item]));
const updated: Array<Pick<T, "id"> & Partial<T>> = [];
const added: T[] = [];
const removed: string[] = [];
for (const item of after) {
const previous = oldItems.get(item.id);
if (!previous) added.push(item);
else if (JSON.stringify(previous) !== JSON.stringify(item)) updated.push(item);
}
for (const item of before) if (!newItems.has(item.id)) removed.push(item.id);
return updated.length || added.length || removed.length ? { updated, added, removed } : undefined;
}
function changedNodeFields(before: SceneNodeIR, after: SceneNodeIR): { id: string } & Partial<SceneNodeIR> {
const change: { id: string } & Partial<SceneNodeIR> = { id: after.id };
if (before.visible !== after.visible) change.visible = after.visible;
if (before.parentId !== after.parentId) change.parentId = after.parentId;
if (JSON.stringify(before.localMatrix) !== JSON.stringify(after.localMatrix)) change.localMatrix = after.localMatrix;
if (JSON.stringify(before.transform) !== JSON.stringify(after.transform)) change.transform = after.transform;
return change;
}
export function diffSceneSnapshots(before: SceneSnapshotIR, after: SceneSnapshotIR): SceneDelta {
const oldNodes = new Map(before.nodes.map((node) => [node.id, node]));
const newNodes = new Map(after.nodes.map((node) => [node.id, node]));
const updated: Array<{ id: string } & Partial<SceneNodeIR>> = [];
const added: SceneNodeIR[] = [];
const removed: string[] = [];
for (const node of after.nodes) {
const previous = oldNodes.get(node.id);
if (!previous) added.push(node);
else if (JSON.stringify(previous) !== JSON.stringify(node)) updated.push(changedNodeFields(previous, node));
}
for (const node of before.nodes) if (!newNodes.has(node.id)) removed.push(node.id);
const delta: SceneDelta = { schemaVersion: 1, baseRevision: before.revision, nextRevision: after.revision };
if (updated.length || added.length || removed.length) delta.nodes = { updated, added, removed };
delta.meshes = diffCollection(before.meshes, after.meshes);
delta.materials = diffCollection(before.materials, after.materials);
delta.animations = diffCollection(before.animations, after.animations);
delta.cameras = diffCollection(before.cameras, after.cameras);
delta.lights = diffCollection(before.lights, after.lights);
if (before.activeObjectId !== after.activeObjectId) delta.activeObjectId = after.activeObjectId;
if (JSON.stringify(before.frame) !== JSON.stringify(after.frame)) delta.frame = after.frame;
return delta;
}
function applyCollectionDelta<T extends { id: string }>(items: readonly T[], delta: SceneCollectionDelta<T> | undefined): T[] {
if (!delta) return [...items];
const result = new Map(items.map((item) => [item.id, item]));
for (const id of delta.removed ?? []) result.delete(id);
for (const item of delta.added ?? []) result.set(item.id, item);
for (const change of delta.updated) {
const current = result.get(change.id);
if (!current) throw new Error(`SceneDelta item not found: ${change.id}`);
result.set(change.id, { ...current, ...change });
}
return [...result.values()].sort((left, right) => left.id.localeCompare(right.id));
}
export function applySceneDelta(snapshot: SceneSnapshotIR, delta: SceneDelta): SceneSnapshotIR {
parseSceneDelta(delta);
if (snapshot.revision !== delta.baseRevision) throw new Error(`SceneDelta revision conflict: ${snapshot.revision} != ${delta.baseRevision}`);
const updated = new Map(snapshot.nodes.map((node) => [node.id, node]));
for (const id of delta.nodes?.removed ?? []) updated.delete(id);
for (const node of delta.nodes?.added ?? []) updated.set(node.id, node);
for (const change of delta.nodes?.updated ?? []) {
const current = updated.get(change.id);
if (!current) throw new Error(`SceneDelta node not found: ${change.id}`);
updated.set(change.id, { ...current, ...change });
}
return {
...snapshot,
revision: delta.nextRevision,
nodes: [...updated.values()].sort((left, right) => left.id.localeCompare(right.id)),
meshes: applyCollectionDelta(snapshot.meshes, delta.meshes),
materials: applyCollectionDelta(snapshot.materials, delta.materials),
animations: applyCollectionDelta(snapshot.animations, delta.animations),
cameras: applyCollectionDelta(snapshot.cameras, delta.cameras),
lights: applyCollectionDelta(snapshot.lights, delta.lights),
activeObjectId: delta.activeObjectId === undefined ? snapshot.activeObjectId : delta.activeObjectId,
frame: delta.frame ?? snapshot.frame,
};
}

1029
web/protocol/scene-ir.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,86 @@
import { normalizeProjectAssetPath } from "./asset-path";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const SCRIPTING_PLATFORM_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 } 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];
export interface ScriptDependencyIR { id: string; sourceSha256: string; sourcePath: string }
export interface ScriptManifestIR {
id: string;
name: string;
entryPath: string;
sourceSha256: string;
publisher: string;
signature: string;
keyId: string;
permissions: ScriptPermission[];
dependencies: ScriptDependencyIR[];
cpuMs: number;
memoryBytes: number;
wallMs: number;
network: false;
autorun: false;
driverExpressions: false;
addonInstall: false;
}
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
export class ScriptingPlatformValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ScriptingPlatformValidationError"; this.code = code; }
}
const SHA256 = /^[a-f0-9]{64}$/; const HEX_SIGNATURE = /^[a-f0-9]{128}$/;
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`); return value; }
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; }
export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");
const ids = new Set<string>();
const scripts = value.scripts.map((item, index): ScriptManifestIR => {
const name = `scripts[${index}]`; if (!record(item) || !Array.isArray(item.permissions) || !Array.isArray(item.dependencies)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Duplicate script ${id}`); ids.add(id);
if (item.permissions.length > SCRIPTING_BUDGET.maxPermissions || item.permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission as ScriptPermission)) || new Set(item.permissions).size !== item.permissions.length) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.permissions are invalid or exceed the allowlist`);
if (item.dependencies.length > SCRIPTING_BUDGET.maxDependencies) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.dependencies exceed the budget`);
const dependencies = item.dependencies.map((dependency, dependencyIndex): ScriptDependencyIR => { const dependencyName = `${name}.dependencies[${dependencyIndex}]`; if (!record(dependency)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName} is invalid`); return { id: text(dependency.id, `${dependencyName}.id`), sourceSha256: digest(dependency.sourceSha256, `${dependencyName}.sourceSha256`), sourcePath: path(dependency.sourcePath, `${dependencyName}.sourcePath`) }; });
if (item.network !== false || item.autorun !== false || item.driverExpressions !== false || item.addonInstall !== false) throw new ScriptingPlatformValidationError(item.driverExpressions === true ? "DRIVER_EXECUTION_BLOCKED" : item.addonInstall === true ? "ADDON_INSTALL_BLOCKED" : "SCRIPT_POLICY_DENIED", `${name} requests a denied execution policy`);
if (typeof item.signature !== "string" || !HEX_SIGNATURE.test(item.signature)) throw new ScriptingPlatformValidationError("SCRIPT_SIGNATURE_INVALID", `${name}.signature is invalid`);
return { id, name: text(item.name, `${name}.name`), entryPath: path(item.entryPath, `${name}.entryPath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), publisher: text(item.publisher, `${name}.publisher`), signature: item.signature, keyId: text(item.keyId, `${name}.keyId`, 128), permissions: [...item.permissions] as ScriptPermission[], dependencies, cpuMs: integer(item.cpuMs, `${name}.cpuMs`, 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(item.memoryBytes, `${name}.memoryBytes`, 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(item.wallMs, `${name}.wallMs`, 1, SCRIPTING_BUDGET.maxWallMs), network: false, autorun: false, driverExpressions: false, addonInstall: false };
});
const scriptIds = new Set(scripts.map((script) => script.id));
const active = new Set<string>(); const complete = new Set<string>(); const visit = (id: string): void => { if (active.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Script dependency cycle includes ${id}`); if (complete.has(id)) return; const script = scripts.find((item) => item.id === id); if (!script) return; active.add(id); for (const dependency of script.dependencies) { if (!scriptIds.has(dependency.id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${id} references missing script ${dependency.id}`); visit(dependency.id); } active.delete(id); complete.add(id); }; scripts.forEach((script) => visit(script.id));
return { schemaVersion: SCRIPTING_PLATFORM_SCHEMA, scripts };
}
export function gateScriptExecution(manifest: unknown, scriptId: string, approvedKeyIds: ReadonlySet<string>): CapabilityGateResult {
const parsed = parseScriptingManifest(manifest); const script = parsed.scripts.find((item) => item.id === scriptId); if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
if (!approvedKeyIds.has(script.keyId)) return blockedGate("N-025", `SCRIPT_${script.id}`, [capabilityIssue("SCRIPT_SIGNATURE_INVALID", `Script ${script.id} is not signed by an approved key`)]);
return blockedGate("N-025", `SCRIPT_${script.id}`, [capabilityIssue("SCRIPT_SANDBOX_UNAVAILABLE", "Local Python/Native execution requires an isolated sandbox")]);
}
export function gateServerScriptJob(value: unknown, manifest: unknown, inputBlendSha256: string): CapabilityGateResult {
const parsed = parseScriptingManifest(manifest); if (!record(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Server script job is invalid"); const script = parsed.scripts.find((item) => item.id === value.scriptId); if (!script || script.sourceSha256 !== value.sourceSha256 || !SHA256.test(inputBlendSha256)) throw new ScriptingPlatformValidationError("ASSET_SOURCE_HASH_MISMATCH", "Server script job source hash is invalid");
return blockedGate("N-025", `SERVER_SCRIPT_${script.id}`, [capabilityIssue("SERVER_JOB_UNAVAILABLE", "Server Blender job endpoint is not configured")]);
}
export function platformCapabilities(scope: typeof globalThis = globalThis): Record<string, "AVAILABLE" | "PROBE_REQUIRED" | "BLOCKED" | "UNAVAILABLE"> {
return {
worker: typeof scope.Worker === "function" ? "AVAILABLE" : "UNAVAILABLE",
webgpu: "gpu" in scope.navigator ? "PROBE_REQUIRED" : "UNAVAILABLE",
offscreenCanvas: "OffscreenCanvas" in scope ? "AVAILABLE" : "UNAVAILABLE",
opfs: scope.navigator.storage && typeof (scope.navigator.storage as StorageManager & { getDirectory?: unknown }).getDirectory === "function" ? "PROBE_REQUIRED" : "UNAVAILABLE",
nativeWindow: "BLOCKED",
cuda: "BLOCKED",
metal: "BLOCKED",
hip: "BLOCKED",
optix: "BLOCKED",
};
}

124
web/protocol/sculpt.ts Normal file
View File

@@ -0,0 +1,124 @@
import type { ErrorCode } from "./error";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
export const SCULPT_PROTOCOL_SCHEMA = 1 as const;
export type SculptBrush = "DRAW" | "INFLATE" | "GRAB" | "SMOOTH";
export interface SculptStrokeSampleIR {
position: [number, number, number];
normal: [number, number, number];
radius: number;
strength: number;
pressure: number;
time: number;
}
export interface SculptStrokeIR {
schemaVersion: typeof SCULPT_PROTOCOL_SCHEMA;
meshId: string;
brush: SculptBrush;
samples: SculptStrokeSampleIR[];
symmetry: [boolean, boolean, boolean];
mirrorObjectSpace: boolean;
}
export interface SculptMeshAttributesIR {
schemaVersion: typeof SCULPT_PROTOCOL_SCHEMA;
meshId: string;
vertexCount: number;
faceCount: number;
mask: number[];
faceSets: number[];
activeFaceSet: number;
revision: number;
}
export interface SculptCapabilityContext {
meshId: string;
singleUser: boolean;
linkedLibrary: boolean;
hasShapeKeys: boolean;
hasTopologyChangingModifier: boolean;
vertexCount: number;
faceCount: number;
}
export class SculptValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "SculptValidationError";
this.code = code;
this.path = path;
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function tuple(value: unknown, length: number, path: string): asserts value is number[] {
if (!Array.isArray(value) || value.length !== length || value.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", `${path} must contain ${length} finite numbers`, path);
}
}
function booleanTuple(value: unknown, path: string): asserts value is [boolean, boolean, boolean] {
if (!Array.isArray(value) || value.length !== 3 || value.some((item) => typeof item !== "boolean")) {
throw new SculptValidationError("TASK_VALIDATION_FAILED", `${path} must contain three booleans`, path);
}
}
export function parseSculptStroke(value: unknown, maxSamples = 2048, maxPathLength = 10000): SculptStrokeIR {
if (!record(value) || value.schemaVersion !== SCULPT_PROTOCOL_SCHEMA) {
throw new SculptValidationError("PROTOCOL_MISMATCH", "Unsupported SculptStroke schema");
}
if (typeof value.meshId !== "string" || value.meshId.length === 0) throw new SculptValidationError("INVALID_ARGUMENT", "meshId is required", "meshId");
if (!["DRAW", "INFLATE", "GRAB", "SMOOTH"].includes(value.brush as string)) throw new SculptValidationError("CAPABILITY_MISSING", "Sculpt brush is not in the deterministic subset", "brush");
if (!Array.isArray(value.samples) || value.samples.length === 0 || value.samples.length > maxSamples) {
throw new SculptValidationError("SCULPT_STROKE_BUDGET_EXCEEDED", `samples must contain 1..${maxSamples} points`, "samples");
}
let pathLength = 0;
let previousPosition: number[] | undefined;
for (const [index, sample] of value.samples.entries()) {
if (!record(sample)) throw new SculptValidationError("TASK_VALIDATION_FAILED", `samples[${index}] must be an object`, `samples[${index}]`);
tuple(sample.position, 3, `samples[${index}].position`);
tuple(sample.normal, 3, `samples[${index}].normal`);
for (const field of ["radius", "strength", "pressure", "time"] as const) {
if (typeof sample[field] !== "number" || !Number.isFinite(sample[field])) throw new SculptValidationError("TASK_VALIDATION_FAILED", `samples[${index}].${field} must be finite`, `samples[${index}].${field}`);
}
const radius = sample.radius as number;
const pressure = sample.pressure as number;
const strength = sample.strength as number;
if (radius <= 0 || pressure < 0 || pressure > 1 || Math.abs(strength) > 1) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", `samples[${index}] is outside brush bounds`, `samples[${index}]`);
const position = sample.position as number[];
if (previousPosition) pathLength += Math.hypot(position[0] - previousPosition[0], position[1] - previousPosition[1], position[2] - previousPosition[2]);
if (pathLength > maxPathLength) throw new SculptValidationError("SCULPT_STROKE_BUDGET_EXCEEDED", `stroke path exceeds the ${maxPathLength} unit budget`, "samples");
previousPosition = position;
}
booleanTuple(value.symmetry, "symmetry");
if (typeof value.mirrorObjectSpace !== "boolean") throw new SculptValidationError("TASK_VALIDATION_FAILED", "mirrorObjectSpace must be boolean", "mirrorObjectSpace");
return value as unknown as SculptStrokeIR;
}
export function parseSculptMeshAttributes(value: unknown): SculptMeshAttributesIR {
if (!record(value) || value.schemaVersion !== SCULPT_PROTOCOL_SCHEMA) throw new SculptValidationError("PROTOCOL_MISMATCH", "Unsupported SculptMeshAttributes schema");
for (const field of ["meshId"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new SculptValidationError("INVALID_ARGUMENT", `${field} is required`, field);
for (const field of ["vertexCount", "faceCount", "revision"] as const) if (typeof value[field] !== "number" || !Number.isSafeInteger(value[field]) || value[field] < 0) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", `${field} must be a non-negative integer`, field);
if (!Array.isArray(value.mask) || value.mask.length !== value.vertexCount || value.mask.some((item) => typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 1)) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", "mask must match vertexCount and stay in [0,1]", "mask");
if (!Array.isArray(value.faceSets) || value.faceSets.length !== value.faceCount || value.faceSets.some((item) => typeof item !== "number" || !Number.isSafeInteger(item) || item < 0)) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", "faceSets must match faceCount", "faceSets");
if (typeof value.activeFaceSet !== "number" || !Number.isSafeInteger(value.activeFaceSet) || value.activeFaceSet < -1) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", "activeFaceSet is invalid", "activeFaceSet");
return value as unknown as SculptMeshAttributesIR;
}
export function gateSculptCapability(context: SculptCapabilityContext): CapabilityGateResult {
const issues = [];
if (!context.singleUser) issues.push(capabilityIssue("SCULPT_MESH_NOT_SINGLE_USER", "Sculpt requires a single-user Mesh", "singleUser"));
if (context.linkedLibrary) issues.push(capabilityIssue("LINKED_DATA_MUTATION_BLOCKED", "Linked-library Mesh is read-only", "linkedLibrary"));
if (context.hasShapeKeys) issues.push(capabilityIssue("SCULPT_TOPOLOGY_UNSUPPORTED", "Shape-key Mesh is outside the initial Sculpt subset", "hasShapeKeys"));
if (context.hasTopologyChangingModifier) issues.push(capabilityIssue("SCULPT_TOPOLOGY_UNSUPPORTED", "Topology-changing modifier must be applied or disabled before Sculpt", "hasTopologyChangingModifier"));
if (!Number.isSafeInteger(context.vertexCount) || context.vertexCount <= 0 || !Number.isSafeInteger(context.faceCount) || context.faceCount <= 0) issues.push(capabilityIssue("SCULPT_ATTRIBUTE_INVALID", "Sculpt requires a non-empty Mesh", "meshId"));
return issues.length > 0 ? blockedGate("N-011", "SCULPT_STROKE", issues) : readyGate("N-011", "SCULPT_STROKE");
}

View File

@@ -0,0 +1,63 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const SELECTION_HISTORY_SCHEMA = 1 as const;
export const SELECTION_HISTORY_BUDGET = { maxEntries: 256, maxObjects: 100_000, maxElements: 1_000_000 } as const;
export type SelectionElementMode = "VERT" | "EDGE" | "FACE";
export type NonMeshSelectionKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
export interface SelectionStateIR { activeObjectId: string | null; objectIds: string[]; meshId: string | null; elementMode: SelectionElementMode; elementIndices: number[]; nonMeshKind?: NonMeshSelectionKind }
export interface SelectionHistoryIR { schemaVersion: typeof SELECTION_HISTORY_SCHEMA; revision: number; cursor: number; entries: SelectionStateIR[] }
export interface RaycastSelectionHitIR { sourceRevision: number; dataId: string; mode: SelectionElementMode; index: number; distance: number; point: [number, number, number]; nonMeshKind?: NonMeshSelectionKind }
export class SelectionHistoryValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "SelectionHistoryValidationError"; this.code = code; }
}
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is outside the bounded range`); return value; }
function ids(value: unknown, name: string, maximum: number): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256) || new Set(value).size !== value.length) throw new SelectionHistoryValidationError(value instanceof Array && value.length > maximum ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", `${name} is invalid`); return [...value] as string[]; }
export function parseSelectionState(value: unknown): SelectionStateIR {
if (!record(value) || !["VERT", "EDGE", "FACE"].includes(value.elementMode as string)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection state is invalid");
const objectIds = ids(value.objectIds, "objectIds", SELECTION_HISTORY_BUDGET.maxObjects);
const activeObjectId = value.activeObjectId === null ? null : typeof value.activeObjectId === "string" ? value.activeObjectId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "activeObjectId is invalid"); })();
if (activeObjectId !== null && !objectIds.includes(activeObjectId)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected");
if (!Array.isArray(value.elementIndices) || value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements || value.elementIndices.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value.elementIndices).size !== value.elementIndices.length) throw new SelectionHistoryValidationError(value.elementIndices instanceof Array && value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", "elementIndices are invalid");
const meshId = value.meshId === null ? null : typeof value.meshId === "string" && value.meshId.length > 0 ? value.meshId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "meshId is invalid"); })();
if (meshId === null && value.elementIndices.length > 0) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires meshId");
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "nonMeshKind is invalid"); })();
return { activeObjectId, objectIds, meshId, elementMode: value.elementMode as SelectionElementMode, elementIndices: [...value.elementIndices].sort((a, b) => a - b) as number[], ...(nonMeshKind ? { nonMeshKind } : {}) };
}
export function parseSelectionHistory(value: unknown): SelectionHistoryIR {
if (!record(value) || value.schemaVersion !== SELECTION_HISTORY_SCHEMA || !Array.isArray(value.entries)) throw new SelectionHistoryValidationError("PROTOCOL_MISMATCH", "Unsupported selection history schema");
if (value.entries.length === 0 || value.entries.length > SELECTION_HISTORY_BUDGET.maxEntries) throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection history entry count exceeds the budget");
return { schemaVersion: SELECTION_HISTORY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), cursor: integer(value.cursor, "cursor", 0, value.entries.length - 1), entries: value.entries.map(parseSelectionState) };
}
function equalState(a: SelectionStateIR, b: SelectionStateIR): boolean { return a.activeObjectId === b.activeObjectId && a.meshId === b.meshId && a.elementMode === b.elementMode && a.nonMeshKind === b.nonMeshKind && a.objectIds.join("\0") === b.objectIds.join("\0") && a.elementIndices.join(",") === b.elementIndices.join(","); }
export function recordSelection(value: unknown, revision: number, stateValue: unknown): SelectionHistoryIR {
const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const state = parseSelectionState(stateValue);
if (equalState(history.entries[history.cursor], state)) return history;
const entries = history.entries.slice(0, history.cursor + 1); entries.push(state); if (entries.length > SELECTION_HISTORY_BUDGET.maxEntries) entries.shift();
return parseSelectionHistory({ schemaVersion: SELECTION_HISTORY_SCHEMA, revision: history.revision + 1, cursor: entries.length - 1, entries });
}
export function stepSelectionHistory(value: unknown, revision: number, direction: "UNDO" | "REDO"): SelectionHistoryIR {
const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const cursor = history.cursor + (direction === "UNDO" ? -1 : 1);
if (cursor < 0 || cursor >= history.entries.length) throw new SelectionHistoryValidationError("SELECTION_UNDO_UNAVAILABLE", `${direction} has no selection entry`);
return { ...history, revision: history.revision + 1, cursor };
}
export function parseRaycastSelectionHit(value: unknown, expectedRevision: number): RaycastSelectionHitIR {
if (!record(value) || value.sourceRevision !== expectedRevision || typeof value.dataId !== "string" || !value.dataId || !["VERT", "EDGE", "FACE"].includes(value.mode as string) || !Number.isSafeInteger(value.index) || (value.index as number) < 0 || typeof value.distance !== "number" || !Number.isFinite(value.distance) || value.distance < 0 || !Array.isArray(value.point) || value.point.length !== 3 || value.point.some((item) => typeof item !== "number" || !Number.isFinite(item))) throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast hit is stale or invalid");
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast non-mesh identity is invalid"); })();
return { sourceRevision: value.sourceRevision as number, dataId: value.dataId, mode: value.mode as SelectionElementMode, index: value.index as number, distance: value.distance, point: value.point as [number, number, number], ...(nonMeshKind ? { nonMeshKind } : {}) };
}
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/non-mesh gizmo interaction is not implemented")]);
}

251
web/protocol/sequencer.ts Normal file
View File

@@ -0,0 +1,251 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
import { normalizeProjectAssetPath } from "./asset-path";
export const SEQUENCER_SCHEMA = 1 as const;
export const SEQUENCER_BUDGET = {
maxStrips: 100_000,
maxChannels: 128,
maxFrames: 1_000_000,
maxDependencies: 64,
maxImageElements: 100_000,
} as const;
export type SequencerStripType = "SCENE" | "MOVIE" | "IMAGE" | "SOUND" | "EFFECT" | "META";
export interface SequencerProxyIR {
status: "MISSING" | "AVAILABLE" | "STALE";
assetId?: string;
width?: number;
height?: number;
quality?: number;
sha256?: string;
}
export interface SequencerStripIR {
id: string;
name: string;
type: SequencerStripType;
channel: number;
frameStart: number;
frameEnd: number;
sourceStart: number;
sourceEnd: number;
speed: number;
muted: boolean;
locked: boolean;
sourceId?: string;
sourcePath?: string;
mimeType?: string;
imageAssetIds?: string[];
effectType?: "CROSS" | "GAMMA_CROSS" | "ADD" | "MULTIPLY" | "TRANSFORM" | "COLOR";
inputStripIds?: string[];
childStripIds?: string[];
proxy?: SequencerProxyIR;
}
export interface SequencerTimelineIR {
schemaVersion: typeof SEQUENCER_SCHEMA;
id: string;
revision: number;
frameStart: number;
frameEnd: number;
fpsNumerator: number;
fpsDenominator: number;
strips: SequencerStripIR[];
}
export type SequencerEditIR =
| { type: "MOVE"; revision: number; stripId: string; frameDelta: number; channel?: number }
| { type: "TRIM"; revision: number; stripId: string; frameStart?: number; frameEnd?: number }
| { type: "SPLIT"; revision: number; stripId: string; frame: number; rightStripId: string };
export interface SequencerRuntimeCapabilityIR {
webCodecsVideo: "PROBE_REQUIRED" | "UNAVAILABLE";
webCodecsAudio: "PROBE_REQUIRED" | "UNAVAILABLE";
htmlMedia: "PROBE_REQUIRED" | "UNAVAILABLE";
localEncoding: "BLOCKED";
}
export class SequencerValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "SequencerValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const STRIP_TYPES = new Set<SequencerStripType>(["SCENE", "MOVIE", "IMAGE", "SOUND", "EFFECT", "META"]);
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string, maximum = 256): string {
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${name} is invalid`);
return value;
}
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${name} is outside the bounded range`);
return value;
}
function parseProxy(value: unknown, index: number): SequencerProxyIR | undefined {
if (value === undefined) return undefined;
if (!record(value) || !["MISSING", "AVAILABLE", "STALE"].includes(value.status as string)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].proxy is invalid`);
const proxy: SequencerProxyIR = { status: value.status as SequencerProxyIR["status"] };
if (value.assetId !== undefined) proxy.assetId = text(value.assetId, `strips[${index}].proxy.assetId`);
if (value.width !== undefined) proxy.width = integer(value.width, `strips[${index}].proxy.width`, 1, 16_384);
if (value.height !== undefined) proxy.height = integer(value.height, `strips[${index}].proxy.height`, 1, 16_384);
if (value.quality !== undefined) proxy.quality = integer(value.quality, `strips[${index}].proxy.quality`, 0, 100);
if (value.sha256 !== undefined) {
if (typeof value.sha256 !== "string" || !SHA256.test(value.sha256)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].proxy.sha256 is invalid`);
proxy.sha256 = value.sha256;
}
if (proxy.status === "AVAILABLE" && (!proxy.assetId || !proxy.sha256)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] available proxy requires assetId and sha256`);
return proxy;
}
export function parseSequencerTimeline(value: unknown): SequencerTimelineIR {
if (!record(value) || value.schemaVersion !== SEQUENCER_SCHEMA || !Array.isArray(value.strips)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer timeline schema");
if (value.strips.length > SEQUENCER_BUDGET.maxStrips) throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer strip count exceeds the budget");
const frameStart = integer(value.frameStart, "frameStart", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const frameEnd = integer(value.frameEnd, "frameEnd", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
if (frameEnd < frameStart) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Timeline frame range is invalid");
const ids = new Set<string>();
const strips = value.strips.map((item, index): SequencerStripIR => {
if (!record(item) || !STRIP_TYPES.has(item.type as SequencerStripType)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] is invalid`);
const id = text(item.id, `strips[${index}].id`);
if (ids.has(id)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Duplicate strip ${id}`);
ids.add(id);
const start = integer(item.frameStart, `strips[${index}].frameStart`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const end = integer(item.frameEnd, `strips[${index}].frameEnd`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const sourceStart = integer(item.sourceStart, `strips[${index}].sourceStart`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const sourceEnd = integer(item.sourceEnd, `strips[${index}].sourceEnd`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
if (end <= start || sourceEnd < sourceStart || end - start > SEQUENCER_BUDGET.maxFrames) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] frame range is invalid`);
const speed = typeof item.speed === "number" && Number.isFinite(item.speed) && item.speed > 0 && item.speed <= 1_000 ? item.speed : NaN;
if (!Number.isFinite(speed)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].speed is invalid`);
const strip: SequencerStripIR = {
id,
name: text(item.name, `strips[${index}].name`),
type: item.type as SequencerStripType,
channel: integer(item.channel, `strips[${index}].channel`, 1, SEQUENCER_BUDGET.maxChannels),
frameStart: start,
frameEnd: end,
sourceStart,
sourceEnd,
speed,
muted: typeof item.muted === "boolean" ? item.muted : false,
locked: typeof item.locked === "boolean" ? item.locked : false,
proxy: parseProxy(item.proxy, index),
};
if (item.sourceId !== undefined) strip.sourceId = text(item.sourceId, `strips[${index}].sourceId`);
if (item.sourcePath !== undefined) {
try { strip.sourcePath = normalizeProjectAssetPath(text(item.sourcePath, `strips[${index}].sourcePath`, 2048)); }
catch { throw new SequencerValidationError("SEQUENCER_RESOURCE_OUTSIDE_PROJECT", `strips[${index}].sourcePath is outside the project`); }
}
if (item.mimeType !== undefined) strip.mimeType = text(item.mimeType, `strips[${index}].mimeType`, 128);
if (item.imageAssetIds !== undefined) {
if (!Array.isArray(item.imageAssetIds) || item.imageAssetIds.length > SEQUENCER_BUDGET.maxImageElements || item.imageAssetIds.some((asset) => typeof asset !== "string" || asset.length === 0)) throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", `strips[${index}].imageAssetIds exceeds the budget`);
strip.imageAssetIds = [...item.imageAssetIds] as string[];
}
for (const field of ["inputStripIds", "childStripIds"] as const) if (item[field] !== undefined) {
if (!Array.isArray(item[field]) || item[field].length > SEQUENCER_BUDGET.maxDependencies || item[field].some((dependency) => typeof dependency !== "string" || dependency.length === 0)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].${field} is invalid`);
strip[field] = [...item[field]] as string[];
}
if (strip.type === "EFFECT") {
if (!["CROSS", "GAMMA_CROSS", "ADD", "MULTIPLY", "TRANSFORM", "COLOR"].includes(item.effectType as string) || !strip.inputStripIds?.length) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] effect dependencies are invalid`);
strip.effectType = item.effectType as SequencerStripIR["effectType"];
}
if (strip.type === "META" && !strip.childStripIds) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] META requires childStripIds`);
if (["MOVIE", "IMAGE", "SOUND"].includes(strip.type) && !strip.sourceId && !strip.sourcePath && !strip.imageAssetIds?.length) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `strips[${index}] has no source resource`);
return strip;
});
const byId = new Map(strips.map((strip) => [strip.id, strip]));
const active = new Set<string>();
const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new SequencerValidationError("SEQUENCER_DEPENDENCY_CYCLE", `Sequencer dependency cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
const strip = byId.get(id);
for (const dependency of [...(strip?.inputStripIds ?? []), ...(strip?.childStripIds ?? [])]) {
if (!byId.has(dependency)) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${id} references missing strip ${dependency}`);
visit(dependency);
}
active.delete(id);
complete.add(id);
};
strips.forEach((strip) => visit(strip.id));
return {
schemaVersion: SEQUENCER_SCHEMA,
id: text(value.id, "id"),
revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER),
frameStart,
frameEnd,
fpsNumerator: integer(value.fpsNumerator, "fpsNumerator", 1, 1_000_000),
fpsDenominator: integer(value.fpsDenominator, "fpsDenominator", 1, 1_000_000),
strips,
};
}
export function applySequencerEdit(value: unknown, edit: SequencerEditIR): SequencerTimelineIR {
const timeline = parseSequencerTimeline(value);
if (edit.revision !== timeline.revision) throw new SequencerValidationError("REVISION_CONFLICT", "Sequencer edit revision is stale");
const strips = timeline.strips.map((strip) => ({ ...strip, inputStripIds: strip.inputStripIds && [...strip.inputStripIds], childStripIds: strip.childStripIds && [...strip.childStripIds] }));
const strip = strips.find((candidate) => candidate.id === edit.stripId);
if (!strip) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Unknown strip ${edit.stripId}`);
if (strip.locked) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${edit.stripId} is locked`);
if (edit.type === "MOVE") {
integer(edit.frameDelta, "frameDelta", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
strip.frameStart += edit.frameDelta;
strip.frameEnd += edit.frameDelta;
if (edit.channel !== undefined) strip.channel = integer(edit.channel, "channel", 1, SEQUENCER_BUDGET.maxChannels);
}
else if (edit.type === "TRIM") {
if (edit.frameStart === undefined && edit.frameEnd === undefined) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "TRIM requires a boundary");
const nextStart = edit.frameStart === undefined ? strip.frameStart : integer(edit.frameStart, "frameStart", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const nextEnd = edit.frameEnd === undefined ? strip.frameEnd : integer(edit.frameEnd, "frameEnd", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
if (nextStart >= nextEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "TRIM would create an empty strip");
const originalFrameStart = strip.frameStart;
const originalSourceStart = strip.sourceStart;
strip.sourceStart = originalSourceStart + Math.round((nextStart - originalFrameStart) * strip.speed);
strip.sourceEnd = Math.min(strip.sourceEnd, originalSourceStart + Math.round((nextEnd - originalFrameStart) * strip.speed));
strip.frameStart = nextStart;
strip.frameEnd = nextEnd;
}
else {
const splitFrame = integer(edit.frame, "frame", strip.frameStart + 1, strip.frameEnd - 1);
if (strips.some((candidate) => candidate.id === edit.rightStripId)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Duplicate split strip ${edit.rightStripId}`);
const originalEnd = strip.frameEnd;
const originalSourceEnd = strip.sourceEnd;
const rightSourceStart = strip.sourceStart + Math.round((splitFrame - strip.frameStart) * strip.speed);
strip.frameEnd = splitFrame;
strip.sourceEnd = rightSourceStart;
strips.push({ ...strip, id: text(edit.rightStripId, "rightStripId"), name: `${strip.name} Right`, frameStart: splitFrame, frameEnd: originalEnd, sourceStart: rightSourceStart, sourceEnd: originalSourceEnd });
}
return parseSequencerTimeline({ ...timeline, revision: timeline.revision + 1, strips });
}
export function sequencerSourceFrame(strip: SequencerStripIR, timelineFrame: number): number {
if (!Number.isFinite(timelineFrame) || timelineFrame < strip.frameStart || timelineFrame >= strip.frameEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${strip.id} is inactive at frame ${timelineFrame}`);
return Math.min(strip.sourceEnd, Math.max(strip.sourceStart, strip.sourceStart + (timelineFrame - strip.frameStart) * strip.speed));
}
export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalThis): SequencerRuntimeCapabilityIR {
return {
webCodecsVideo: "VideoDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
webCodecsAudio: "AudioDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
htmlMedia: "HTMLMediaElement" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
localEncoding: "BLOCKED",
};
}
export function gateSequencerCodec(mimeType: string, verifiedMimeTypes: ReadonlySet<string>): CapabilityGateResult {
if (verifiedMimeTypes.has(mimeType)) return readyGate("N-021", `CODEC_${mimeType}`);
return blockedGate("N-021", `CODEC_${mimeType}`, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", `Codec ${mimeType} has not passed an exact seek/decode probe`)]);
}

View File

@@ -0,0 +1,178 @@
import type { ErrorCode } from "./error";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
export const SHADER_GRAPH_SCHEMA = 1 as const;
export type ShaderSocketType = "VALUE" | "VECTOR" | "COLOR" | "BOOLEAN" | "SHADER" | "CLOSURE";
export type ShaderNodeType = "RGB" | "VALUE" | "MIX" | "MATH" | "MAPPING" | "TEX_COORD" | "IMAGE_TEXTURE" | "NORMAL_MAP" | "BUMP" | "PRINCIPLED" | "MATERIAL_OUTPUT";
export type ShaderSocketValue = boolean | number | string | number[];
export interface ShaderSocketIR {
id: string;
name: string;
direction: "INPUT" | "OUTPUT";
dataType: ShaderSocketType;
defaultValue?: ShaderSocketValue;
}
export interface ShaderNodeIR {
id: string;
type: ShaderNodeType | "UNSUPPORTED";
name: string;
sockets: ShaderSocketIR[];
imageId?: string | null;
properties?: Record<string, ShaderSocketValue>;
}
export interface ShaderLinkIR {
fromNodeId: string;
fromSocketId: string;
toNodeId: string;
toSocketId: string;
}
export interface ShaderGraphIR {
schemaVersion: typeof SHADER_GRAPH_SCHEMA;
id: string;
materialId: string;
nodes: ShaderNodeIR[];
links: ShaderLinkIR[];
outputNodeId?: string;
graphHash?: string;
}
export interface ShaderGraphValidationContext {
imageIds: ReadonlySet<string>;
blockedImageIds?: ReadonlySet<string>;
materialIds?: ReadonlySet<string>;
}
export interface ShaderGraphValidation {
status: "SUPPORTED" | "BLOCKED";
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
cycles: string[][];
}
export class ShaderGraphError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "ShaderGraphError";
this.code = code;
this.path = path;
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function socket(value: unknown, path: string): value is ShaderSocketIR {
if (!record(value) || typeof value.id !== "string" || typeof value.name !== "string" || !["INPUT", "OUTPUT"].includes(value.direction as string) || !["VALUE", "VECTOR", "COLOR", "BOOLEAN", "SHADER", "CLOSURE"].includes(value.dataType as string)) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `${path} is invalid`, path);
if (value.defaultValue !== undefined && !(typeof value.defaultValue === "boolean" || typeof value.defaultValue === "number" || typeof value.defaultValue === "string" || (Array.isArray(value.defaultValue) && value.defaultValue.every((item) => typeof item === "number" && Number.isFinite(item))))) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `${path}.defaultValue is invalid`, `${path}.defaultValue`);
return true;
}
const supportedTypes = new Set<ShaderNodeType>(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "NORMAL_MAP", "PRINCIPLED", "MATERIAL_OUTPUT"]);
const supportedMathOperations = new Set(["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"]);
export function parseShaderGraph(value: unknown): ShaderGraphIR {
if (!record(value) || value.schemaVersion !== SHADER_GRAPH_SCHEMA) throw new ShaderGraphError("PROTOCOL_MISMATCH", "Unsupported ShaderGraph schema");
if (typeof value.id !== "string" || typeof value.materialId !== "string" || value.id.length === 0 || value.materialId.length === 0 || !Array.isArray(value.nodes) || !Array.isArray(value.links)) throw new ShaderGraphError("SHADER_INVALID_GRAPH", "ShaderGraph metadata or arrays are invalid");
value.nodes.forEach((node, index) => {
if (!record(node) || typeof node.id !== "string" || typeof node.name !== "string" || typeof node.type !== "string" || !Array.isArray(node.sockets)) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`);
node.sockets.forEach((item, socketIndex) => socket(item, `nodes[${index}].sockets[${socketIndex}]`));
if (node.imageId !== undefined && node.imageId !== null && typeof node.imageId !== "string") throw new ShaderGraphError("SHADER_INVALID_GRAPH", `nodes[${index}].imageId is invalid`, `nodes[${index}].imageId`);
if (node.properties !== undefined && (!record(node.properties) || Object.values(node.properties).some((item) => !(typeof item === "boolean" || typeof item === "number" || typeof item === "string" || (Array.isArray(item) && item.every((entry) => typeof entry === "number" && Number.isFinite(entry))))))) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`);
});
value.links.forEach((link, index) => {
if (!record(link) || typeof link.fromNodeId !== "string" || typeof link.fromSocketId !== "string" || typeof link.toNodeId !== "string" || typeof link.toSocketId !== "string") throw new ShaderGraphError("SHADER_INVALID_GRAPH", `links[${index}] is invalid`, `links[${index}]`);
});
if (value.outputNodeId !== undefined && typeof value.outputNodeId !== "string") throw new ShaderGraphError("SHADER_INVALID_GRAPH", "outputNodeId is invalid", "outputNodeId");
return value as unknown as ShaderGraphIR;
}
function compatible(from: ShaderSocketIR, to: ShaderSocketIR): boolean {
if (from.direction !== "OUTPUT" || to.direction !== "INPUT") return false;
if (from.dataType === to.dataType) return true;
return (from.dataType === "COLOR" && to.dataType === "VECTOR") || (from.dataType === "VECTOR" && to.dataType === "COLOR") || (from.dataType === "VALUE" && to.dataType === "VECTOR");
}
export function validateShaderGraph(graph: ShaderGraphIR, context: ShaderGraphValidationContext): ShaderGraphValidation {
const issues: ShaderGraphValidation["issues"] = [];
const nodes = new Map<string, ShaderNodeIR>();
const sockets = new Map<string, ShaderSocketIR>();
for (const [index, node] of graph.nodes.entries()) {
if (nodes.has(node.id)) issues.push({ code: "SHADER_INVALID_GRAPH", message: `duplicate node ID: ${node.id}`, path: `nodes.${index}` });
nodes.set(node.id, node);
if (node.type === "UNSUPPORTED" || !supportedTypes.has(node.type)) issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: `unsupported shader node: ${node.type}`, path: `nodes.${index}.type` });
const propertyKeys = Object.keys(node.properties ?? {});
if (node.type === "MATH") {
const operation = node.properties?.operation;
if (propertyKeys.length !== 1 || typeof operation !== "string" || !supportedMathOperations.has(operation)) {
issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: "Math requires one supported operation property", path: `nodes.${index}.properties.operation` });
}
}
else if (propertyKeys.length > 0) issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: `shader node properties are not writable for ${node.type}`, path: `nodes.${index}.properties` });
if (node.type === "IMAGE_TEXTURE" && (!node.imageId || !context.imageIds.has(node.imageId))) issues.push({ code: "SHADER_EXTERNAL_RESOURCE_MISSING", message: `shader node references a missing image: ${node.imageId ?? "none"}`, path: `nodes.${index}.imageId` });
else if (node.type === "IMAGE_TEXTURE" && node.imageId && context.blockedImageIds?.has(node.imageId)) issues.push({ code: "SHADER_EXTERNAL_RESOURCE_MISSING", message: `shader image is blocked by the library/path sandbox: ${node.imageId}`, path: `nodes.${index}.imageId` });
for (const item of node.sockets) {
if (sockets.has(`${node.id}:${item.id}`)) issues.push({ code: "SHADER_INVALID_GRAPH", message: `duplicate socket ID: ${node.id}:${item.id}`, path: `nodes.${index}.sockets` });
sockets.set(`${node.id}:${item.id}`, item);
}
}
if (context.materialIds && !context.materialIds.has(graph.materialId)) {
issues.push({ code: "SHADER_EXTERNAL_RESOURCE_MISSING", message: `shader graph references a missing material: ${graph.materialId}`, path: "materialId" });
}
const edges = new Map<string, string[]>();
const linkedInputs = new Set<string>();
for (const [index, link] of graph.links.entries()) {
const from = sockets.get(`${link.fromNodeId}:${link.fromSocketId}`);
const to = sockets.get(`${link.toNodeId}:${link.toSocketId}`);
if (!from || !to) {
issues.push({ code: "SHADER_INVALID_GRAPH", message: "link references an unknown socket", path: `links.${index}` });
continue;
}
if (!compatible(from, to)) issues.push({ code: "SHADER_SOCKET_TYPE_MISMATCH", message: `incompatible shader link ${link.fromNodeId}:${link.fromSocketId} -> ${link.toNodeId}:${link.toSocketId}`, path: `links.${index}` });
const inputKey = `${link.toNodeId}:${link.toSocketId}`;
if (linkedInputs.has(inputKey)) issues.push({ code: "SHADER_INVALID_GRAPH", message: `shader input has more than one link: ${inputKey}`, path: `links.${index}` });
linkedInputs.add(inputKey);
edges.set(link.fromNodeId, [...(edges.get(link.fromNodeId) ?? []), link.toNodeId]);
}
const cycles: string[][] = [];
const state = new Map<string, 0 | 1 | 2>();
const path: string[] = [];
const visit = (id: string): void => {
const current = state.get(id) ?? 0;
if (current === 2) return;
if (current === 1) {
const start = path.indexOf(id);
cycles.push(start < 0 ? [id] : [...path.slice(start), id]);
return;
}
state.set(id, 1);
path.push(id);
for (const next of edges.get(id) ?? []) visit(next);
path.pop();
state.set(id, 2);
};
for (const node of graph.nodes) visit(node.id);
if (cycles.length > 0) issues.push({ code: "SHADER_GRAPH_CYCLE", message: "Shader graph contains a cycle", path: "links" });
const outputNodes = graph.nodes.filter((node) => node.type === "MATERIAL_OUTPUT");
if (outputNodes.length !== 1 || (graph.outputNodeId !== undefined && graph.outputNodeId !== outputNodes[0]?.id)) issues.push({ code: "SHADER_INVALID_GRAPH", message: "Shader graph must have exactly one Material Output", path: "outputNodeId" });
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues, cycles };
}
export function gateShaderGraph(value: unknown, context: ShaderGraphValidationContext = { imageIds: new Set<string>() }): CapabilityGateResult {
try {
const graph = parseShaderGraph(value);
const result = validateShaderGraph(graph, context);
if (result.status === "SUPPORTED") return readyGate("N-013", "SHADER_NODE_GRAPH");
return blockedGate("N-013", "SHADER_NODE_GRAPH", result.issues.map((issue) => capabilityIssue(issue.code, issue.message, issue.path)));
}
catch (error) {
const issue = error as ShaderGraphError;
return blockedGate("N-013", "SHADER_NODE_GRAPH", [capabilityIssue(issue.code ?? "SHADER_INVALID_GRAPH", issue.message, issue.path)]);
}
}

310
web/protocol/simplify.ts Normal file
View File

@@ -0,0 +1,310 @@
export type SimplifyMode = "COLLAPSE" | "UNSUBDIV" | "DISSOLVE_PLANAR";
export type SimplifyDelimit = "NORMAL" | "MATERIAL" | "SEAM" | "SHARP" | "UV" | "ALL_BOUNDARIES";
export type SimplifyAttributePolicy = "PRESERVE" | "RECOMPUTE_NORMALS" | "DROP";
export interface SkinSimplifyPolicy {
maxInfluences: number;
minWeight: number;
maxPositionError: number;
shapeKeys: "PRESERVE" | "REJECT";
}
export type SimplifyErrorCode =
| "INVALID_SCHEMA"
| "INVALID_PARAMETER"
| "MISSING_VERTEX_GROUP"
| "UNSUPPORTED_PARAMETER"
| "INVALID_LOD_MANIFEST";
export class SimplifyValidationError extends Error {
readonly code: SimplifyErrorCode;
constructor(code: SimplifyErrorCode, message: string) {
super(message);
this.name = "SimplifyValidationError";
this.code = code;
}
}
interface SimplifyCommon {
schemaVersion: 1;
sourceMeshRevision: number;
triangleBudget?: number;
maxGeometricError?: number;
screenSpaceError?: number;
attributePolicy: SimplifyAttributePolicy;
skinPolicy?: SkinSimplifyPolicy;
}
export type CollapseSimplifyProfile = SimplifyCommon & {
mode: "COLLAPSE";
ratio: number;
vertexGroup?: string | null;
vertexGroupFactor?: number;
vertexGroupInvert?: boolean;
triangulate: boolean;
useSymmetry: boolean;
symmetryAxis?: 0 | 1 | 2;
symmetryTolerance?: number;
};
export type UnsubdivideSimplifyProfile = SimplifyCommon & {
mode: "UNSUBDIV";
iterations: number;
};
export type DissolvePlanarSimplifyProfile = SimplifyCommon & {
mode: "DISSOLVE_PLANAR";
angleLimit: number;
useDissolveBoundaries: boolean;
delimit: SimplifyDelimit[];
};
export type SimplifyProfile = CollapseSimplifyProfile | UnsubdivideSimplifyProfile | DissolvePlanarSimplifyProfile;
export interface SimplifyResult {
schemaVersion: 1;
status: "applied" | "rejected";
sourceMeshRevision: number;
mode: SimplifyMode;
originalFaceCount: number;
originalTriangleCount: number;
outputFaceCount: number;
outputTriangleCount: number;
ratio: number;
triangleBudget?: number;
maxGeometricError?: number;
screenSpaceError?: number;
evaluatedMeshId?: string;
warnings?: string[];
error?: { code: SimplifyErrorCode; message: string };
}
export interface LODLevel {
level: number;
sourceMeshRevision: number;
triangleBudget: number;
meshId?: string;
maxGeometricError?: number;
screenSpaceError?: number;
}
export interface LODManifest {
schemaVersion: 1;
meshId: string;
sourceMeshRevision: number;
levels: LODLevel[];
}
function record(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SimplifyValidationError("INVALID_SCHEMA", `${field} must be an object`);
}
return value as Record<string, unknown>;
}
function finite(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be finite`);
}
return value;
}
function integer(value: unknown, field: string, minimum = 0): number {
const result = finite(value, field);
if (!Number.isInteger(result) || result < minimum) {
throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be an integer >= ${minimum}`);
}
return result;
}
function optionalNonNegative(value: unknown, field: string): number | undefined {
if (value === undefined) return undefined;
const result = finite(value, field);
if (result < 0) throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be >= 0`);
return result;
}
function boolean(value: unknown, field: string, fallback: boolean): boolean {
if (value === undefined) return fallback;
if (typeof value !== "boolean") throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be boolean`);
return value;
}
function optionalBudget(value: unknown): number | undefined {
if (value === undefined) return undefined;
return integer(value, "triangleBudget", 1);
}
function attributePolicy(value: unknown): SimplifyAttributePolicy {
if (value === undefined) return "PRESERVE";
if (value !== "PRESERVE" && value !== "RECOMPUTE_NORMALS" && value !== "DROP") {
throw new SimplifyValidationError("INVALID_PARAMETER", "attributePolicy must be PRESERVE, RECOMPUTE_NORMALS or DROP");
}
return value;
}
function skinPolicy(value: unknown): SkinSimplifyPolicy | undefined {
if (value === undefined) return undefined;
const input = record(value, "skinPolicy");
const maxInfluences = integer(input.maxInfluences, "skinPolicy.maxInfluences", 1);
if (maxInfluences > 8) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.maxInfluences must be <= 8");
const minWeight = finite(input.minWeight, "skinPolicy.minWeight");
if (minWeight < 0 || minWeight > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.minWeight must be between 0 and 1");
const maxPositionError = optionalNonNegative(input.maxPositionError, "skinPolicy.maxPositionError");
if (maxPositionError === undefined) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.maxPositionError is required");
const shapeKeys = input.shapeKeys;
if (shapeKeys !== "PRESERVE" && shapeKeys !== "REJECT") {
throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.shapeKeys must be PRESERVE or REJECT");
}
return { maxInfluences, minWeight, maxPositionError, shapeKeys };
}
function common(input: Record<string, unknown>): SimplifyCommon {
if (input.schemaVersion !== 1) throw new SimplifyValidationError("INVALID_SCHEMA", "Unsupported SimplifyProfile schema");
return {
schemaVersion: 1,
sourceMeshRevision: integer(input.sourceMeshRevision, "sourceMeshRevision"),
triangleBudget: optionalBudget(input.triangleBudget),
maxGeometricError: optionalNonNegative(input.maxGeometricError, "maxGeometricError"),
screenSpaceError: optionalNonNegative(input.screenSpaceError, "screenSpaceError"),
attributePolicy: attributePolicy(input.attributePolicy),
skinPolicy: skinPolicy(input.skinPolicy),
};
}
function rejectFields(input: Record<string, unknown>, fields: string[]): void {
const present = fields.find((field) => input[field] !== undefined);
if (present) throw new SimplifyValidationError("UNSUPPORTED_PARAMETER", `${present} is not valid for this simplify mode`);
}
const delimitValues: SimplifyDelimit[] = ["NORMAL", "MATERIAL", "SEAM", "SHARP", "UV", "ALL_BOUNDARIES"];
export function parseSimplifyProfile(value: unknown): SimplifyProfile {
const input = record(value, "SimplifyProfile");
const base = common(input);
if (input.mode === "COLLAPSE") {
const ratio = finite(input.ratio, "ratio");
if (ratio <= 0 || ratio > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "ratio must be > 0 and <= 1");
const vertexGroup = input.vertexGroup === undefined || input.vertexGroup === null ? null : input.vertexGroup;
if (vertexGroup !== null && typeof vertexGroup !== "string") {
throw new SimplifyValidationError("INVALID_PARAMETER", "vertexGroup must be a string or null");
}
const vertexGroupFactor = finite(input.vertexGroupFactor ?? 1, "vertexGroupFactor");
if (vertexGroupFactor < 0 || vertexGroupFactor > 1) {
throw new SimplifyValidationError("INVALID_PARAMETER", "vertexGroupFactor must be between 0 and 1");
}
const vertexGroupInvert = boolean(input.vertexGroupInvert, "vertexGroupInvert", false);
if (vertexGroup === null && (input.vertexGroupFactor !== undefined || vertexGroupInvert)) {
throw new SimplifyValidationError("MISSING_VERTEX_GROUP", "vertexGroupFactor/invert requires vertexGroup");
}
const useSymmetry = boolean(input.useSymmetry, "useSymmetry", false);
const symmetryAxis = integer(input.symmetryAxis ?? 0, "symmetryAxis");
if (symmetryAxis > 2) throw new SimplifyValidationError("INVALID_PARAMETER", "symmetryAxis must be 0, 1 or 2");
const symmetryTolerance = finite(input.symmetryTolerance ?? 1e-4, "symmetryTolerance");
if (symmetryTolerance <= 0) throw new SimplifyValidationError("INVALID_PARAMETER", "symmetryTolerance must be > 0");
if (!useSymmetry && (input.symmetryAxis !== undefined || input.symmetryTolerance !== undefined)) {
throw new SimplifyValidationError("UNSUPPORTED_PARAMETER", "symmetryAxis/tolerance requires useSymmetry");
}
return {
...base,
mode: "COLLAPSE",
ratio,
vertexGroup,
vertexGroupFactor,
vertexGroupInvert,
triangulate: boolean(input.triangulate, "triangulate", false),
useSymmetry,
symmetryAxis: symmetryAxis as 0 | 1 | 2,
symmetryTolerance,
};
}
if (input.mode === "UNSUBDIV") {
rejectFields(input, ["ratio", "vertexGroup", "vertexGroupFactor", "vertexGroupInvert", "triangulate", "useSymmetry", "symmetryAxis", "symmetryTolerance", "angleLimit", "delimit"]);
return { ...base, mode: "UNSUBDIV", iterations: integer(input.iterations, "iterations", 1) };
}
if (input.mode === "DISSOLVE_PLANAR") {
rejectFields(input, ["ratio", "vertexGroup", "vertexGroupFactor", "vertexGroupInvert", "triangulate", "useSymmetry", "symmetryAxis", "symmetryTolerance", "iterations"]);
const angleLimit = finite(input.angleLimit, "angleLimit");
if (angleLimit < 0 || angleLimit > Math.PI) throw new SimplifyValidationError("INVALID_PARAMETER", "angleLimit must be between 0 and PI");
const delimitInput = input.delimit ?? [];
if (!Array.isArray(delimitInput) || delimitInput.some((item) => typeof item !== "string" || !delimitValues.includes(item as SimplifyDelimit))) {
throw new SimplifyValidationError("INVALID_PARAMETER", "delimit contains an unsupported boundary type");
}
const delimit = [...new Set(delimitInput as SimplifyDelimit[])];
return {
...base,
mode: "DISSOLVE_PLANAR",
angleLimit,
useDissolveBoundaries: boolean(input.useDissolveBoundaries, "useDissolveBoundaries", false),
delimit,
};
}
throw new SimplifyValidationError("INVALID_PARAMETER", "mode must be COLLAPSE, UNSUBDIV or DISSOLVE_PLANAR");
}
export function parseSimplifyResult(value: unknown): SimplifyResult {
const input = record(value, "SimplifyResult");
if (input.schemaVersion !== 1 || (input.status !== "applied" && input.status !== "rejected")) {
throw new SimplifyValidationError("INVALID_SCHEMA", "SimplifyResult envelope is invalid");
}
const mode = input.mode;
if (mode !== "COLLAPSE" && mode !== "UNSUBDIV" && mode !== "DISSOLVE_PLANAR") {
throw new SimplifyValidationError("INVALID_SCHEMA", "SimplifyResult.mode is invalid");
}
for (const field of ["originalFaceCount", "originalTriangleCount", "outputFaceCount", "outputTriangleCount"]) {
integer(input[field], field);
}
const ratio = finite(input.ratio, "ratio");
if (ratio < 0 || ratio > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "result ratio must be between 0 and 1");
const result: SimplifyResult = {
schemaVersion: 1,
status: input.status,
sourceMeshRevision: integer(input.sourceMeshRevision, "sourceMeshRevision"),
mode,
originalFaceCount: input.originalFaceCount as number,
originalTriangleCount: input.originalTriangleCount as number,
outputFaceCount: input.outputFaceCount as number,
outputTriangleCount: input.outputTriangleCount as number,
ratio,
triangleBudget: optionalBudget(input.triangleBudget),
maxGeometricError: optionalNonNegative(input.maxGeometricError, "maxGeometricError"),
screenSpaceError: optionalNonNegative(input.screenSpaceError, "screenSpaceError"),
evaluatedMeshId: input.evaluatedMeshId === undefined ? undefined : String(input.evaluatedMeshId),
warnings: input.warnings === undefined ? undefined : input.warnings as string[],
error: input.error === undefined ? undefined : record(input.error, "error") as SimplifyResult["error"],
};
if (result.status === "rejected" && !result.error) throw new SimplifyValidationError("INVALID_SCHEMA", "rejected result must include error");
return result;
}
export function parseLODManifest(value: unknown): LODManifest {
const input = record(value, "LODManifest");
if (input.schemaVersion !== 1 || typeof input.meshId !== "string" || !input.meshId) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LODManifest envelope is invalid");
}
if (!Array.isArray(input.levels) || input.levels.length === 0) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LODManifest.levels must not be empty");
}
const sourceMeshRevision = integer(input.sourceMeshRevision, "sourceMeshRevision");
const levels = input.levels.map((raw, index) => {
const level = record(raw, `levels[${index}]`);
const parsed: LODLevel = {
level: integer(level.level, `levels[${index}].level`),
sourceMeshRevision: integer(level.sourceMeshRevision, `levels[${index}].sourceMeshRevision`),
triangleBudget: integer(level.triangleBudget, `levels[${index}].triangleBudget`, 1),
meshId: level.meshId === undefined ? undefined : String(level.meshId),
maxGeometricError: optionalNonNegative(level.maxGeometricError, `levels[${index}].maxGeometricError`),
screenSpaceError: optionalNonNegative(level.screenSpaceError, `levels[${index}].screenSpaceError`),
};
if (parsed.sourceMeshRevision !== sourceMeshRevision) throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "all LOD levels must share sourceMeshRevision");
return parsed;
});
for (let index = 0; index < levels.length; index++) {
if (levels[index].level !== index) throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD levels must be contiguous from level 0");
if (index > 0 && levels[index].triangleBudget >= levels[index - 1].triangleBudget) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD triangle budgets must strictly decrease");
}
}
return { schemaVersion: 1, meshId: input.meshId, sourceMeshRevision, levels };
}

View File

@@ -0,0 +1,127 @@
import type { ErrorCode } from "./error";
export const SIMULATION_CACHE_SCHEMA = 1 as const;
export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
export interface SimulationCacheFrameIR {
frame: number;
byteOffset: number;
byteLength: number;
sha256: string;
}
export interface SimulationCacheManifestIR {
schemaVersion: typeof SIMULATION_CACHE_SCHEMA;
graphId: string;
graphHash: string;
sourceBlendSha256: string;
inputHash: string;
cacheSha256: string;
blenderVersion: string;
frameStart: number;
frameEnd: number;
byteLength: number;
frames: SimulationCacheFrameIR[];
}
export class SimulationCacheValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(message);
this.name = "SimulationCacheValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function integer(value: unknown, name: string, minimum = 0): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer >= ${minimum}`);
}
return value;
}
function digest(value: unknown, name: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be a lowercase SHA-256 digest`);
}
return value;
}
export function parseSimulationCacheManifest(value: unknown): SimulationCacheManifestIR {
if (!record(value) || value.schemaVersion !== SIMULATION_CACHE_SCHEMA) {
throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", "Unsupported SimulationCache manifest schema");
}
for (const name of ["graphId", "blenderVersion"] as const) {
if (typeof value[name] !== "string" || value[name].length === 0) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is required`);
}
}
if (!(value.blenderVersion as string).startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) {
throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", `Simulation cache requires Blender ${SIMULATION_CACHE_BLENDER_VERSION_PREFIX}x`);
}
const frameStart = integer(value.frameStart, "frameStart", -1_000_000);
const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000);
const byteLength = integer(value.byteLength, "byteLength", 1);
if (frameEnd < frameStart || frameEnd - frameStart > 100_000 || !Array.isArray(value.frames)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid");
}
if (value.frames.length !== frameEnd - frameStart + 1) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache must contain every declared frame");
}
let nextOffset = 0;
const frames = value.frames.map((item, index): SimulationCacheFrameIR => {
if (!record(item)) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is invalid`);
const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000);
const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`);
const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1);
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset + frameByteLength > byteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`);
}
nextOffset += frameByteLength;
return { frame, byteOffset, byteLength: frameByteLength, sha256: digest(item.sha256, `frames[${index}].sha256`) };
});
if (nextOffset !== byteLength) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame ranges do not cover the cache payload");
return {
schemaVersion: SIMULATION_CACHE_SCHEMA,
graphId: value.graphId as string,
graphHash: digest(value.graphHash, "graphHash"),
sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"),
inputHash: digest(value.inputHash, "inputHash"),
cacheSha256: digest(value.cacheSha256, "cacheSha256"),
blenderVersion: value.blenderVersion as string,
frameStart,
frameEnd,
byteLength,
frames,
};
}
async function sha256(data: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function verifySimulationCache(manifestValue: unknown, data: ArrayBuffer): Promise<SimulationCacheManifestIR> {
const manifest = parseSimulationCacheManifest(manifestValue);
if (data.byteLength !== manifest.byteLength || await sha256(data) !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload does not match its manifest");
}
for (const frame of manifest.frames) {
const bytes = data.slice(frame.byteOffset, frame.byteOffset + frame.byteLength);
if (await sha256(bytes) !== frame.sha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame.frame} failed SHA-256 verification`);
}
}
return manifest;
}
export function simulationCacheKey(manifest: SimulationCacheManifestIR): string {
return `${manifest.graphHash.slice(0, 16)}-${manifest.sourceBlendSha256.slice(0, 16)}-${manifest.inputHash.slice(0, 16)}-${manifest.frameStart}-${manifest.frameEnd}`;
}

62
web/protocol/skin.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { ShapeKeyIR, SkinWeightsIR } from "./scene-ir";
import type { SkinSimplifyPolicy } from "./simplify";
export interface NormalizedSkinWeights {
indices: number[];
weights: number[];
droppedInfluences: number;
}
export interface SkinSimplifyGate {
allowed: boolean;
reason?: "MISSING_SKIN_ATTRIBUTES" | "INVALID_SKIN_ATTRIBUTES" | "SHAPE_KEYS_REJECTED" | "SHAPE_KEY_DATA_INVALID";
message?: string;
normalized?: NormalizedSkinWeights;
}
function finiteWeight(value: number): boolean {
return Number.isFinite(value) && value >= 0;
}
export function normalizeSkinWeights(vertexCount: number, skin: SkinWeightsIR, policy: SkinSimplifyPolicy): NormalizedSkinWeights {
if (!Number.isInteger(vertexCount) || vertexCount <= 0 || skin.indices.length !== vertexCount * 4 || skin.weights.length !== vertexCount * 4) {
throw new Error("skin arrays must contain four influences per vertex");
}
const indices: number[] = [];
const weights: number[] = [];
let droppedInfluences = 0;
for (let vertex = 0; vertex < vertexCount; vertex++) {
const influences = [0, 1, 2, 3].map((slot) => ({
index: skin.indices[vertex * 4 + slot],
weight: skin.weights[vertex * 4 + slot],
})).filter((influence) => finiteWeight(influence.weight) && influence.weight >= policy.minWeight)
.sort((left, right) => right.weight - left.weight)
.slice(0, policy.maxInfluences);
droppedInfluences += 4 - influences.length;
const total = influences.reduce((sum, influence) => sum + influence.weight, 0);
if (total <= 0) throw new Error(`vertex ${vertex} has no surviving skin influence`);
for (let slot = 0; slot < 4; slot++) {
const influence = influences[slot];
indices.push(influence?.index ?? 0);
weights.push(influence ? influence.weight / total : 0);
}
}
return { indices, weights, droppedInfluences };
}
export function evaluateSkinSimplifyGate(vertexCount: number, skin: SkinWeightsIR | undefined, shapeKeys: readonly ShapeKeyIR[] | undefined, policy: SkinSimplifyPolicy): SkinSimplifyGate {
if (!skin) return { allowed: false, reason: "MISSING_SKIN_ATTRIBUTES", message: "Skin simplification requires exported bone indices, weights and bind matrix" };
try {
const normalized = normalizeSkinWeights(vertexCount, skin, policy);
if (shapeKeys && shapeKeys.length > 0 && policy.shapeKeys === "REJECT") {
return { allowed: false, reason: "SHAPE_KEYS_REJECTED", message: "Shape keys must be explicitly rejected before skin simplification" };
}
if (shapeKeys?.some((shape) => shape.positions.length !== vertexCount * 3)) {
return { allowed: false, reason: "SHAPE_KEY_DATA_INVALID", message: "Shape key positions do not match the source vertex count" };
}
return { allowed: true, normalized };
}
catch (error) {
return { allowed: false, reason: "INVALID_SKIN_ATTRIBUTES", message: error instanceof Error ? error.message : "Invalid skin attributes" };
}
}

218
web/protocol/storage.ts Normal file
View File

@@ -0,0 +1,218 @@
import type { LODCacheRecord } from "./lod";
import type { SimulationCacheManifestIR } from "./simulation-cache";
import type { ErrorCode } from "./error";
export interface StorageSmokeResult {
backend: "indexeddb";
opfsAvailable: boolean;
rowCount: number;
persisted: boolean;
schemaVersion: number;
stores: string[];
}
export interface StorageInfoResult {
backend: "indexeddb";
opfsAvailable: boolean;
schemaVersion: number;
stores: string[];
}
export interface StorageProjectResult {
projectId: string;
scenePath: string;
directories: string[];
}
export interface StorageSaveResult {
projectId: string;
bytes: number;
revision: number;
persisted: boolean;
backend: "opfs" | "indexeddb";
scenePath: string;
sha256: string;
}
export interface StorageRecoveryResult {
projectId: string;
status: "clean" | "recovered" | "missing";
recovered: boolean;
backend: "opfs" | "indexeddb";
revision?: number;
bytes?: number;
sha256?: string;
}
export interface StorageProjectReadResult {
projectId: string;
revision: number;
bytes: number;
sha256: string;
backend: "opfs" | "indexeddb";
recovered: boolean;
buffer: ArrayBuffer;
}
export interface StorageOperationResult {
id: string;
projectId: string;
revision: number;
persisted: boolean;
}
export interface StorageOperationRecord {
id: string;
projectId: string;
revision: number;
payload: unknown;
inversePayload?: unknown;
createdAt: string;
}
export interface StorageOperationListResult {
projectId: string;
afterRevision: number;
operations: StorageOperationRecord[];
quarantined: number;
}
export interface StorageOperationPruneResult {
projectId: string;
throughRevision: number;
removed: number;
}
export interface StorageSnapshotRecord {
projectId: string;
revision: number;
bytes: number;
sha256: string;
createdAt: string;
}
export interface StorageSnapshotResult extends StorageSnapshotRecord { persisted: boolean; }
export interface StorageSnapshotListResult { projectId: string; snapshots: StorageSnapshotRecord[]; }
export interface StorageSnapshotReadResult extends StorageSnapshotRecord { buffer: ArrayBuffer; }
export interface StorageAssetRecord {
assetId: string;
projectId: string;
sha256: string;
bytes: number;
mimeType: string;
sourcePath?: string;
path: string;
createdAt: string;
lastAccessAt: string;
}
export interface StorageAssetPutResult extends StorageAssetRecord {
persisted: boolean;
deduplicated: boolean;
}
export interface StorageAssetReadResult {
asset: StorageAssetRecord;
data: ArrayBuffer;
}
export interface StorageAssetListResult {
projectId: string;
assets: StorageAssetRecord[];
}
export interface StorageLODResult {
projectId: string;
cacheKey: string;
bytes: number;
persisted: boolean;
path: string;
}
export interface StorageLODManifestResult {
projectId: string;
cacheKey: string;
persisted: boolean;
manifest?: LODCacheRecord;
}
export interface StorageLODManifestListResult {
projectId: string;
manifests: LODCacheRecord[];
}
export interface StorageLODReadResult {
projectId: string;
cacheKey: string;
data: ArrayBuffer;
bytes: number;
}
export interface StorageLODPruneResult {
projectId: string;
maxBytes: number;
removed: number;
bytes: number;
cacheKeys: string[];
}
export interface StorageSimulationCacheResult {
projectId: string;
cacheKey: string;
persisted: boolean;
manifest: SimulationCacheManifestIR;
path: string;
}
export interface StorageSimulationCacheReadResult extends StorageSimulationCacheResult {
data: ArrayBuffer;
}
export interface StorageSimulationCacheListResult {
projectId: string;
caches: Array<{
cacheKey: string;
manifest: SimulationCacheManifestIR;
path: string;
createdAt: string;
}>;
}
export interface StorageRequest {
requestId: string;
command:
| { type: "smoke" }
| { type: "info" }
| { type: "ensureProject"; projectId: string }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "quota" }
| { type: "recoverProject"; projectId: string }
| { type: "readProject"; projectId: string }
| { type: "appendOperation"; id: string; projectId: string; revision: number; payload: unknown; inversePayload?: unknown }
| { type: "listOperations"; projectId: string; afterRevision: number }
| { type: "pruneOperations"; projectId: string; throughRevision: number }
| { type: "saveSnapshot"; projectId: string; revision: number; buffer: ArrayBuffer; maxCount?: number; maxBytes?: number }
| { type: "listSnapshots"; projectId: string }
| { type: "readSnapshot"; projectId: string; revision: number }
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string }
| { type: "readAsset"; projectId: string; sha256: string }
| { type: "listAssets"; projectId: string }
| { type: "saveLOD"; projectId: string; cacheKey: string; data: ArrayBuffer }
| { type: "putLODManifest"; projectId: string; manifest: LODCacheRecord }
| { type: "getLODManifest"; projectId: string; cacheKey: string }
| { type: "listLODManifests"; projectId: string }
| { type: "readLOD"; projectId: string; cacheKey: string }
| { type: "deleteLOD"; projectId: string; cacheKey: string }
| { type: "pruneLOD"; projectId: string; maxBytes: number }
| { type: "putSimulationCache"; projectId: string; manifest: SimulationCacheManifestIR; data: ArrayBuffer }
| { type: "readSimulationCache"; projectId: string; cacheKey: string }
| { type: "listSimulationCaches"; projectId: string };
}
export interface StorageResponse {
requestId: string;
ok: boolean;
result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheListResult;
error?: string;
errorCode?: ErrorCode;
}

View File

@@ -0,0 +1,329 @@
import { normalizeProjectAssetPath } from "./asset-path";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const TRACKING_MASK_SCHEMA = 1 as const;
export const TRACKING_MASK_BUDGET = {
maxClips: 256,
maxTracks: 100_000,
maxMarkers: 1_000_000,
maxPlaneTracks: 10_000,
maxMaskLayers: 1_024,
maxSplines: 100_000,
maxMaskPoints: 1_000_000,
maxBindings: 4_096,
maxFrame: 1_000_000,
} as const;
type Vec2 = [number, number];
type Quad2 = [Vec2, Vec2, Vec2, Vec2];
export interface TrackingMarkerIR {
frame: number;
position: Vec2;
patternMin: Vec2;
patternMax: Vec2;
searchMin: Vec2;
searchMax: Vec2;
keyframe: boolean;
muted: boolean;
selected: boolean;
}
export interface TrackingTrackIR {
id: string;
name: string;
selected: boolean;
locked: boolean;
markers: TrackingMarkerIR[];
}
export interface PlaneTrackKeyframeIR { frame: number; corners: Quad2 }
export interface PlaneTrackIR {
id: string;
name: string;
selected: boolean;
pointTrackIds: string[];
keyframes: PlaneTrackKeyframeIR[];
}
export interface CameraSolveIR {
status: "NONE" | "SOLVED" | "FAILED";
sourceSha256?: string;
settingsHash?: string;
reprojectionError?: number;
focalLength?: number;
principalPoint?: Vec2;
solvedFrames?: number[];
}
export interface MovieClipIR {
id: string;
name: string;
sourcePath: string;
sourceSha256: string;
width: number;
height: number;
frameStart: number;
frameEnd: number;
fpsNumerator: number;
fpsDenominator: number;
tracks: TrackingTrackIR[];
planeTracks: PlaneTrackIR[];
cameraSolve?: CameraSolveIR;
}
export interface MaskPointIR {
id: string;
co: Vec2;
handleLeft: Vec2;
handleRight: Vec2;
handleType: "AUTO" | "VECTOR" | "ALIGNED" | "FREE";
feather: number;
selected: boolean;
}
export interface MaskSplineIR {
id: string;
cyclic: boolean;
fill: boolean;
points: MaskPointIR[];
}
export interface MaskLayerIR {
id: string;
name: string;
visible: boolean;
locked: boolean;
opacity: number;
splines: MaskSplineIR[];
}
export interface MaskIR { id: string; name: string; layers: MaskLayerIR[] }
export interface TrackingMaskBindingIR {
id: string;
target: "COMPOSITOR" | "SCENE";
ownerId: string;
clipId?: string;
maskId?: string;
}
export interface TrackingMaskProjectIR {
schemaVersion: typeof TRACKING_MASK_SCHEMA;
revision: number;
clips: MovieClipIR[];
masks: MaskIR[];
bindings: TrackingMaskBindingIR[];
}
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 }
| { type: "SET_TRACK_SELECTION"; revision: number; clipId: string; trackId: string; selected: boolean }
| { type: "SET_MASK_POINT"; revision: number; maskId: string; layerId: string; splineId: string; point: MaskPointIR }
| { type: "SET_SPLINE_CYCLIC"; revision: number; maskId: string; layerId: string; splineId: string; cyclic: boolean };
export class TrackingMaskValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "TrackingMaskValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const HANDLE_TYPES = new Set<MaskPointIR["handleType"]>(["AUTO", "VECTOR", "ALIGNED", "FREE"]);
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string, maximum = 256): string {
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`);
return value;
}
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is outside the bounded range`);
return value;
}
function finite(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is outside the bounded range`);
return value;
}
function digest(value: unknown, name: string): string {
if (typeof value !== "string" || !SHA256.test(value)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} must be a lowercase SHA-256 digest`);
return value;
}
function vec2(value: unknown, name: string, minimum = -16, maximum = 16): Vec2 {
if (!Array.isArray(value) || value.length !== 2) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} must be vec2`);
return [finite(value[0], `${name}[0]`, minimum, maximum), finite(value[1], `${name}[1]`, minimum, maximum)];
}
function marker(value: unknown, name: string): TrackingMarkerIR {
if (!record(value)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`);
const patternMin = vec2(value.patternMin, `${name}.patternMin`);
const patternMax = vec2(value.patternMax, `${name}.patternMax`);
const searchMin = vec2(value.searchMin, `${name}.searchMin`);
const searchMax = vec2(value.searchMax, `${name}.searchMax`);
if (patternMin[0] >= patternMax[0] || patternMin[1] >= patternMax[1] || searchMin[0] >= searchMax[0] || searchMin[1] >= searchMax[1]) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} bounds are invalid`);
return {
frame: integer(value.frame, `${name}.frame`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame),
position: vec2(value.position, `${name}.position`, 0, 1),
patternMin, patternMax, searchMin, searchMax,
keyframe: value.keyframe === true,
muted: value.muted === true,
selected: value.selected === true,
};
}
function sortedFrames<T extends { frame: number }>(items: T[], name: string): T[] {
items.sort((a, b) => a.frame - b.frame);
if (items.some((item, index) => index > 0 && item.frame === items[index - 1].frame)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} has duplicate frames`);
return items;
}
function maskPoint(value: unknown, name: string): MaskPointIR {
if (!record(value) || !HANDLE_TYPES.has(value.handleType as MaskPointIR["handleType"])) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${name} is invalid`);
return {
id: text(value.id, `${name}.id`),
co: vec2(value.co, `${name}.co`, -4, 4),
handleLeft: vec2(value.handleLeft, `${name}.handleLeft`, -4, 4),
handleRight: vec2(value.handleRight, `${name}.handleRight`, -4, 4),
handleType: value.handleType as MaskPointIR["handleType"],
feather: finite(value.feather, `${name}.feather`, 0, 100),
selected: value.selected === true,
};
}
function cameraSolve(value: unknown, sourceSha256: string, name: string): CameraSolveIR | undefined {
if (value === undefined) return undefined;
if (!record(value) || !["NONE", "SOLVED", "FAILED"].includes(value.status as string)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`);
const solve: CameraSolveIR = { status: value.status as CameraSolveIR["status"] };
if (solve.status === "SOLVED") {
solve.sourceSha256 = digest(value.sourceSha256, `${name}.sourceSha256`);
if (solve.sourceSha256 !== sourceSha256) throw new TrackingMaskValidationError("TRACKING_SOURCE_HASH_MISMATCH", `${name} source hash is stale`);
solve.settingsHash = digest(value.settingsHash, `${name}.settingsHash`);
solve.reprojectionError = finite(value.reprojectionError, `${name}.reprojectionError`, 0, 1_000_000);
solve.focalLength = finite(value.focalLength, `${name}.focalLength`, 0.001, 100_000);
solve.principalPoint = vec2(value.principalPoint, `${name}.principalPoint`, -4, 4);
if (!Array.isArray(value.solvedFrames) || value.solvedFrames.length === 0 || value.solvedFrames.length > TRACKING_MASK_BUDGET.maxMarkers) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", `${name}.solvedFrames exceeds the budget`);
solve.solvedFrames = sortedFrames(value.solvedFrames.map((item, index) => ({ frame: integer(item, `${name}.solvedFrames[${index}]`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame) })), `${name}.solvedFrames`).map((item) => item.frame);
}
return solve;
}
export function parseTrackingMaskProject(value: unknown): TrackingMaskProjectIR {
if (!record(value) || value.schemaVersion !== TRACKING_MASK_SCHEMA || !Array.isArray(value.clips) || !Array.isArray(value.masks) || !Array.isArray(value.bindings)) throw new TrackingMaskValidationError("PROTOCOL_MISMATCH", "Unsupported TrackingMask project schema");
if (value.clips.length > TRACKING_MASK_BUDGET.maxClips || value.bindings.length > TRACKING_MASK_BUDGET.maxBindings) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Tracking project exceeds the clip or binding budget");
let trackCount = 0; let markerCount = 0; let planeTrackCount = 0; let layerCount = 0; let splineCount = 0; let pointCount = 0;
const clipIds = new Set<string>();
const clips = value.clips.map((clipValue, clipIndex): MovieClipIR => {
const name = `clips[${clipIndex}]`;
if (!record(clipValue) || !Array.isArray(clipValue.tracks) || !Array.isArray(clipValue.planeTracks)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`);
const id = text(clipValue.id, `${name}.id`); if (clipIds.has(id)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate clip ${id}`); clipIds.add(id);
const frameStart = integer(clipValue.frameStart, `${name}.frameStart`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame);
const frameEnd = integer(clipValue.frameEnd, `${name}.frameEnd`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame);
if (frameEnd < frameStart) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} frame range is invalid`);
trackCount += clipValue.tracks.length; planeTrackCount += clipValue.planeTracks.length;
if (trackCount > TRACKING_MASK_BUDGET.maxTracks || planeTrackCount > TRACKING_MASK_BUDGET.maxPlaneTracks) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Tracking tracks exceed the budget");
const trackIds = new Set<string>();
const tracks = clipValue.tracks.map((trackValue, trackIndex): TrackingTrackIR => {
const trackName = `${name}.tracks[${trackIndex}]`;
if (!record(trackValue) || !Array.isArray(trackValue.markers)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${trackName} is invalid`);
const trackId = text(trackValue.id, `${trackName}.id`); if (trackIds.has(trackId)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate track ${trackId}`); trackIds.add(trackId);
markerCount += trackValue.markers.length; if (markerCount > TRACKING_MASK_BUDGET.maxMarkers) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Tracking markers exceed the budget");
return { id: trackId, name: text(trackValue.name, `${trackName}.name`), selected: trackValue.selected === true, locked: trackValue.locked === true, markers: sortedFrames(trackValue.markers.map((item, markerIndex) => marker(item, `${trackName}.markers[${markerIndex}]`)), `${trackName}.markers`) };
});
const planeIds = new Set<string>();
const planeTracks = clipValue.planeTracks.map((planeValue, planeIndex): PlaneTrackIR => {
const planeName = `${name}.planeTracks[${planeIndex}]`;
if (!record(planeValue) || !Array.isArray(planeValue.pointTrackIds) || planeValue.pointTrackIds.length < 4 || planeValue.pointTrackIds.length > 64 || !Array.isArray(planeValue.keyframes)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${planeName} is invalid`);
const planeId = text(planeValue.id, `${planeName}.id`); if (planeIds.has(planeId)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate plane track ${planeId}`); planeIds.add(planeId);
const pointTrackIds = planeValue.pointTrackIds.map((item, index) => text(item, `${planeName}.pointTrackIds[${index}]`));
if (new Set(pointTrackIds).size !== pointTrackIds.length || pointTrackIds.some((trackId) => !trackIds.has(trackId))) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `${planeName} references missing point tracks`);
const keyframes = sortedFrames(planeValue.keyframes.map((item, keyIndex): PlaneTrackKeyframeIR => {
if (!record(item) || !Array.isArray(item.corners) || item.corners.length !== 4) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${planeName}.keyframes[${keyIndex}] is invalid`);
return { frame: integer(item.frame, `${planeName}.keyframes[${keyIndex}].frame`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame), corners: item.corners.map((corner, cornerIndex) => vec2(corner, `${planeName}.keyframes[${keyIndex}].corners[${cornerIndex}]`, -4, 4)) as Quad2 };
}), `${planeName}.keyframes`);
return { id: planeId, name: text(planeValue.name, `${planeName}.name`), selected: planeValue.selected === true, pointTrackIds, keyframes };
});
let sourcePath: string; try { sourcePath = normalizeProjectAssetPath(text(clipValue.sourcePath, `${name}.sourcePath`, 2048)); } catch { throw new TrackingMaskValidationError("TRACKING_RESOURCE_OUTSIDE_PROJECT", `${name}.sourcePath is outside the project`); }
const sourceSha256 = digest(clipValue.sourceSha256, `${name}.sourceSha256`);
return { id, name: text(clipValue.name, `${name}.name`), sourcePath, sourceSha256, width: integer(clipValue.width, `${name}.width`, 1, 32768), height: integer(clipValue.height, `${name}.height`, 1, 32768), frameStart, frameEnd, fpsNumerator: integer(clipValue.fpsNumerator, `${name}.fpsNumerator`, 1, 1_000_000), fpsDenominator: integer(clipValue.fpsDenominator, `${name}.fpsDenominator`, 1, 1_000_000), tracks, planeTracks, cameraSolve: cameraSolve(clipValue.cameraSolve, sourceSha256, `${name}.cameraSolve`) };
});
const maskIds = new Set<string>();
const masks = value.masks.map((maskValue, maskIndex): MaskIR => {
const name = `masks[${maskIndex}]`; if (!record(maskValue) || !Array.isArray(maskValue.layers)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${name} is invalid`);
const id = text(maskValue.id, `${name}.id`); if (maskIds.has(id)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask ${id}`); maskIds.add(id);
layerCount += maskValue.layers.length; if (layerCount > TRACKING_MASK_BUDGET.maxMaskLayers) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask layers exceed the budget");
const layerIds = new Set<string>();
const layers = maskValue.layers.map((layerValue, layerIndex): MaskLayerIR => {
const layerName = `${name}.layers[${layerIndex}]`; if (!record(layerValue) || !Array.isArray(layerValue.splines)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${layerName} is invalid`);
const layerId = text(layerValue.id, `${layerName}.id`); if (layerIds.has(layerId)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask layer ${layerId}`); layerIds.add(layerId);
splineCount += layerValue.splines.length; if (splineCount > TRACKING_MASK_BUDGET.maxSplines) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask splines exceed the budget");
const splineIds = new Set<string>();
const splines = layerValue.splines.map((splineValue, splineIndex): MaskSplineIR => {
const splineName = `${layerName}.splines[${splineIndex}]`; if (!record(splineValue) || !Array.isArray(splineValue.points)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${splineName} is invalid`);
const splineId = text(splineValue.id, `${splineName}.id`); if (splineIds.has(splineId)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask spline ${splineId}`); splineIds.add(splineId);
pointCount += splineValue.points.length; if (pointCount > TRACKING_MASK_BUDGET.maxMaskPoints) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask points exceed the budget");
const pointIds = new Set<string>(); const points = splineValue.points.map((item, pointIndex) => { const point = maskPoint(item, `${splineName}.points[${pointIndex}]`); if (pointIds.has(point.id)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask point ${point.id}`); pointIds.add(point.id); return point; });
if (points.length < 2) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${splineName} needs at least two points`);
return { id: splineId, cyclic: splineValue.cyclic === true, fill: splineValue.fill !== false, points };
});
return { id: layerId, name: text(layerValue.name, `${layerName}.name`), visible: layerValue.visible !== false, locked: layerValue.locked === true, opacity: finite(layerValue.opacity, `${layerName}.opacity`, 0, 1), splines };
});
return { id, name: text(maskValue.name, `${name}.name`), layers };
});
const bindingIds = new Set<string>();
const bindings = value.bindings.map((bindingValue, index): TrackingMaskBindingIR => {
const name = `bindings[${index}]`; if (!record(bindingValue) || !["COMPOSITOR", "SCENE"].includes(bindingValue.target as string)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`);
const id = text(bindingValue.id, `${name}.id`); if (bindingIds.has(id)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate binding ${id}`); bindingIds.add(id);
const binding: TrackingMaskBindingIR = { id, target: bindingValue.target as TrackingMaskBindingIR["target"], ownerId: text(bindingValue.ownerId, `${name}.ownerId`) };
if (bindingValue.clipId !== undefined) binding.clipId = text(bindingValue.clipId, `${name}.clipId`);
if (bindingValue.maskId !== undefined) binding.maskId = text(bindingValue.maskId, `${name}.maskId`);
if ((!binding.clipId && !binding.maskId) || (binding.clipId && !clipIds.has(binding.clipId)) || (binding.maskId && !maskIds.has(binding.maskId))) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `${name} references missing resources`);
return binding;
});
return { schemaVersion: TRACKING_MASK_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), clips, masks, bindings };
}
export function applyTrackingMaskEdit(value: unknown, edit: TrackingMaskEditIR): TrackingMaskProjectIR {
const project = parseTrackingMaskProject(value);
if (edit.revision !== project.revision) throw new TrackingMaskValidationError("REVISION_CONFLICT", "Tracking/Mask edit revision is stale");
const clone = structuredClone(project);
if (edit.type === "SET_MARKER" || edit.type === "DELETE_MARKER" || edit.type === "SET_TRACK_SELECTION") {
const clip = clone.clips.find((item) => item.id === edit.clipId); const track = clip?.tracks.find((item) => item.id === edit.trackId);
if (!track) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `Unknown track ${edit.trackId}`);
if (track.locked) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${track.id} is locked`);
if (edit.type === "SET_TRACK_SELECTION") track.selected = edit.selected;
else if (edit.type === "DELETE_MARKER") {
const index = track.markers.findIndex((item) => item.frame === edit.frame); if (index < 0) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Marker frame ${edit.frame} is missing`); track.markers.splice(index, 1);
}
else {
const next = marker(edit.marker, "edit.marker"); const index = track.markers.findIndex((item) => item.frame === next.frame); if (index < 0) track.markers.push(next); else track.markers[index] = next;
}
}
else {
const maskValue = clone.masks.find((item) => item.id === edit.maskId); const layer = maskValue?.layers.find((item) => item.id === edit.layerId); const spline = layer?.splines.find((item) => item.id === edit.splineId);
if (!spline || !layer) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `Unknown mask spline ${edit.splineId}`);
if (layer.locked) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${layer.id} is locked`);
if (edit.type === "SET_SPLINE_CYCLIC") spline.cyclic = edit.cyclic;
else { const next = maskPoint(edit.point, "edit.point"); const index = spline.points.findIndex((item) => item.id === next.id); if (index < 0) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Unknown mask point ${next.id}`); spline.points[index] = next; }
}
clone.revision += 1;
return parseTrackingMaskProject(clone);
}
export function gateTrackingOperation(operation: "MARKER_EDIT" | "MASK_EDIT" | "BROWSER_TRACKING" | "CAMERA_SOLVE", browserProbe: "VERIFIED" | "UNAVAILABLE" | "UNVERIFIED" = "UNVERIFIED"): CapabilityGateResult {
if (operation === "MARKER_EDIT" || operation === "MASK_EDIT") return readyGate("N-022", operation);
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")]);
}

129
web/protocol/ui-schema.ts Normal file
View File

@@ -0,0 +1,129 @@
export type WorkspaceId = "Layout" | "Modeling" | "Animation";
export type EditorType = "3D Viewport" | "Outliner" | "Properties" | "Timeline";
export type BlenderMode = "Object" | "Edit" | "Pose";
export interface RegionIR {
id: string;
kind: "header" | "main" | "toolbar" | "sidebar" | "footer";
visible: boolean;
}
export interface AreaIR {
id: string;
editor: EditorType;
regions: RegionIR[];
rect: { x: number; y: number; width: number; height: number };
maximized: boolean;
}
export interface WorkspaceIR {
id: WorkspaceId;
name: string;
areas: AreaIR[];
activeAreaId: string;
revision: number;
}
export interface UIContextIR {
workspaceId: WorkspaceId;
activeAreaId: string;
activeEditor: EditorType;
mode: BlenderMode;
activeObjectId: string | null;
selection: string[];
viewLayer: string;
pinnedData: string | null;
revision: number;
}
export interface WebWorkspaceState {
workspaces: Record<WorkspaceId, WorkspaceIR>;
context: UIContextIR;
operatorSearchOpen: boolean;
sidebarVisible: boolean;
}
export type UICommand =
| { type: "switchWorkspace"; workspaceId: WorkspaceId }
| { type: "setMode"; mode: BlenderMode }
| { type: "setActiveArea"; areaId: string }
| { type: "toggleOperatorSearch"; open?: boolean }
| { type: "toggleSidebar"; visible?: boolean };
function regions(): RegionIR[] {
return [
{ id: "header", kind: "header", visible: true },
{ id: "main", kind: "main", visible: true },
{ id: "toolbar", kind: "toolbar", visible: true },
{ id: "sidebar", kind: "sidebar", visible: true },
];
}
function layoutAreas(): AreaIR[] {
return [
{ id: "viewport", editor: "3D Viewport", regions: regions(), rect: { x: 0, y: 0, width: 0.8, height: 0.78 }, maximized: false },
{ id: "outliner", editor: "Outliner", regions: regions(), rect: { x: 0.8, y: 0, width: 0.2, height: 0.42 }, maximized: false },
{ id: "properties", editor: "Properties", regions: regions(), rect: { x: 0.8, y: 0.42, width: 0.2, height: 0.36 }, maximized: false },
{ id: "timeline", editor: "Timeline", regions: regions(), rect: { x: 0, y: 0.78, width: 1, height: 0.22 }, maximized: false },
];
}
function workspace(id: WorkspaceId): WorkspaceIR {
return { id, name: id, areas: layoutAreas(), activeAreaId: "viewport", revision: 0 };
}
export function createDefaultWebWorkspaceState(): WebWorkspaceState {
const workspaces = {
Layout: workspace("Layout"),
Modeling: workspace("Modeling"),
Animation: workspace("Animation"),
} satisfies Record<WorkspaceId, WorkspaceIR>;
return {
workspaces,
context: {
workspaceId: "Layout",
activeAreaId: "viewport",
activeEditor: "3D Viewport",
mode: "Object",
activeObjectId: "BasicCube",
selection: ["BasicCube"],
viewLayer: "ViewLayer",
pinnedData: null,
revision: 0,
},
operatorSearchOpen: false,
sidebarVisible: true,
};
}
export function reduceUICommand(state: WebWorkspaceState, command: UICommand): WebWorkspaceState {
switch (command.type) {
case "switchWorkspace": {
const next = state.workspaces[command.workspaceId];
return {
...state,
context: {
...state.context,
workspaceId: command.workspaceId,
activeAreaId: next.activeAreaId,
activeEditor: next.areas.find((area) => area.id === next.activeAreaId)?.editor ?? "3D Viewport",
revision: state.context.revision + 1,
},
};
}
case "setMode":
return { ...state, context: { ...state.context, mode: command.mode, revision: state.context.revision + 1 } };
case "setActiveArea": {
const active = state.workspaces[state.context.workspaceId].areas.find((area) => area.id === command.areaId);
if (!active) return state;
return {
...state,
context: { ...state.context, activeAreaId: active.id, activeEditor: active.editor, revision: state.context.revision + 1 },
};
}
case "toggleOperatorSearch":
return { ...state, operatorSearchOpen: command.open ?? !state.operatorSearchOpen };
case "toggleSidebar":
return { ...state, sidebarVisible: command.visible ?? !state.sidebarVisible };
}
}

255
web/protocol/usd-export.ts Normal file
View File

@@ -0,0 +1,255 @@
import type { DepsgraphEvaluationIR } from "./depsgraph";
import { analyzeNonMeshExport, mapBinaryNonMeshForExport, mapEvaluatedNonMeshForExport, type NonMeshExportMapping } from "./nonmesh-export";
import type { MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir";
import type { MeshGeometryBuffer } from "./web-engine";
import type { NonMeshGeometryChunk, ReassembledNonMeshAttribute, ReassembledNonMeshGeometry } from "./nonmesh-binary";
export type USDSerializationErrorCode = "USD_GEOMETRY_BUFFER_MISSING" | "USD_GEOMETRY_INVALID" | "USD_EMPTY_SCENE";
export interface USDSerializationError {
code: USDSerializationErrorCode;
message: string;
id?: string;
}
export interface USDExportReport {
schemaVersion: 1;
canExport: boolean;
meshTargets: Array<{ meshId: string; target: "UsdGeomMesh" | "UsdGeomBasisCurves" | "UsdGeomPoints"; status: "MAPPED" | "BLOCKED"; errorCode?: "SUMMARY_ONLY_MESH" }>;
nonMeshTargets: NonMeshExportMapping[];
errors?: USDSerializationError[];
}
export interface USDExportResult {
report: USDExportReport;
usda?: Uint8Array;
}
export function analyzeUSDExport(snapshot: SceneSnapshotIR, depsgraph?: DepsgraphEvaluationIR): USDExportReport {
const meshTarget = (mesh: MeshSummaryIR): "UsdGeomMesh" | "UsdGeomBasisCurves" | "UsdGeomPoints" => mesh.topology === "lines" ? "UsdGeomBasisCurves" : mesh.topology === "points" ? "UsdGeomPoints" : "UsdGeomMesh";
const meshTargets = snapshot.meshes.map((mesh) => mesh.geometryStatus === "summary-only"
? { meshId: mesh.id, target: meshTarget(mesh), status: "BLOCKED" as const, errorCode: "SUMMARY_ONLY_MESH" as const }
: { meshId: mesh.id, target: meshTarget(mesh), status: "MAPPED" as const });
const nonMesh = analyzeNonMeshExport(snapshot, depsgraph);
return {
schemaVersion: 1,
canExport: meshTargets.every((mapping) => mapping.status === "MAPPED") && nonMesh.canExportUSD,
meshTargets,
nonMeshTargets: nonMesh.mappings,
};
}
function values<T extends Float32Array | Uint32Array>(
payload: MeshGeometryBuffer | undefined,
summary: MeshSummaryIR,
key: "positions" | "indices" | "edgeVertexIndices",
): T | undefined {
const inline = summary[key];
if (inline) return (key === "positions" ? Float32Array.from(inline) : Uint32Array.from(inline)) as T;
const buffer = payload?.[key];
if (!buffer) return undefined;
return new (key === "positions" ? Float32Array : Uint32Array)(buffer) as T;
}
function usdString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll("\r", "\\r");
}
function primIdentifier(value: string, used: Set<string>): string {
const base = value.normalize("NFKD").replace(/[^A-Za-z0-9_]/g, "_").replace(/^[^A-Za-z_]/, "_$&") || "Object";
let result = base;
for (let suffix = 2; used.has(result); suffix++) result = `${base}_${suffix}`;
used.add(result);
return result;
}
function number(value: number): string {
if (!Number.isFinite(value)) throw new Error("USD geometry contains a non-finite number");
const normalized = Object.is(value, -0) ? 0 : value;
return Number.isInteger(normalized) ? String(normalized) : normalized.toPrecision(9).replace(/(?:\.0+|(?:(\.\d*?)0+))(?=e|$)/, "$1");
}
function tuples(values: ArrayLike<number>, width: number): string {
const result: string[] = [];
for (let index = 0; index < values.length; index += width) {
result.push(`(${Array.from({ length: width }, (_, component) => number(values[index + component])).join(", ")})`);
}
return `[${result.join(", ")}]`;
}
function integers(values: ArrayLike<number>): string {
return `[${Array.from(values, number).join(", ")}]`;
}
function scalars(values: ArrayLike<number>, booleanValues = false): string {
return `[${Array.from(values, (value) => booleanValues ? value ? "true" : "false" : number(value)).join(", ")}]`;
}
function primvarName(value: string): string {
return value.replace(/[^A-Za-z0-9_]/g, "_").replace(/^[^A-Za-z_]/, "_$&") || "attribute";
}
function attributeValues(attribute: ReassembledNonMeshAttribute, pointOrder?: readonly number[]): ArrayLike<number> {
if (attribute.domain !== "POINT" || !pointOrder) return attribute.values;
const result = attribute.storage === "FLOAT32" ? new Float32Array(pointOrder.length * attribute.components) :
attribute.storage === "INT32" ? new Int32Array(pointOrder.length * attribute.components) : new Uint8Array(pointOrder.length * attribute.components);
for (const [target, source] of pointOrder.entries()) for (let component = 0; component < attribute.components; component++) {
result[target * attribute.components + component] = attribute.values[source * attribute.components + component];
}
return result;
}
function primvar(attribute: ReassembledNonMeshAttribute, pointOrder?: readonly number[]): string {
const source = attributeValues(attribute, pointOrder);
const interpolation = attribute.domain === "POINT" ? "vertex" : attribute.domain === "CURVE" ? "uniform" : "constant";
const name = primvarName(attribute.name);
if (attribute.dataType === "BOOL") return ` bool[] primvars:${name} = ${scalars(source, true)} (interpolation = "${interpolation}")`;
if (attribute.dataType === "INT") return ` int[] primvars:${name} = ${integers(source)} (interpolation = "${interpolation}")`;
if (attribute.dataType === "BYTE_COLOR") {
const normalized = Float32Array.from(source, (value) => value / 255);
return ` color4f[] primvars:${name} = ${tuples(normalized, 4)} (interpolation = "${interpolation}")`;
}
const type = attribute.dataType === "FLOAT" ? "float" : attribute.dataType === "FLOAT2" ? "float2" : attribute.dataType === "FLOAT3" ? "vector3f" : "color4f";
const encoded = attribute.components === 1 ? scalars(source) : tuples(source, attribute.components);
return ` ${type}[] primvars:${name} = ${encoded} (interpolation = "${interpolation}")`;
}
function matrix(value: readonly number[]): string {
if (value.length !== 16) throw new Error("USD object matrix must contain 16 values");
return `(${[0, 4, 8, 12].map((offset) => `(${[0, 1, 2, 3].map((component) => number(value[offset + component])).join(", ")})`).join(", ")})`;
}
function edgeChains(edges: Uint32Array, vertexCount: number): number[][] {
const adjacency = Array.from({ length: vertexCount }, () => [] as Array<{ edge: number; vertex: number }>);
for (let edge = 0; edge < edges.length / 2; edge++) {
const left = edges[edge * 2];
const right = edges[edge * 2 + 1];
if (left >= vertexCount || right >= vertexCount || left === right) throw new Error("USD line geometry contains an invalid edge");
adjacency[left].push({ edge, vertex: right });
adjacency[right].push({ edge, vertex: left });
}
const visited = new Uint8Array(edges.length / 2);
const chains: number[][] = [];
const starts = Array.from({ length: vertexCount }, (_, vertex) => vertex).filter((vertex) => adjacency[vertex].length !== 2);
const walk = (start: number, firstEdge?: number): void => {
const chain = [start];
let current = start;
let edge = firstEdge ?? adjacency[current].find((candidate) => !visited[candidate.edge])?.edge;
while (edge !== undefined && !visited[edge]) {
visited[edge] = 1;
const left = edges[edge * 2];
const right = edges[edge * 2 + 1];
current = current === left ? right : left;
chain.push(current);
edge = adjacency[current].find((candidate) => !visited[candidate.edge])?.edge;
}
if (chain.length > 1) chains.push(chain);
};
for (const start of starts) for (const candidate of adjacency[start]) if (!visited[candidate.edge]) walk(start, candidate.edge);
for (let edge = 0; edge < visited.length; edge++) if (!visited[edge]) walk(edges[edge * 2], edge);
return chains;
}
function geometryBlock(
primName: string,
blenderId: string,
transform: readonly number[],
summary: MeshSummaryIR,
payload: MeshGeometryBuffer | undefined,
rich?: ReassembledNonMeshGeometry,
): string {
const positions = values<Float32Array>(payload, summary, "positions");
if (!positions || positions.length !== summary.vertexCount * 3) throw new Error(`USD geometry ${summary.id} has no valid position buffer`);
const header = ` customData = { string blenderId = "${usdString(blenderId)}" }`;
const xform = ` matrix4d xformOp:transform = ${matrix(transform)}\n uniform token[] xformOpOrder = ["xformOp:transform"]`;
if (summary.topology === "points") {
const widths = rich?.radii ? Float32Array.from(rich.radii, (radius) => radius * 2) : new Float32Array([0.01]);
const widthInterpolation = rich?.radii ? "vertex" : "constant";
const attributes = rich?.attributes.map((attribute) => primvar(attribute)).join("\n") ?? "";
return ` def Points "${primName}" (\n${header}\n ) {\n${xform}\n point3f[] points = ${tuples(positions, 3)}\n float[] widths = ${scalars(widths)} (interpolation = "${widthInterpolation}")${attributes ? `\n${attributes}` : ""}\n }`;
}
if (summary.topology === "lines") {
const edges = values<Uint32Array>(payload, summary, "edgeVertexIndices");
if (!edges || edges.length !== summary.edgeCount * 2 || edges.length === 0) throw new Error(`USD line geometry ${summary.id} has no valid edge buffer`);
const chains = rich?.curveOffsets ? Array.from({ length: rich.curveOffsets.length - 1 }, (_, curve) =>
Array.from({ length: rich.curveOffsets![curve + 1] - rich.curveOffsets![curve] }, (__, point) => rich.curveOffsets![curve] + point)) : edgeChains(edges, summary.vertexCount);
const points = new Float32Array(chains.reduce((total, chain) => total + chain.length, 0) * 3);
const pointOrder: number[] = [];
let cursor = 0;
for (const chain of chains) for (const vertex of chain) {
points.set(positions.subarray(vertex * 3, vertex * 3 + 3), cursor);
pointOrder.push(vertex);
cursor += 3;
}
const widths = rich?.radii ? Float32Array.from(pointOrder, (point) => rich.radii![point] * 2) : new Float32Array([0.01]);
const widthInterpolation = rich?.radii ? "vertex" : "constant";
const attributes = rich?.attributes.map((attribute) => primvar(attribute, pointOrder)).join("\n") ?? "";
return ` def BasisCurves "${primName}" (\n${header}\n ) {\n${xform}\n uniform token type = "linear"\n uniform token wrap = "nonperiodic"\n int[] curveVertexCounts = ${integers(chains.map((chain) => chain.length))}\n point3f[] points = ${tuples(points, 3)}\n float[] widths = ${scalars(widths)} (interpolation = "${widthInterpolation}")${attributes ? `\n${attributes}` : ""}\n }`;
}
const indices = values<Uint32Array>(payload, summary, "indices");
if (!indices || indices.length !== (summary.triangleCount ?? summary.faceCount) * 3 || indices.some((index) => index >= summary.vertexCount)) {
throw new Error(`USD mesh geometry ${summary.id} has no valid triangle buffer`);
}
const faceCounts = new Uint32Array(indices.length / 3).fill(3);
return ` def Mesh "${primName}" (\n${header}\n ) {\n${xform}\n uniform token subdivisionScheme = "none"\n uniform token orientation = "rightHanded"\n point3f[] points = ${tuples(positions, 3)}\n int[] faceVertexCounts = ${integers(faceCounts)}\n int[] faceVertexIndices = ${integers(indices)}\n }`;
}
function identity(): number[] {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
export function exportUSD(
snapshot: SceneSnapshotIR,
geometryBuffers: readonly MeshGeometryBuffer[] = [],
depsgraph?: DepsgraphEvaluationIR,
nonMeshGeometryBuffers: readonly NonMeshGeometryChunk[] = [],
): USDExportResult {
const analysis = analyzeUSDExport(snapshot, depsgraph);
if (!analysis.canExport) return { report: analysis };
const evaluated = depsgraph ? mapEvaluatedNonMeshForExport(snapshot, geometryBuffers, depsgraph) : { snapshot, geometryBuffers: [...geometryBuffers] };
let mapped: ReturnType<typeof mapBinaryNonMeshForExport>;
try {
mapped = mapBinaryNonMeshForExport(evaluated.snapshot, evaluated.geometryBuffers, nonMeshGeometryBuffers);
}
catch (error) {
return { report: { ...analysis, canExport: false, errors: [{ code: "USD_GEOMETRY_INVALID", message: error instanceof Error ? error.message : "WNM geometry is invalid" }] } };
}
const bufferById = new Map(mapped.geometryBuffers.map((buffer) => [buffer.meshId, buffer]));
const meshById = new Map(mapped.snapshot.meshes.map((mesh) => [mesh.id, mesh]));
const used = new Set<string>();
const blocks: string[] = [];
const referenced = new Set<string>();
const errors: USDSerializationError[] = [];
const append = (summary: MeshSummaryIR, node?: SceneNodeIR): void => {
const geometryId = summary.geometryBufferId ?? summary.id;
try {
blocks.push(geometryBlock(
primIdentifier(node?.name ?? summary.name, used),
node?.id ?? summary.id,
node?.worldMatrix ?? identity(),
summary,
bufferById.get(geometryId) ?? bufferById.get(summary.id),
mapped.geometryByMeshId.get(summary.id),
));
}
catch (error) {
errors.push({
code: error instanceof Error && error.message.includes("buffer") ? "USD_GEOMETRY_BUFFER_MISSING" : "USD_GEOMETRY_INVALID",
message: error instanceof Error ? error.message : `USD geometry ${summary.id} is invalid`,
id: summary.id,
});
}
};
for (const node of mapped.snapshot.nodes) {
if (!node.dataId) continue;
const summary = meshById.get(node.dataId);
if (!summary) continue;
referenced.add(summary.id);
append(summary, node);
}
for (const summary of mapped.snapshot.meshes) if (!referenced.has(summary.id)) append(summary);
if (blocks.length === 0 && errors.length === 0) errors.push({ code: "USD_EMPTY_SCENE", message: "The scene contains no serializable geometry" });
if (errors.length > 0) return { report: { ...analysis, canExport: false, errors } };
const source = `#usda 1.0\n(\n defaultPrim = "Scene"\n metersPerUnit = 1\n upAxis = "Z"\n)\n\ndef Xform "Scene" {\n${blocks.join("\n\n")}\n}\n`;
return { report: analysis, usda: new TextEncoder().encode(source) };
}

View File

@@ -0,0 +1,79 @@
import { normalizeProjectAssetPath } from "./asset-path";
import type { VolumeGridMetadataIR } from "./scene-ir";
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 interface VDBResourceManifest {
projectId: string;
sourcePath: string;
byteLength: number;
sha256: string;
grids: VolumeGridMetadataIR[];
}
export interface VDBDecodeRequest extends VDBResourceManifest {
data: ArrayBuffer;
}
export interface VDBDecodeResult {
metadata: VDBResourceManifest;
decodedByteLength: number;
}
export type VDBDecoder = (request: VDBDecodeRequest, signal: AbortSignal) => Promise<VDBDecodeResult>;
function invalid(message: string): never {
throw new Error(`NON_MESH_BINARY_INVALID: ${message}`);
}
export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest {
if (!manifest.projectId || !/^[a-zA-Z0-9._-]+$/.test(manifest.projectId)) invalid("VDB projectId is invalid");
let sourcePath: string;
try {
sourcePath = normalizeProjectAssetPath(manifest.sourcePath);
}
catch {
throw new Error("NON_MESH_RESOURCE_OUTSIDE_PROJECT: VDB 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`);
}
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 };
}

208
web/protocol/web-engine.ts Normal file
View File

@@ -0,0 +1,208 @@
import type { ErrorReport } from "./error";
import type { ProgressEvent } from "./progress";
import type { NonMeshCurveSplineType, NonMeshFontCharacterIR, NonMeshFontLinksIR, NonMeshFontPropertiesIR, NonMeshFontTextBoxIR, SceneSnapshotIR } from "./scene-ir";
import type { SceneDelta } from "./scene-delta";
import type { SimplifyProfile } from "./simplify";
import type { SimplifyResult } from "./simplify";
import type { LODGenerationRequest } from "./lod";
import type { LODManifest } from "./simplify";
import type { DepsgraphEvaluationIR } from "./depsgraph";
import type { SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
import type { GeometryNodeGraphIR } from "./geometry-nodes";
import type { ShaderGraphIR } from "./shader-graph";
import type { NlaTrackIR } from "./nla";
import type { RenderCapabilityRequest } from "./render-capabilities";
import type { CapabilityGateResult } from "./capability-gates";
import type { NonMeshGeometryChunk } from "./nonmesh-binary";
export interface MeshGeometryBuffer {
schemaVersion: 1;
meshId: string;
byteLength: number;
positions: ArrayBuffer;
indices: ArrayBuffer;
normals?: ArrayBuffer;
triangleCornerIndices?: ArrayBuffer;
uvs?: ArrayBuffer;
colors?: ArrayBuffer;
triangleMaterialIndices?: ArrayBuffer;
triangleFaceIndices?: ArrayBuffer;
edgeVertexIndices?: ArrayBuffer;
tangents?: ArrayBuffer;
splitNormals?: ArrayBuffer;
sculptMask?: ArrayBuffer;
faceSets?: ArrayBuffer;
}
export type MeshGeometryBufferField =
| "positions"
| "indices"
| "normals"
| "triangleCornerIndices"
| "uvs"
| "colors"
| "triangleMaterialIndices"
| "triangleFaceIndices"
| "edgeVertexIndices"
| "tangents"
| "splitNormals"
| "sculptMask"
| "faceSets";
export interface MeshGeometryRangePatch {
meshId: string;
field: MeshGeometryBufferField;
byteOffset: number;
data: ArrayBuffer;
}
export interface MeshGeometryDelta {
schemaVersion: 1;
patches: MeshGeometryRangePatch[];
replaced: MeshGeometryBuffer[];
removed: string[];
}
export type MeshElementMode = "VERT" | "EDGE" | "FACE";
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT";
export type FCurveInterpolation = "CONSTANT" | "LINEAR" | "BEZIER";
export type WebEngineEditCommand =
| { type: "setFrame"; frame: number }
| { type: "setObjectVisibility"; objectId: string; visible: boolean }
| { type: "setModifierEnabled"; meshId: string; modifierUuid: string; enabled: boolean }
| { type: "setModifierVisibility"; meshId: string; modifierUuid: string; showViewport: boolean; showRender: boolean; showEditMode: boolean; showOnCage: boolean }
| { type: "decimateMesh"; meshId: string; profile: SimplifyProfile }
| { type: "previewDecimateMesh"; meshId: string; profile: SimplifyProfile }
| { type: "createPrimitive"; primitive: "CUBE" | "PLANE"; name?: string; location: [number, number, number] }
| { type: "duplicateObject"; objectId: string; offset?: [number, number, number]; linked?: boolean }
| { type: "deleteObject"; objectId: string }
| { type: "setObjectTransform"; objectId: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
| { type: "translateMeshVertices"; meshId: string; vertexIndices: number[]; offset: [number, number, number] }
| { type: "meshEdit"; meshId: string; operation: MeshEditOperation; selectionMode: MeshElementMode; elementIndices: number[]; offset?: [number, number, number]; amount?: number; segments?: number }
| { type: "createUVMap"; meshId: string; name: string }
| { type: "setActiveUVMap"; meshId: string; name: string }
| { type: "unwrapUV"; meshId: string; faceIndices: number[]; method: "PLANAR" | "CUBE" }
| { type: "addMaterialSlot"; objectId: string; name?: string }
| { type: "removeMaterialSlot"; objectId: string; slotIndex: number }
| { type: "assignMaterialFaces"; meshId: string; faceIndices: number[]; slotIndex: number }
| { type: "setMaterialPrincipled"; materialId: string; baseColor: [number, number, number, number]; roughness: number; metallic: number; emissionColor?: [number, number, number, number]; alpha?: number; ior?: number; specularIORLevel?: number; transmissionWeight?: number; coatWeight?: number; coatRoughness?: number; emissionStrength?: number }
| { type: "importImage"; name: string; mimeType: "image/png" | "image/jpeg"; width: number; height: number; base64: string }
| { type: "setMaterialImageNode"; materialId: string; imageId: string; usage: "BASE_COLOR" | "NORMAL"; uvMap?: string }
| { type: "insertObjectKeyframe"; objectId: string; frame: number; property: "LOCATION" | "ROTATION_EULER" | "SCALE"; interpolation?: FCurveInterpolation }
| { type: "deleteObjectKeyframe"; objectId: string; frame: number; property?: "LOCATION" | "ROTATION_EULER" | "SCALE" }
| { type: "setFCurveInterpolation"; animationId: string; path: string; interpolation: FCurveInterpolation }
| { type: "setActiveAction"; objectId: string; actionId: string | null }
| { type: "setConstraint"; objectId: string; constraintName: string; enabled: boolean; influence: number }
| { type: "setParent"; objectId: string; parentId: string | null; keepTransform?: boolean }
| { type: "createCollection"; name: string; parentCollectionId?: string | null }
| { type: "moveObjectToCollection"; objectId: string; collectionId: string }
| { type: "renameId"; id: string; name: string }
| { type: "joinObjects"; activeObjectId: string; objectIds: string[] }
| { type: "separateMeshFaces"; objectId: string; faceIndices: number[]; name?: string }
| { type: "applyObjectTransform"; objectId: string }
| { type: "setObjectOrigin"; objectId: string; mode: "GEOMETRY" | "WORLD" }
| { type: "createCurve"; curveType: "CURVE"; name?: string; controlPoints: number[]; cyclic?: boolean; resolution?: number }
| { type: "deleteNonMeshData"; dataId: string }
| { type: "setCurveControlPoints"; dataId: string; controlPoints: number[]; splineOffsets?: number[]; resolution?: number }
| { type: "setCurveHandle"; dataId: string; pointIndex: number; side: "LEFT" | "RIGHT"; position: [number, number, number] }
| { type: "setCurveTopology"; dataId: string; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
| { type: "setCurveSplines"; dataId: string; splineTypes: NonMeshCurveSplineType[]; splineOffsets: number[]; ordersU: number[]; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; handleTypes: number[]; handlePoints: number[] }
| { type: "setSurfaceTopology"; dataId: string; splineDimensions: Array<{ u: number; v: number; orderU: number; orderV: number }>; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; cyclicV: boolean[] }
| { type: "setFontBody"; dataId: string; body: string }
| { type: "setFontProperties"; dataId: string; properties: Partial<NonMeshFontPropertiesIR> }
| { type: "setFontAdvanced"; dataId: string; characters: NonMeshFontCharacterIR[]; textBoxes: NonMeshFontTextBoxIR[]; activeTextBox: number }
| { type: "setFontLinks"; dataId: string; links: NonMeshFontLinksIR }
| { type: "createGreasePencilLayer"; dataId: string; name: string }
| { type: "removeGreasePencilLayer"; dataId: string; layerId: string }
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" }
| { type: "insertGreasePencilFrame"; dataId: string; layerId: string; frame: number; duration?: number }
| { type: "removeGreasePencilFrame"; dataId: string; layerId: string; frame: number }
| { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> }
| { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] }
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean }
| { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } }
| { type: "setWorldProperties"; dataId: string; properties: { color?: [number, number, number]; exposure?: number; mist?: { enabled?: boolean; type?: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC"; start?: number; depth?: number; intensity?: number; height?: number } } }
| { type: "setMetaballElements"; dataId: string; elements: Array<{ type: number; position: [number, number, number]; radius: number; scale: [number, number, number] }> }
| { type: "sculptStroke"; stroke: SculptStrokeIR }
| { type: "setSculptMeshAttributes"; attributes: SculptMeshAttributesIR }
| { type: "setGeometryNodeGraph"; meshId: string; graph: GeometryNodeGraphIR }
| { type: "setShaderGraph"; materialId: string; graph: ShaderGraphIR }
| { type: "setNLAStack"; objectId: string; tracks: NlaTrackIR[] }
| { type: "undo" }
| { type: "redo" };
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
| { requestId: string; command: { type: "snapshot" } }
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
| { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } }
| { requestId: string; command: { type: "delta" } }
| { requestId: string; command: { type: "requestAsset"; assetId: string } }
| { requestId: string; command: { type: "queryRenderCapability"; request: RenderCapabilityRequest } }
| { requestId: string; command: { type: "queryNonMeshCapability"; dataId: string } }
| { requestId: string; command: { type: "evaluateDepsgraph" } }
| { requestId: string; command: { type: "saveBlend" } }
| { requestId: string; command: { type: "shutdown" } };
export interface WebEngineStatus {
ready: boolean;
liveHandles: number;
allocatedBytes: number;
}
export interface AssetRequestResult {
assetId: string;
status: "external" | "packed" | "packed-unavailable" | "missing" | "blocked";
sourcePath?: string;
safeSourcePath?: string;
pathStatus?: "safe" | "blocked";
errorCode?: "ASSET_PATH_OUTSIDE_PROJECT";
mimeType?: string;
width?: number;
height?: number;
data?: ArrayBuffer;
}
export interface WebEngineLODLevelResult {
level: number;
meshId: string;
triangleBudget: number;
outputTriangleCount: number;
outputVertexCount: number;
simplify?: SimplifyResult;
geometryBuffers: MeshGeometryBuffer[];
}
export interface WebEngineLODResult {
manifest: LODManifest;
levels: WebEngineLODLevelResult[];
errors?: WebEngineLODError[];
}
export interface WebEngineLODError {
requestedLevel: number;
triangleBudget: number;
message: string;
}
export interface WebEngineResult {
status: WebEngineStatus;
snapshot?: SceneSnapshotIR;
geometryBuffers?: MeshGeometryBuffer[];
nonMeshGeometryBuffers?: NonMeshGeometryChunk[];
geometryDelta?: MeshGeometryDelta;
delta?: SceneDelta;
simplify?: SimplifyResult;
lod?: WebEngineLODResult;
asset?: AssetRequestResult;
capabilityGate?: CapabilityGateResult;
depsgraph?: DepsgraphEvaluationIR;
blend?: ArrayBuffer;
}
export type WebEngineResponse =
| { kind: "progress"; requestId: string; progress: ProgressEvent }
| { kind: "result"; requestId: string; ok: true; result: WebEngineResult }
| { kind: "result"; requestId: string; ok: false; error: ErrorReport };