Add Chromium-only Blender WebEngine parity work
This commit is contained in:
135
web/protocol/asset-library-io.ts
Normal file
135
web/protocol/asset-library-io.ts
Normal 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" };
|
||||
}
|
||||
23
web/protocol/asset-path.ts
Normal file
23
web/protocol/asset-path.ts
Normal 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
177
web/protocol/budget.ts
Normal 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 };
|
||||
}
|
||||
31
web/protocol/capability-gates.ts
Normal file
31
web/protocol/capability-gates.ts
Normal 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
368
web/protocol/compositor.ts
Normal 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
102
web/protocol/deformation.ts
Normal 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
336
web/protocol/depsgraph.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
83
web/protocol/editor-workflow.ts
Normal file
83
web/protocol/editor-workflow.ts
Normal 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
33
web/protocol/engine.ts
Normal 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
155
web/protocol/error.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
282
web/protocol/geometry-nodes.ts
Normal file
282
web/protocol/geometry-nodes.ts
Normal 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
719
web/protocol/glb-export.ts
Normal 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
200
web/protocol/glb-import.ts
Normal 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 };
|
||||
}
|
||||
237
web/protocol/grease-pencil.ts
Normal file
237
web/protocol/grease-pencil.ts
Normal 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
104
web/protocol/lod.ts
Normal 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
44
web/protocol/manifest.ts
Normal 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
138
web/protocol/mesh-cache.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
120
web/protocol/mesh-geometry-delta.ts
Normal file
120
web/protocol/mesh-geometry-delta.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
78
web/protocol/modifier-graph.ts
Normal file
78
web/protocol/modifier-graph.ts
Normal 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
27
web/protocol/modifier.ts
Normal 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
160
web/protocol/nla.ts
Normal 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
17
web/protocol/non-mesh.ts
Normal 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}`)]);
|
||||
}
|
||||
413
web/protocol/nonmesh-binary.ts
Normal file
413
web/protocol/nonmesh-binary.ts
Normal 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 };
|
||||
}
|
||||
233
web/protocol/nonmesh-export.ts
Normal file
233
web/protocol/nonmesh-export.ts
Normal 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
148
web/protocol/paint.ts
Normal 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;
|
||||
}
|
||||
|
||||
239
web/protocol/physics-simulation.ts
Normal file
239
web/protocol/physics-simulation.ts
Normal 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
11
web/protocol/progress.ts
Normal 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;
|
||||
}
|
||||
64
web/protocol/release-gate.ts
Normal file
64
web/protocol/release-gate.ts
Normal 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); }
|
||||
197
web/protocol/render-assets.ts
Normal file
197
web/protocol/render-assets.ts
Normal 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");
|
||||
}
|
||||
65
web/protocol/render-capabilities.ts
Normal file
65
web/protocol/render-capabilities.ts
Normal 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
159
web/protocol/scene-delta.ts
Normal 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
1029
web/protocol/scene-ir.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
web/protocol/schema-version
Normal file
1
web/protocol/schema-version
Normal file
@@ -0,0 +1 @@
|
||||
1
|
||||
86
web/protocol/scripting-platform.ts
Normal file
86
web/protocol/scripting-platform.ts
Normal 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
124
web/protocol/sculpt.ts
Normal 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");
|
||||
}
|
||||
63
web/protocol/selection-history.ts
Normal file
63
web/protocol/selection-history.ts
Normal 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
251
web/protocol/sequencer.ts
Normal 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`)]);
|
||||
}
|
||||
178
web/protocol/shader-graph.ts
Normal file
178
web/protocol/shader-graph.ts
Normal 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
310
web/protocol/simplify.ts
Normal 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 };
|
||||
}
|
||||
127
web/protocol/simulation-cache.ts
Normal file
127
web/protocol/simulation-cache.ts
Normal 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
62
web/protocol/skin.ts
Normal 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
218
web/protocol/storage.ts
Normal 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;
|
||||
}
|
||||
329
web/protocol/tracking-mask.ts
Normal file
329
web/protocol/tracking-mask.ts
Normal 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
129
web/protocol/ui-schema.ts
Normal 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
255
web/protocol/usd-export.ts
Normal 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) };
|
||||
}
|
||||
79
web/protocol/volume-vdb.ts
Normal file
79
web/protocol/volume-vdb.ts
Normal 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
208
web/protocol/web-engine.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user