Files
workinf_Blender_Wasm/web/protocol/scene-ir.ts
2026-08-12 17:14:27 -04:00

1140 lines
64 KiB
TypeScript

import { parseNlaTracks, type NlaTrackIR } from "./nla";
import { parseGreasePencilData, type GreasePencilDataIR } from "./grease-pencil";
import { parseCompositorGraph, type CompositorGraphIR } from "./compositor";
import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer";
import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask";
import { normalizeProjectAssetPath } from "./asset-path";
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
import { parsePhysicsSimulationManifest, type PhysicsSimulationManifestIR } from "./physics-simulation";
export type SceneNodeType =
| "EMPTY"
| "MESH"
| "CURVE"
| "SURFACE"
| "FONT"
| "METABALL"
| "LIGHT"
| "CAMERA"
| "SPEAKER"
| "LIGHT_PROBE"
| "LATTICE"
| "ARMATURE"
| "CURVES"
| "POINT_CLOUD"
| "VOLUME"
| "GREASE_PENCIL"
| "UNKNOWN";
export interface SceneTransformIR {
translation: [number, number, number];
rotationEuler: [number, number, number];
scale: [number, number, number];
rotationMode: number;
}
export interface SceneNodeIR {
id: string;
name: string;
type: SceneNodeType;
parentId: string | null;
dataId: string | null;
visible: boolean;
selectable: boolean;
localMatrix: number[];
worldMatrix: number[];
transform: SceneTransformIR;
constraints?: ConstraintIR[];
}
export interface ConstraintIR {
name: string;
typeCode: number;
type: string;
enabled: boolean;
influence: number;
targetObjectId?: string | null;
}
export interface ModifierIR {
uuid: string;
type: string;
name: string;
enabled: boolean;
showViewport: boolean;
showRender: boolean;
showEditMode?: boolean;
showOnCage?: boolean;
parameters: Record<string, unknown>;
dependsOn?: string[];
evaluationStatus?: "EVALUATED" | "METADATA_ONLY" | "BLOCKED";
}
export interface SkinWeightsIR {
boneNames: string[];
indices: number[];
weights: number[];
bindMatrix: number[];
armatureId?: string;
jointIds?: string[];
}
export interface ArmatureBoneIR {
id: string;
name: string;
parentId: string | null;
head: [number, number, number];
tail: [number, number, number];
restMatrix: number[];
poseMatrix?: number[];
}
export interface ArmatureIR {
id: string;
name: string;
objectId?: string;
bones: ArmatureBoneIR[];
}
export interface ShapeKeyIR {
name: string;
positions: number[];
relativeTo?: string | null;
value?: number;
}
export interface VertexGroupIR {
name: string;
index: number;
}
export interface MeshAttributeIR {
name: string;
domain: "POINT" | "EDGE" | "FACE" | "CORNER";
dataType: string;
}
export interface UVLayerIR {
name: string;
active: boolean;
activeRender: boolean;
}
export interface MeshSummaryIR {
id: string;
name: string;
vertexCount: number;
edgeCount: number;
faceCount: number;
cornerCount: number;
triangleCount?: number;
geometryStatus: "summary-only" | "available" | "binary";
geometryBufferId?: string;
topology?: "triangles" | "lines" | "points";
positions?: number[];
indices?: number[];
normals?: number[];
triangleCornerIndices?: number[];
uvs?: number[];
colors?: number[];
triangleMaterialIndices?: number[];
triangleFaceIndices?: number[];
edgeVertexIndices?: number[];
tangents?: number[];
splitNormals?: number[];
seamEdges?: number[];
sharpEdges?: number[];
materialSlotIds?: string[];
bounds?: { min: [number, number, number]; max: [number, number, number] };
modifierStack?: ModifierIR[];
skinWeights?: SkinWeightsIR;
shapeKeys?: ShapeKeyIR[];
vertexGroups?: VertexGroupIR[];
attributes?: MeshAttributeIR[];
uvLayers?: UVLayerIR[];
activeUVMap?: string | null;
sculptMask?: number[];
faceSets?: number[];
activeFaceSet?: number;
sculptRevision?: number;
}
export interface MaterialNodeIR {
id: string;
type: "RGB" | "VALUE" | "MATH" | "PRINCIPLED" | "IMAGE_TEXTURE" | "NORMAL_MAP" | "OUTPUT" | "UNSUPPORTED";
name: string;
imageId?: string | null;
defaultValue?: number[];
properties?: { operation?: "ADD" | "SUBTRACT" | "MULTIPLY" | "DIVIDE" | "MINIMUM" | "MAXIMUM" };
}
export interface MaterialLinkIR {
fromNodeId: string;
fromSocket: string;
toNodeId: string;
toSocket: string;
}
export interface MaterialIR {
id: string;
name: 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;
normalImageId?: string | null;
imageIds?: string[];
warnings?: string[];
nodes?: MaterialNodeIR[];
links?: MaterialLinkIR[];
}
export interface CameraIR {
id: string;
name: string;
projection: "PERSPECTIVE" | "ORTHOGRAPHIC" | "PANORAMIC" | "CUSTOM";
lensMm: number;
sensorWidthMm: number;
sensorHeightMm: number;
sensorFit: number;
shift: [number, number];
near: number;
far: number;
orthoScale: number;
panoramaType?: number;
fisheyeFov?: number;
depthOfField?: {
enabled: boolean;
focusObjectId?: string | null;
focusDistance: number;
apertureFStop: number;
apertureBlades: number;
apertureRotation: number;
apertureRatio: number;
};
}
export interface LightIR {
id: string;
name: string;
lightType: number;
color: [number, number, number];
energy: number;
radius: number;
spotAngle: number;
spotBlend: number;
areaShape: number;
areaSize: number;
areaSizeY: number;
sunAngle: number;
exposure?: number;
temperature?: number;
useTemperature?: boolean;
castsShadow?: boolean;
areaSpread?: number;
}
export interface WorldIR {
id: string;
name: string;
color: [number, number, number];
exposure: number;
environmentImageId?: string | null;
environmentStrength?: number;
environmentRotation?: number;
backgroundVisible?: boolean;
mist?: {
enabled: boolean;
type: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC";
start: number;
depth: number;
intensity: number;
height: number;
};
}
export interface ImageIR {
id: string;
name: string;
assetId: string;
mimeType?: string;
width?: number;
height?: number;
sha256?: string;
sourcePath?: string;
packed?: boolean;
packedByteLength?: number;
sourceKind?: "FILE" | "SEQUENCE" | "MOVIE" | "GENERATED" | "VIEWER" | "TILED" | "UNKNOWN";
assetStatus?: "PACKED" | "GENERATED" | "EXTERNAL" | "UDIM_EXTERNAL" | "LINKED_LIBRARY_REQUIRED" | "MISSING" | "CORRUPT";
errorCode?: "PACKED_IMAGE_SIGNATURE_INVALID" | "LINKED_IMAGE_LIBRARY_REQUIRED";
libraryLinked?: boolean;
libraryPath?: string;
tiles?: Array<{
number: number;
label: string;
width: number;
height: number;
generatedType: number;
generatedColor?: number[];
sourcePath: string;
assetId: string;
mimeType: string;
packed: boolean;
packedByteLength?: number;
validSignature?: boolean;
}>;
}
export type NonMeshDataType = "CURVE" | "SURFACE" | "FONT" | "METABALL" | "POINT_CLOUD" | "CURVES" | "VOLUME" | "HAIR";
export type NonMeshAttributeDomain = "POINT" | "CURVE" | "INSTANCE";
export type NonMeshAttributeDataType = "BOOL" | "INT" | "FLOAT" | "FLOAT2" | "FLOAT3" | "BYTE_COLOR" | "FLOAT_COLOR";
export interface NonMeshAttributeIR {
name: string;
domain: NonMeshAttributeDomain;
dataType: NonMeshAttributeDataType;
components: 1 | 2 | 3 | 4;
}
export interface EvaluatedNonMeshGeometryIR {
objectId: string;
meshId: string;
vertexCount: number;
edgeCount?: number;
triangleCount: number;
status: "EVALUATED" | "BLOCKED";
errorCode?: "NON_MESH_DATA_BUDGET_EXCEEDED";
materialSlotIds?: string[];
sourceElementIndices?: number[];
}
export interface VolumeGridMetadataIR {
name: string;
valueType: string;
voxelCount: number;
activeVoxelCount?: number;
bounds?: { min: [number, number, number]; max: [number, number, number] };
}
export type NonMeshCurveSplineType = "POLY" | "BEZIER" | "NURBS";
export interface NonMeshFontPropertiesIR {
alignment: "LEFT" | "CENTER" | "RIGHT" | "JUSTIFY" | "FLUSH";
alignY: "TOP_BASELINE" | "TOP" | "CENTER" | "BOTTOM_BASELINE" | "BOTTOM";
extrude: number;
bevelDepth: number;
bevelResolution: number;
offset: number;
spacing?: number;
lineDistance?: number;
wordSpace?: number;
shear?: number;
fontSize?: number;
offsetX?: number;
offsetY?: number;
}
export interface NonMeshFontCharacterIR {
kern: number;
materialIndex: number;
styleFlags: number;
}
export interface NonMeshFontTextBoxIR {
x: number;
y: number;
width: number;
height: number;
}
export interface NonMeshFontLinksIR {
regular: string;
bold: string;
italic: string;
boldItalic: string;
}
export interface VFontResourceIR {
id: string;
name: string;
sourcePath: string;
builtin: boolean;
packed: boolean;
}
export interface NonMeshDataIR {
id: string;
name: string;
type: NonMeshDataType;
geometryStatus: "summary-only" | "available" | "binary" | "blocked";
pointCount: number;
splineCount: number;
geometryBufferId?: string;
controlPoints?: number[];
radii?: number[];
splineOffsets?: number[];
splineDimensions?: Array<{ u: number; v: number; orderU: number; orderV: number }>;
pointWeights?: number[];
splineTypes?: NonMeshCurveSplineType[];
cyclicU?: boolean[];
cyclicV?: boolean[];
handleTypes?: number[];
handlePoints?: number[];
handlePointIndices?: number[];
attributes?: NonMeshAttributeIR[];
attributeValues?: Array<NonMeshAttributeIR & { values: number[] }>;
text?: string;
fontProperties?: NonMeshFontPropertiesIR;
fontCharacters?: NonMeshFontCharacterIR[];
fontTextBoxes?: NonMeshFontTextBoxIR[];
activeFontTextBox?: number;
fontLinks?: NonMeshFontLinksIR;
resolution?: number;
elements?: Array<{ type: number; position: [number, number, number]; radius: number; scale: [number, number, number] }>;
evaluatedGeometry?: EvaluatedNonMeshGeometryIR[];
sourcePath?: string;
resourceKind?: "OPENVDB";
resourceByteLength?: number;
volumeGrids?: VolumeGridMetadataIR[];
errorCode?: "NON_MESH_DATA_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_RESOURCE_MISSING" | "NON_MESH_BINARY_INVALID" | "NON_MESH_RESOURCE_OUTSIDE_PROJECT" | "NON_MESH_VDB_BUDGET_EXCEEDED";
}
export interface AnimationIR {
id: string;
name: string;
targetId: string;
frameStart: number;
frameEnd: number;
channels: Array<{ path: string; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
}
export interface CollectionIR {
id: string;
name: string;
objectIds: string[];
childCollectionIds: string[];
}
export interface SceneIR {
id: string;
name: string;
rootCollectionId?: string | null;
cameraObjectId?: string | null;
worldId?: string | null;
renderEngine?: string;
colorManagement?: {
displayDevice: string;
viewTransform: string;
look: string;
exposure: number;
gamma: number;
temperature?: number;
tint?: number;
whiteBalanceStatus?: "AVAILABLE" | "BLOCKED";
};
compositorGraph?: CompositorGraphIR;
compositorStatus?: "AVAILABLE" | "BLOCKED";
sequencerTimeline?: SequencerTimelineIR;
sequencerStatus?: "AVAILABLE" | "BLOCKED";
}
export interface SceneSnapshotIR {
schemaVersion: 1;
revision: number;
sceneId: string;
source: {
kind: "blend" | "mock";
fileVersion?: number;
fileFormatVersion?: number;
pointerSize?: number;
endianness?: "little" | "big";
compression?: "none" | "gzip" | "zstd";
};
coordinateSystem: {
upAxis: "Z";
forwardAxis: "-Y";
handedness: "RIGHT";
unitSystem: number;
unitScale: number;
};
nodes: SceneNodeIR[];
meshes: MeshSummaryIR[];
materials: MaterialIR[];
cameras: CameraIR[];
lights: LightIR[];
worlds: WorldIR[];
images: ImageIR[];
nonMeshData?: NonMeshDataIR[];
vfonts?: VFontResourceIR[];
greasePencils?: GreasePencilDataIR[];
trackingMasks?: TrackingMaskProjectIR;
trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
libraryStatus?: "AVAILABLE" | "BLOCKED";
editorWorkflow?: EditorWorkflowIR;
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
scriptSources?: ScriptSourceInventoryIR;
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
physicsSimulation?: PhysicsSimulationManifestIR;
libraries?: Array<{
id: string;
name: string;
sourcePath: string;
packed: boolean;
packedByteLength?: number;
status: "PACKED" | "EXTERNAL_REQUIRED";
errorCode?: "LINKED_LIBRARY_RESOURCE_REQUIRED";
dependencyIds: string[];
readOnly: true;
}>;
animations: AnimationIR[];
nlaTracks?: NlaTrackIR[];
armatures?: ArmatureIR[];
collections: CollectionIR[];
scenes: SceneIR[];
activeObjectId: string | null;
frame: { current: number; start: number; end: number };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireArray(value: unknown, field: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`SceneIR.${field} must be an array`);
return value;
}
function requireString(value: unknown, field: string): string {
if (typeof value !== "string") throw new Error(`SceneIR.${field} must be a string`);
return value;
}
function requireNumber(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`SceneIR.${field} must be a finite number`);
}
return value;
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new Error(`SceneIR.${field} must be a boolean`);
return value;
}
function requireTuple(value: unknown, length: number, field: string): void {
if (!Array.isArray(value) || value.length !== length || value.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
throw new Error(`SceneIR.${field} must contain ${length} finite numbers`);
}
}
export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
if (!isRecord(value)) throw new Error("SceneIR snapshot must be an object");
if (value.schemaVersion !== 1) throw new Error(`Unsupported SceneIR schema: ${String(value.schemaVersion)}`);
requireNumber(value.revision, "revision");
requireString(value.sceneId, "sceneId");
const nodes = requireArray(value.nodes, "nodes");
const meshes = requireArray(value.meshes, "meshes");
requireArray(value.materials, "materials");
requireArray(value.cameras, "cameras");
requireArray(value.lights, "lights");
requireArray(value.worlds, "worlds");
requireArray(value.images, "images");
if (value.nonMeshData !== undefined) requireArray(value.nonMeshData, "nonMeshData");
if (value.greasePencils !== undefined) requireArray(value.greasePencils, "greasePencils");
if (value.libraries !== undefined) requireArray(value.libraries, "libraries");
requireArray(value.animations, "animations");
if (value.armatures !== undefined) requireArray(value.armatures, "armatures");
requireArray(value.collections, "collections");
requireArray(value.scenes, "scenes");
if (!isRecord(value.source)) throw new Error("SceneIR.source must be an object");
if (!isRecord(value.coordinateSystem)) throw new Error("SceneIR.coordinateSystem must be an object");
if (!isRecord(value.frame)) throw new Error("SceneIR.frame must be an object");
if (value.source.kind !== "blend" && value.source.kind !== "mock") throw new Error("SceneIR.source.kind is invalid");
if (value.coordinateSystem.upAxis !== "Z" || value.coordinateSystem.forwardAxis !== "-Y" || value.coordinateSystem.handedness !== "RIGHT") {
throw new Error("SceneIR.coordinateSystem is unsupported");
}
requireNumber(value.coordinateSystem.unitSystem, "coordinateSystem.unitSystem");
requireNumber(value.coordinateSystem.unitScale, "coordinateSystem.unitScale");
requireNumber(value.frame.current, "frame.current");
requireNumber(value.frame.start, "frame.start");
requireNumber(value.frame.end, "frame.end");
for (const [index, node] of nodes.entries()) {
if (!isRecord(node)) throw new Error(`SceneIR.nodes[${index}] must be an object`);
requireString(node.id, `nodes[${index}].id`);
requireString(node.name, `nodes[${index}].name`);
requireBoolean(node.visible, `nodes[${index}].visible`);
requireBoolean(node.selectable, `nodes[${index}].selectable`);
if (!Array.isArray(node.localMatrix) || node.localMatrix.length !== 16) {
throw new Error(`SceneIR.nodes[${index}].localMatrix must contain 16 numbers`);
}
requireTuple(node.localMatrix, 16, `nodes[${index}].localMatrix`);
requireTuple(node.worldMatrix, 16, `nodes[${index}].worldMatrix`);
if (!isRecord(node.transform)) throw new Error(`SceneIR.nodes[${index}].transform must be an object`);
requireTuple(node.transform.translation, 3, `nodes[${index}].transform.translation`);
requireTuple(node.transform.rotationEuler, 3, `nodes[${index}].transform.rotationEuler`);
requireTuple(node.transform.scale, 3, `nodes[${index}].transform.scale`);
requireNumber(node.transform.rotationMode, `nodes[${index}].transform.rotationMode`);
}
for (const [index, mesh] of meshes.entries()) {
if (!isRecord(mesh)) throw new Error(`SceneIR.meshes[${index}] must be an object`);
requireString(mesh.id, `meshes[${index}].id`);
const vertexCount = requireNumber(mesh.vertexCount, `meshes[${index}].vertexCount`);
const edgeCount = requireNumber(mesh.edgeCount, `meshes[${index}].edgeCount`);
requireNumber(mesh.faceCount, `meshes[${index}].faceCount`);
const cornerCount = requireNumber(mesh.cornerCount, `meshes[${index}].cornerCount`);
if (mesh.positions !== undefined) requireTuple(mesh.positions, vertexCount * 3, `meshes[${index}].positions`);
if (mesh.normals !== undefined) requireTuple(mesh.normals, vertexCount * 3, `meshes[${index}].normals`);
if (mesh.uvs !== undefined) requireTuple(mesh.uvs, cornerCount * 2, `meshes[${index}].uvs`);
if (mesh.colors !== undefined) requireTuple(mesh.colors, cornerCount * 4, `meshes[${index}].colors`);
if (mesh.sculptMask !== undefined) {
if (!Array.isArray(mesh.sculptMask) || mesh.sculptMask.length !== vertexCount || mesh.sculptMask.some((item) => typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 1)) {
throw new Error(`SceneIR.meshes[${index}].sculptMask must match vertexCount and contain values in [0,1]`);
}
}
if (mesh.faceSets !== undefined) {
if (!Array.isArray(mesh.faceSets) || mesh.faceSets.length !== mesh.faceCount || mesh.faceSets.some((item) => typeof item !== "number" || !Number.isSafeInteger(item) || item < 0)) {
throw new Error(`SceneIR.meshes[${index}].faceSets must match faceCount and contain non-negative integers`);
}
}
if (mesh.activeFaceSet !== undefined && (typeof mesh.activeFaceSet !== "number" || !Number.isSafeInteger(mesh.activeFaceSet) || mesh.activeFaceSet < -1)) throw new Error(`SceneIR.meshes[${index}].activeFaceSet is invalid`);
if (mesh.sculptRevision !== undefined && (typeof mesh.sculptRevision !== "number" || !Number.isSafeInteger(mesh.sculptRevision) || mesh.sculptRevision < 0)) throw new Error(`SceneIR.meshes[${index}].sculptRevision is invalid`);
if (mesh.indices !== undefined) {
if (!Array.isArray(mesh.indices) || mesh.indices.some((item) => !Number.isInteger(item) || item < 0)) {
throw new Error(`SceneIR.meshes[${index}].indices must contain non-negative integers`);
}
if (mesh.indices.length % 3 !== 0) throw new Error(`SceneIR.meshes[${index}].indices must contain triangles`);
}
if (mesh.edgeVertexIndices !== undefined) {
if (!Array.isArray(mesh.edgeVertexIndices) || mesh.edgeVertexIndices.some((item) => !Number.isInteger(item) || item < 0 || item >= vertexCount)) {
throw new Error(`SceneIR.meshes[${index}].edgeVertexIndices must contain valid vertex indices`);
}
if (mesh.edgeVertexIndices.length !== edgeCount * 2) throw new Error(`SceneIR.meshes[${index}].edgeVertexIndices length must match edgeCount`);
}
if (mesh.triangleCornerIndices !== undefined) {
if (!Array.isArray(mesh.triangleCornerIndices) || mesh.triangleCornerIndices.some((item) => !Number.isInteger(item) || item < 0)) {
throw new Error(`SceneIR.meshes[${index}].triangleCornerIndices must contain non-negative integers`);
}
if (mesh.indices !== undefined && mesh.triangleCornerIndices.length !== mesh.indices.length) {
throw new Error(`SceneIR.meshes[${index}].triangleCornerIndices length must match index count`);
}
}
if (mesh.triangleMaterialIndices !== undefined) {
if (!Array.isArray(mesh.triangleMaterialIndices) || mesh.triangleMaterialIndices.some((item) => !Number.isInteger(item) || item < 0)) {
throw new Error(`SceneIR.meshes[${index}].triangleMaterialIndices must contain non-negative integers`);
}
if (mesh.indices !== undefined && mesh.triangleMaterialIndices.length !== mesh.indices.length / 3) {
throw new Error(`SceneIR.meshes[${index}].triangleMaterialIndices length must match triangle count`);
}
}
if (mesh.materialSlotIds !== undefined && (!Array.isArray(mesh.materialSlotIds) || mesh.materialSlotIds.some((item) => typeof item !== "string"))) {
throw new Error(`SceneIR.meshes[${index}].materialSlotIds must contain strings`);
}
if (mesh.bounds !== undefined) {
if (!isRecord(mesh.bounds)) throw new Error(`SceneIR.meshes[${index}].bounds must be an object`);
requireTuple(mesh.bounds.min, 3, `meshes[${index}].bounds.min`);
requireTuple(mesh.bounds.max, 3, `meshes[${index}].bounds.max`);
}
if (mesh.modifierStack !== undefined) {
if (!Array.isArray(mesh.modifierStack)) throw new Error(`SceneIR.meshes[${index}].modifierStack must be an array`);
for (const [modifierIndex, modifier] of mesh.modifierStack.entries()) {
if (!isRecord(modifier)) throw new Error(`SceneIR.meshes[${index}].modifierStack[${modifierIndex}] must be an object`);
requireString(modifier.uuid, `meshes[${index}].modifierStack[${modifierIndex}].uuid`);
requireString(modifier.type, `meshes[${index}].modifierStack[${modifierIndex}].type`);
requireString(modifier.name, `meshes[${index}].modifierStack[${modifierIndex}].name`);
requireBoolean(modifier.enabled, `meshes[${index}].modifierStack[${modifierIndex}].enabled`);
requireBoolean(modifier.showViewport, `meshes[${index}].modifierStack[${modifierIndex}].showViewport`);
requireBoolean(modifier.showRender, `meshes[${index}].modifierStack[${modifierIndex}].showRender`);
if (!isRecord(modifier.parameters)) throw new Error(`SceneIR.meshes[${index}].modifierStack[${modifierIndex}].parameters must be an object`);
if (modifier.dependsOn !== undefined && (!Array.isArray(modifier.dependsOn) || modifier.dependsOn.some((item) => typeof item !== "string"))) {
throw new Error(`SceneIR.meshes[${index}].modifierStack[${modifierIndex}].dependsOn must contain strings`);
}
if (modifier.evaluationStatus !== undefined && !["EVALUATED", "METADATA_ONLY", "BLOCKED"].includes(modifier.evaluationStatus as string)) {
throw new Error(`SceneIR.meshes[${index}].modifierStack[${modifierIndex}].evaluationStatus is invalid`);
}
}
}
if (mesh.skinWeights !== undefined) {
if (!isRecord(mesh.skinWeights)) throw new Error(`SceneIR.meshes[${index}].skinWeights must be an object`);
const skin = mesh.skinWeights;
const boneNames = requireArray(skin.boneNames, `meshes[${index}].skinWeights.boneNames`);
if (boneNames.some((item) => typeof item !== "string")) throw new Error(`SceneIR.meshes[${index}].skinWeights.boneNames must contain strings`);
const indices = requireArray(skin.indices, `meshes[${index}].skinWeights.indices`);
const weights = requireArray(skin.weights, `meshes[${index}].skinWeights.weights`);
if (indices.length !== weights.length || indices.length !== vertexCount * 4) throw new Error(`SceneIR.meshes[${index}].skinWeights arrays must contain vertexCount * 4 entries`);
if (indices.some((item) => typeof item !== "number" || !Number.isInteger(item) || item < 0 || item >= boneNames.length)) throw new Error(`SceneIR.meshes[${index}].skinWeights.indices are invalid`);
if (weights.some((item) => typeof item !== "number" || !Number.isFinite(item) || item < 0)) throw new Error(`SceneIR.meshes[${index}].skinWeights.weights are invalid`);
requireTuple(skin.bindMatrix, 16, `meshes[${index}].skinWeights.bindMatrix`);
if (skin.armatureId !== undefined) requireString(skin.armatureId, `meshes[${index}].skinWeights.armatureId`);
if (skin.jointIds !== undefined) {
if (!Array.isArray(skin.jointIds) || skin.jointIds.some((item) => typeof item !== "string")) {
throw new Error(`meshes[${index}].skinWeights.jointIds must contain strings`);
}
if (skin.jointIds.length !== boneNames.length) throw new Error(`meshes[${index}].skinWeights.jointIds must match boneNames`);
}
}
if (mesh.shapeKeys !== undefined) {
if (!Array.isArray(mesh.shapeKeys)) throw new Error(`SceneIR.meshes[${index}].shapeKeys must be an array`);
for (const [shapeIndex, shape] of mesh.shapeKeys.entries()) {
if (!isRecord(shape)) throw new Error(`SceneIR.meshes[${index}].shapeKeys[${shapeIndex}] must be an object`);
requireString(shape.name, `meshes[${index}].shapeKeys[${shapeIndex}].name`);
requireTuple(shape.positions, vertexCount * 3, `meshes[${index}].shapeKeys[${shapeIndex}].positions`);
if (shape.relativeTo !== undefined && shape.relativeTo !== null) requireString(shape.relativeTo, `meshes[${index}].shapeKeys[${shapeIndex}].relativeTo`);
if (shape.value !== undefined) requireNumber(shape.value, `meshes[${index}].shapeKeys[${shapeIndex}].value`);
}
}
if (mesh.geometryStatus === "available" && (!mesh.positions || !mesh.indices || !["triangles", "lines", "points"].includes(mesh.topology as string))) {
throw new Error(`SceneIR.meshes[${index}] available geometry is incomplete`);
}
if (mesh.topology === "lines" && mesh.edgeVertexIndices === undefined && mesh.geometryStatus === "available") {
throw new Error(`SceneIR.meshes[${index}] line geometry is missing edgeVertexIndices`);
}
if (mesh.topology === "lines" && mesh.geometryStatus === "binary" && edgeCount === 0) {
throw new Error(`SceneIR.meshes[${index}] line geometry must contain edges`);
}
if (mesh.geometryStatus === "binary" && (typeof mesh.geometryBufferId !== "string" || !["triangles", "lines", "points"].includes(mesh.topology as string))) {
throw new Error(`SceneIR.meshes[${index}] binary geometry is incomplete`);
}
}
for (const [index, armature] of ((value.armatures ?? []) as unknown[]).entries()) {
if (!isRecord(armature)) throw new Error(`SceneIR.armatures[${index}] must be an object`);
requireString(armature.id, `armatures[${index}].id`);
requireString(armature.name, `armatures[${index}].name`);
if (armature.objectId !== undefined) requireString(armature.objectId, `armatures[${index}].objectId`);
const bones = requireArray(armature.bones, `armatures[${index}].bones`);
for (const [boneIndex, bone] of bones.entries()) {
if (!isRecord(bone)) throw new Error(`SceneIR.armatures[${index}].bones[${boneIndex}] must be an object`);
requireString(bone.id, `armatures[${index}].bones[${boneIndex}].id`);
requireString(bone.name, `armatures[${index}].bones[${boneIndex}].name`);
if (bone.parentId !== null) requireString(bone.parentId, `armatures[${index}].bones[${boneIndex}].parentId`);
requireTuple(bone.head, 3, `armatures[${index}].bones[${boneIndex}].head`);
requireTuple(bone.tail, 3, `armatures[${index}].bones[${boneIndex}].tail`);
requireTuple(bone.restMatrix, 16, `armatures[${index}].bones[${boneIndex}].restMatrix`);
if (bone.poseMatrix !== undefined) requireTuple(bone.poseMatrix, 16, `armatures[${index}].bones[${boneIndex}].poseMatrix`);
}
}
for (const [index, image] of (value.images as unknown[]).entries()) {
if (!isRecord(image)) throw new Error(`SceneIR.images[${index}] must be an object`);
requireString(image.id, `images[${index}].id`);
requireString(image.name, `images[${index}].name`);
requireString(image.assetId, `images[${index}].assetId`);
if (image.sourcePath !== undefined) requireString(image.sourcePath, `images[${index}].sourcePath`);
if (image.packed !== undefined) requireBoolean(image.packed, `images[${index}].packed`);
if (image.packedByteLength !== undefined) requireNumber(image.packedByteLength, `images[${index}].packedByteLength`);
if (image.sourceKind !== undefined) requireString(image.sourceKind, `images[${index}].sourceKind`);
if (image.assetStatus !== undefined) requireString(image.assetStatus, `images[${index}].assetStatus`);
if (image.libraryLinked !== undefined) requireBoolean(image.libraryLinked, `images[${index}].libraryLinked`);
if (image.libraryPath !== undefined) requireString(image.libraryPath, `images[${index}].libraryPath`);
if (image.tiles !== undefined && !Array.isArray(image.tiles)) throw new Error(`images[${index}].tiles must be an array`);
}
const nonMeshIds = new Set<string>();
const vfontIds = new Set<string>();
if (value.vfonts !== undefined) {
if (!Array.isArray(value.vfonts) || value.vfonts.length > 4096) throw new Error("SceneIR.vfonts is invalid");
for (const [index, font] of value.vfonts.entries()) {
if (!isRecord(font)) throw new Error(`SceneIR.vfonts[${index}] is invalid`);
const id = requireString(font.id, `vfonts[${index}].id`);
if (!id.startsWith("vfont:") || vfontIds.has(id)) throw new Error(`SceneIR.vfonts[${index}].id is invalid`);
vfontIds.add(id);
requireString(font.name, `vfonts[${index}].name`);
requireString(font.sourcePath, `vfonts[${index}].sourcePath`);
requireBoolean(font.builtin, `vfonts[${index}].builtin`);
requireBoolean(font.packed, `vfonts[${index}].packed`);
}
}
for (const [index, data] of ((value.nonMeshData ?? []) as unknown[]).entries()) {
if (!isRecord(data)) throw new Error(`SceneIR.nonMeshData[${index}] must be an object`);
const dataId = requireString(data.id, `nonMeshData[${index}].id`);
if (nonMeshIds.has(dataId)) throw new Error(`SceneIR.nonMeshData contains duplicate id: ${dataId}`);
nonMeshIds.add(dataId);
requireString(data.name, `nonMeshData[${index}].name`);
if (!["CURVE", "SURFACE", "FONT", "METABALL", "POINT_CLOUD", "CURVES", "VOLUME", "HAIR"].includes(data.type as string)) throw new Error(`SceneIR.nonMeshData[${index}].type is invalid`);
if (!["summary-only", "available", "binary", "blocked"].includes(data.geometryStatus as string)) throw new Error(`SceneIR.nonMeshData[${index}].geometryStatus is invalid`);
const pointCount = requireNumber(data.pointCount, `nonMeshData[${index}].pointCount`);
const splineCount = requireNumber(data.splineCount, `nonMeshData[${index}].splineCount`);
if (!Number.isSafeInteger(pointCount) || pointCount < 0 || (pointCount > 1_000_000 && (data.geometryStatus !== "blocked" || data.errorCode !== "NON_MESH_DATA_BUDGET_EXCEEDED"))) throw new Error(`SceneIR.nonMeshData[${index}].pointCount exceeds the 1M point budget without a blocked gate`);
if (!Number.isSafeInteger(splineCount) || splineCount < 0 || splineCount > 1_000_000) throw new Error(`SceneIR.nonMeshData[${index}].splineCount is invalid`);
if (data.geometryStatus === "binary" && (typeof data.geometryBufferId !== "string" || data.geometryBufferId.length === 0)) throw new Error(`SceneIR.nonMeshData[${index}] binary geometry is incomplete`);
if (data.controlPoints !== undefined) {
if (!Array.isArray(data.controlPoints) || data.controlPoints.some((item) => typeof item !== "number" || !Number.isFinite(item)) || data.controlPoints.length % 3 !== 0) throw new Error(`SceneIR.nonMeshData[${index}].controlPoints is invalid`);
if (data.geometryStatus !== "available") throw new Error(`SceneIR.nonMeshData[${index}].controlPoints requires available geometry`);
if (data.controlPoints.length !== pointCount * 3) throw new Error(`SceneIR.nonMeshData[${index}].controlPoints does not match pointCount`);
}
if (data.radii !== undefined && (!Array.isArray(data.radii) || data.radii.length !== pointCount || data.radii.some((item) => typeof item !== "number" || !Number.isFinite(item) || item < 0))) throw new Error(`SceneIR.nonMeshData[${index}].radii is invalid`);
if (data.splineOffsets !== undefined) {
if (!Array.isArray(data.splineOffsets) || data.splineOffsets.length !== splineCount + 1 || data.splineOffsets.some((item) => typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) || data.splineOffsets[0] !== 0 || data.splineOffsets.at(-1) !== pointCount) throw new Error(`SceneIR.nonMeshData[${index}].splineOffsets is invalid`);
for (let offsetIndex = 1; offsetIndex < data.splineOffsets.length; offsetIndex++) {
if (data.splineOffsets[offsetIndex] <= data.splineOffsets[offsetIndex - 1]) throw new Error(`SceneIR.nonMeshData[${index}].splineOffsets must be strictly increasing`);
}
}
if (data.splineDimensions !== undefined) {
if (!Array.isArray(data.splineDimensions) || data.splineDimensions.length !== splineCount) throw new Error(`SceneIR.nonMeshData[${index}].splineDimensions is invalid`);
let dimensionPoints = 0;
for (const [dimensionIndex, dimension] of data.splineDimensions.entries()) {
if (!isRecord(dimension)) throw new Error(`SceneIR.nonMeshData[${index}].splineDimensions[${dimensionIndex}] is invalid`);
for (const field of ["u", "v", "orderU", "orderV"] as const) if (!Number.isSafeInteger(dimension[field]) || (dimension[field] as number) < (field === "u" || field === "v" ? 1 : 0) || (dimension[field] as number) > 1_000_000) throw new Error(`SceneIR.nonMeshData[${index}].splineDimensions[${dimensionIndex}].${field} is invalid`);
if ((dimension.orderU as number) > (dimension.u as number) || (dimension.orderV as number) > (dimension.v as number)) throw new Error(`SceneIR.nonMeshData[${index}].splineDimensions[${dimensionIndex}] order exceeds its control grid`);
dimensionPoints += (dimension.u as number) * (dimension.v as number);
}
if (!Number.isSafeInteger(dimensionPoints) || dimensionPoints !== pointCount) throw new Error(`SceneIR.nonMeshData[${index}].splineDimensions does not cover pointCount`);
}
if (data.pointWeights !== undefined && (!Array.isArray(data.pointWeights) || data.pointWeights.length !== pointCount || data.pointWeights.some((weight) => typeof weight !== "number" || !Number.isFinite(weight) || weight <= 0 || weight > 1000000))) throw new Error(`SceneIR.nonMeshData[${index}].pointWeights is invalid`);
if (data.splineTypes !== undefined && (!Array.isArray(data.splineTypes) || data.splineTypes.length !== splineCount || data.splineTypes.some((item) => !["POLY", "BEZIER", "NURBS"].includes(item as string)))) throw new Error(`SceneIR.nonMeshData[${index}].splineTypes is invalid`);
for (const field of ["cyclicU", "cyclicV"] as const) {
if (data[field] !== undefined && (!Array.isArray(data[field]) || data[field].length !== splineCount || data[field].some((item) => typeof item !== "boolean"))) throw new Error(`SceneIR.nonMeshData[${index}].${field} is invalid`);
}
if (data.handleTypes !== undefined && (!Array.isArray(data.handleTypes) || data.handleTypes.length > pointCount * 2 || data.handleTypes.length % 2 !== 0 || data.handleTypes.some((item) => !Number.isSafeInteger(item) || item < 0 || item > 5))) throw new Error(`SceneIR.nonMeshData[${index}].handleTypes is invalid`);
if (data.handlePoints !== undefined && (!Array.isArray(data.handlePoints) || data.handlePoints.length > pointCount * 6 || data.handlePoints.length % 6 !== 0 || data.handlePoints.some((item) => typeof item !== "number" || !Number.isFinite(item)))) throw new Error(`SceneIR.nonMeshData[${index}].handlePoints is invalid`);
if (data.handlePointIndices !== undefined && (!Array.isArray(data.handlePointIndices) || !Array.isArray(data.handlePoints) || data.handlePointIndices.length !== data.handlePoints.length / 6 || data.handlePointIndices.some((item) => !Number.isSafeInteger(item) || item < 0 || item >= pointCount) || new Set(data.handlePointIndices).size !== data.handlePointIndices.length)) throw new Error(`SceneIR.nonMeshData[${index}].handlePointIndices is invalid`);
const parseAttribute = (attribute: unknown, attributeIndex: number, requireValues: boolean): void => {
if (!isRecord(attribute)) throw new Error(`SceneIR.nonMeshData[${index}].attributes[${attributeIndex}] must be an object`);
requireString(attribute.name, `nonMeshData[${index}].attributes[${attributeIndex}].name`);
if (!["POINT", "CURVE", "INSTANCE"].includes(attribute.domain as string)) throw new Error(`SceneIR.nonMeshData[${index}].attributes[${attributeIndex}].domain is invalid`);
if (!["BOOL", "INT", "FLOAT", "FLOAT2", "FLOAT3", "BYTE_COLOR", "FLOAT_COLOR"].includes(attribute.dataType as string)) throw new Error(`SceneIR.nonMeshData[${index}].attributes[${attributeIndex}].dataType is invalid`);
if (![1, 2, 3, 4].includes(attribute.components as number)) throw new Error(`SceneIR.nonMeshData[${index}].attributes[${attributeIndex}].components is invalid`);
if (requireValues) {
const domainCount = attribute.domain === "POINT" ? pointCount : attribute.domain === "CURVE" ? splineCount : 1;
if (!Array.isArray(attribute.values) || attribute.values.length !== domainCount * (attribute.components as number) || attribute.values.some((item) => typeof item !== "number" || !Number.isFinite(item))) throw new Error(`SceneIR.nonMeshData[${index}].attributeValues[${attributeIndex}].values is invalid`);
}
};
if (data.attributes !== undefined) {
if (!Array.isArray(data.attributes)) throw new Error(`SceneIR.nonMeshData[${index}].attributes must be an array`);
data.attributes.forEach((attribute, attributeIndex) => parseAttribute(attribute, attributeIndex, false));
}
if (data.attributeValues !== undefined) {
if (!Array.isArray(data.attributeValues) || data.geometryStatus !== "available") throw new Error(`SceneIR.nonMeshData[${index}].attributeValues requires available geometry`);
data.attributeValues.forEach((attribute, attributeIndex) => parseAttribute(attribute, attributeIndex, true));
}
if (data.text !== undefined) requireString(data.text, `nonMeshData[${index}].text`);
if (data.fontProperties !== undefined) {
if (!isRecord(data.fontProperties) || data.type !== "FONT") throw new Error(`SceneIR.nonMeshData[${index}].fontProperties is invalid`);
if (!["LEFT", "CENTER", "RIGHT", "JUSTIFY", "FLUSH"].includes(data.fontProperties.alignment as string)) throw new Error(`SceneIR.nonMeshData[${index}].fontProperties.alignment is invalid`);
if (!["TOP_BASELINE", "TOP", "CENTER", "BOTTOM_BASELINE", "BOTTOM"].includes(data.fontProperties.alignY as string)) throw new Error(`SceneIR.nonMeshData[${index}].fontProperties.alignY is invalid`);
for (const field of ["extrude", "bevelDepth", "offset"] as const) {
const property = requireNumber(data.fontProperties[field], `nonMeshData[${index}].fontProperties.${field}`);
if (property < 0 || property > 1000) throw new Error(`SceneIR.nonMeshData[${index}].fontProperties.${field} is outside the bounded range`);
}
const bevelResolution = requireNumber(data.fontProperties.bevelResolution, `nonMeshData[${index}].fontProperties.bevelResolution`);
if (!Number.isSafeInteger(bevelResolution) || bevelResolution < 0 || bevelResolution > 64) throw new Error(`SceneIR.nonMeshData[${index}].fontProperties.bevelResolution is invalid`);
const boundedFontFields: Array<[keyof NonMeshFontPropertiesIR, number, number]> = [["spacing", 0, 10], ["lineDistance", 0, 10], ["wordSpace", 0, 100], ["shear", -10, 10], ["fontSize", 0.001, 1000], ["offsetX", -100000, 100000], ["offsetY", -100000, 100000]];
for (const [field, minimum, maximum] of boundedFontFields) if (data.fontProperties[field] !== undefined) {
const property = requireNumber(data.fontProperties[field], `nonMeshData[${index}].fontProperties.${field}`);
if (property < minimum || property > maximum) throw new Error(`SceneIR.nonMeshData[${index}].fontProperties.${field} is outside the bounded range`);
}
}
if (data.fontCharacters !== undefined) {
if (data.type !== "FONT" || !Array.isArray(data.fontCharacters) || typeof data.text !== "string" || data.fontCharacters.length !== Array.from(data.text).length) throw new Error(`SceneIR.nonMeshData[${index}].fontCharacters must match the Font code-point count`);
for (const [characterIndex, character] of data.fontCharacters.entries()) {
if (!isRecord(character)) throw new Error(`SceneIR.nonMeshData[${index}].fontCharacters[${characterIndex}] is invalid`);
const kern = requireNumber(character.kern, `nonMeshData[${index}].fontCharacters[${characterIndex}].kern`);
const materialIndex = requireNumber(character.materialIndex, `nonMeshData[${index}].fontCharacters[${characterIndex}].materialIndex`);
const styleFlags = requireNumber(character.styleFlags, `nonMeshData[${index}].fontCharacters[${characterIndex}].styleFlags`);
if (kern < -1000 || kern > 1000 || !Number.isSafeInteger(materialIndex) || materialIndex < 0 || materialIndex > 32767 || !Number.isSafeInteger(styleFlags) || styleFlags < 0 || (styleFlags & ~0x17) !== 0) throw new Error(`SceneIR.nonMeshData[${index}].fontCharacters[${characterIndex}] is outside the bounded range`);
}
}
if (data.fontTextBoxes !== undefined) {
if (data.type !== "FONT" || !Array.isArray(data.fontTextBoxes) || data.fontTextBoxes.length === 0 || data.fontTextBoxes.length > 256) throw new Error(`SceneIR.nonMeshData[${index}].fontTextBoxes is invalid`);
for (const [boxIndex, box] of data.fontTextBoxes.entries()) {
if (!isRecord(box)) throw new Error(`SceneIR.nonMeshData[${index}].fontTextBoxes[${boxIndex}] is invalid`);
for (const field of ["x", "y", "width", "height"] as const) {
const value = requireNumber(box[field], `nonMeshData[${index}].fontTextBoxes[${boxIndex}].${field}`);
if (value < (field === "width" || field === "height" ? 0 : -100000) || value > 100000) throw new Error(`SceneIR.nonMeshData[${index}].fontTextBoxes[${boxIndex}].${field} is outside the bounded range`);
}
}
if (!Number.isSafeInteger(data.activeFontTextBox) || (data.activeFontTextBox as number) < 0 || (data.activeFontTextBox as number) >= data.fontTextBoxes.length) throw new Error(`SceneIR.nonMeshData[${index}].activeFontTextBox is invalid`);
}
if (data.fontLinks !== undefined) {
if (data.type !== "FONT" || !isRecord(data.fontLinks)) throw new Error(`SceneIR.nonMeshData[${index}].fontLinks is invalid`);
for (const field of ["regular", "bold", "italic", "boldItalic"] as const) {
const id = requireString(data.fontLinks[field], `nonMeshData[${index}].fontLinks.${field}`);
if (!vfontIds.has(id)) throw new Error(`SceneIR.nonMeshData[${index}].fontLinks.${field} references a missing VFont`);
}
}
if (data.resolution !== undefined) requireNumber(data.resolution, `nonMeshData[${index}].resolution`);
if (data.sourcePath !== undefined) requireString(data.sourcePath, `nonMeshData[${index}].sourcePath`);
if (data.resourceKind !== undefined && data.resourceKind !== "OPENVDB") throw new Error(`SceneIR.nonMeshData[${index}].resourceKind is invalid`);
if (data.resourceByteLength !== undefined && (!Number.isSafeInteger(data.resourceByteLength) || (data.resourceByteLength as number) < 0)) throw new Error(`SceneIR.nonMeshData[${index}].resourceByteLength is invalid`);
if (data.errorCode !== undefined) requireString(data.errorCode, `nonMeshData[${index}].errorCode`);
if (data.elements !== undefined) {
if (!Array.isArray(data.elements)) throw new Error(`SceneIR.nonMeshData[${index}].elements must be an array`);
for (const [elementIndex, element] of data.elements.entries()) {
if (!isRecord(element)) throw new Error(`SceneIR.nonMeshData[${index}].elements[${elementIndex}] must be an object`);
requireNumber(element.type, `nonMeshData[${index}].elements[${elementIndex}].type`);
requireTuple(element.position, 3, `nonMeshData[${index}].elements[${elementIndex}].position`);
requireNumber(element.radius, `nonMeshData[${index}].elements[${elementIndex}].radius`);
requireTuple(element.scale, 3, `nonMeshData[${index}].elements[${elementIndex}].scale`);
}
}
if (data.evaluatedGeometry !== undefined) {
if (!Array.isArray(data.evaluatedGeometry)) throw new Error(`SceneIR.nonMeshData[${index}].evaluatedGeometry must be an array`);
for (const [evaluationIndex, evaluation] of data.evaluatedGeometry.entries()) {
if (!isRecord(evaluation)) throw new Error(`SceneIR.nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}] must be an object`);
requireString(evaluation.objectId, `nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].objectId`);
requireString(evaluation.meshId, `nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].meshId`);
if (evaluation.status !== "EVALUATED" && evaluation.status !== "BLOCKED") throw new Error(`SceneIR.nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].status is invalid`);
if (evaluation.errorCode !== undefined && evaluation.errorCode !== "NON_MESH_DATA_BUDGET_EXCEEDED") throw new Error(`SceneIR.nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].errorCode is invalid`);
for (const field of ["vertexCount", "triangleCount"] as const) {
const count = requireNumber(evaluation[field], `nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].${field}`);
if (!Number.isSafeInteger(count) || count < 0) throw new Error(`SceneIR.nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].${field} is invalid`);
}
if (evaluation.edgeCount !== undefined && (!Number.isSafeInteger(evaluation.edgeCount) || (evaluation.edgeCount as number) < 0)) throw new Error(`nonMeshData[${index}].evaluatedGeometry[${evaluationIndex}].edgeCount is invalid`);
}
}
if (data.volumeGrids !== undefined) {
if (!Array.isArray(data.volumeGrids)) throw new Error(`SceneIR.nonMeshData[${index}].volumeGrids must be an array`);
for (const [gridIndex, grid] of data.volumeGrids.entries()) {
if (!isRecord(grid)) throw new Error(`SceneIR.nonMeshData[${index}].volumeGrids[${gridIndex}] must be an object`);
requireString(grid.name, `nonMeshData[${index}].volumeGrids[${gridIndex}].name`);
requireString(grid.valueType, `nonMeshData[${index}].volumeGrids[${gridIndex}].valueType`);
const voxelCount = requireNumber(grid.voxelCount, `nonMeshData[${index}].volumeGrids[${gridIndex}].voxelCount`);
if (!Number.isSafeInteger(voxelCount) || voxelCount < 0) throw new Error(`SceneIR.nonMeshData[${index}].volumeGrids[${gridIndex}].voxelCount is invalid`);
if (grid.bounds !== undefined) {
if (!isRecord(grid.bounds)) throw new Error(`SceneIR.nonMeshData[${index}].volumeGrids[${gridIndex}].bounds is invalid`);
requireTuple(grid.bounds.min, 3, `nonMeshData[${index}].volumeGrids[${gridIndex}].bounds.min`);
requireTuple(grid.bounds.max, 3, `nonMeshData[${index}].volumeGrids[${gridIndex}].bounds.max`);
}
}
}
}
for (const [index, greasePencil] of ((value.greasePencils ?? []) as unknown[]).entries()) {
parseGreasePencilData(greasePencil, `SceneIR.greasePencils[${index}]`);
}
for (const [index, camera] of (value.cameras as unknown[]).entries()) {
if (!isRecord(camera)) throw new Error(`SceneIR.cameras[${index}] must be an object`);
requireString(camera.id, `cameras[${index}].id`);
requireString(camera.name, `cameras[${index}].name`);
if (!["PERSPECTIVE", "ORTHOGRAPHIC", "PANORAMIC", "CUSTOM"].includes(camera.projection as string)) {
throw new Error(`SceneIR.cameras[${index}].projection is invalid`);
}
for (const field of ["lensMm", "sensorWidthMm", "sensorHeightMm", "sensorFit", "near", "far", "orthoScale"]) {
requireNumber(camera[field], `cameras[${index}].${field}`);
}
requireTuple(camera.shift, 2, `cameras[${index}].shift`);
if (camera.panoramaType !== undefined) requireNumber(camera.panoramaType, `cameras[${index}].panoramaType`);
if (camera.fisheyeFov !== undefined) requireNumber(camera.fisheyeFov, `cameras[${index}].fisheyeFov`);
if (camera.depthOfField !== undefined) {
if (!isRecord(camera.depthOfField)) throw new Error(`SceneIR.cameras[${index}].depthOfField must be an object`);
requireBoolean(camera.depthOfField.enabled, `cameras[${index}].depthOfField.enabled`);
if (camera.depthOfField.focusObjectId !== undefined && camera.depthOfField.focusObjectId !== null) requireString(camera.depthOfField.focusObjectId, `cameras[${index}].depthOfField.focusObjectId`);
for (const field of ["focusDistance", "apertureFStop", "apertureBlades", "apertureRotation", "apertureRatio"] as const) requireNumber(camera.depthOfField[field], `cameras[${index}].depthOfField.${field}`);
}
}
for (const [index, material] of (value.materials as unknown[]).entries()) {
if (!isRecord(material)) throw new Error(`SceneIR.materials[${index}] must be an object`);
requireString(material.id, `materials[${index}].id`);
requireString(material.name, `materials[${index}].name`);
requireTuple(material.baseColor, 4, `materials[${index}].baseColor`);
requireTuple(material.emissionColor, 4, `materials[${index}].emissionColor`);
for (const field of ["roughness", "metallic", "alpha", "ior"]) {
requireNumber(material[field], `materials[${index}].${field}`);
}
for (const field of ["specularIORLevel", "transmissionWeight", "coatWeight", "coatRoughness", "emissionStrength"]) {
if (material[field] !== undefined) requireNumber(material[field], `materials[${index}].${field}`);
}
for (const field of ["specularIORLevel", "transmissionWeight", "coatWeight", "coatRoughness"]) {
if (typeof material[field] === "number" && (material[field] < 0 || material[field] > 1)) {
throw new Error(`SceneIR.materials[${index}].${field} must be in [0,1]`);
}
}
if (typeof material.emissionStrength === "number" && (material.emissionStrength < 0 || material.emissionStrength > 1_000_000)) {
throw new Error(`SceneIR.materials[${index}].emissionStrength is outside the supported range`);
}
if (material.normalImageId !== undefined && material.normalImageId !== null) requireString(material.normalImageId, `materials[${index}].normalImageId`);
if (material.imageIds !== undefined && (!Array.isArray(material.imageIds) || material.imageIds.some((item) => typeof item !== "string"))) {
throw new Error(`SceneIR.materials[${index}].imageIds must contain strings`);
}
if (material.warnings !== undefined && (!Array.isArray(material.warnings) || material.warnings.some((item) => typeof item !== "string"))) {
throw new Error(`SceneIR.materials[${index}].warnings must contain strings`);
}
if (material.nodes !== undefined) {
if (!Array.isArray(material.nodes)) throw new Error(`SceneIR.materials[${index}].nodes must be an array`);
for (const [nodeIndex, node] of material.nodes.entries()) {
if (!isRecord(node)) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}] must be an object`);
requireString(node.id, `SceneIR.materials[${index}].nodes[${nodeIndex}].id`);
requireString(node.name, `SceneIR.materials[${index}].nodes[${nodeIndex}].name`);
if (!["RGB", "VALUE", "MATH", "PRINCIPLED", "IMAGE_TEXTURE", "NORMAL_MAP", "OUTPUT", "UNSUPPORTED"].includes(node.type as string)) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}].type is invalid`);
if (node.imageId !== undefined && node.imageId !== null) requireString(node.imageId, `SceneIR.materials[${index}].nodes[${nodeIndex}].imageId`);
if (node.defaultValue !== undefined && (!Array.isArray(node.defaultValue) || node.defaultValue.length === 0 || node.defaultValue.length > 4 || node.defaultValue.some((value) => typeof value !== "number" || !Number.isFinite(value)))) throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}].defaultValue is invalid`);
if (node.properties !== undefined) {
if (!isRecord(node.properties) || Object.keys(node.properties).some((key) => key !== "operation") ||
(node.properties.operation !== undefined && !["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"].includes(node.properties.operation as string))) {
throw new Error(`SceneIR.materials[${index}].nodes[${nodeIndex}].properties is invalid`);
}
}
}
}
if (material.links !== undefined) {
if (!Array.isArray(material.links)) throw new Error(`SceneIR.materials[${index}].links must be an array`);
for (const [linkIndex, link] of material.links.entries()) {
if (!isRecord(link)) throw new Error(`SceneIR.materials[${index}].links[${linkIndex}] must be an object`);
for (const field of ["fromNodeId", "fromSocket", "toNodeId", "toSocket"] as const) requireString(link[field], `SceneIR.materials[${index}].links[${linkIndex}].${field}`);
}
}
}
for (const [index, light] of (value.lights as unknown[]).entries()) {
if (!isRecord(light)) throw new Error(`SceneIR.lights[${index}] must be an object`);
requireString(light.id, `lights[${index}].id`);
requireString(light.name, `lights[${index}].name`);
requireNumber(light.lightType, `lights[${index}].lightType`);
requireTuple(light.color, 3, `lights[${index}].color`);
for (const field of ["energy", "radius", "spotAngle", "spotBlend", "areaShape", "areaSize", "areaSizeY", "sunAngle"]) {
requireNumber(light[field], `lights[${index}].${field}`);
}
for (const field of ["exposure", "temperature", "areaSpread"] as const) if (light[field] !== undefined) requireNumber(light[field], `lights[${index}].${field}`);
for (const field of ["useTemperature", "castsShadow"] as const) if (light[field] !== undefined) requireBoolean(light[field], `lights[${index}].${field}`);
}
for (const [index, world] of (value.worlds as unknown[]).entries()) {
if (!isRecord(world)) throw new Error(`SceneIR.worlds[${index}] must be an object`);
requireString(world.id, `worlds[${index}].id`);
requireString(world.name, `worlds[${index}].name`);
requireTuple(world.color, 3, `worlds[${index}].color`);
requireNumber(world.exposure, `worlds[${index}].exposure`);
if (world.environmentImageId !== undefined && world.environmentImageId !== null) requireString(world.environmentImageId, `worlds[${index}].environmentImageId`);
if (world.environmentStrength !== undefined) requireNumber(world.environmentStrength, `worlds[${index}].environmentStrength`);
if (world.environmentRotation !== undefined) requireNumber(world.environmentRotation, `worlds[${index}].environmentRotation`);
if (world.backgroundVisible !== undefined && typeof world.backgroundVisible !== "boolean") throw new Error(`SceneIR.worlds[${index}].backgroundVisible must be a boolean`);
if (world.mist !== undefined) {
if (!isRecord(world.mist) || !["QUADRATIC", "LINEAR", "INVERSE_QUADRATIC"].includes(world.mist.type as string)) throw new Error(`SceneIR.worlds[${index}].mist is invalid`);
requireBoolean(world.mist.enabled, `worlds[${index}].mist.enabled`);
for (const field of ["start", "depth", "intensity", "height"] as const) requireNumber(world.mist[field], `worlds[${index}].mist.${field}`);
}
}
for (const [index, scene] of (value.scenes as unknown[]).entries()) {
if (!isRecord(scene)) throw new Error(`SceneIR.scenes[${index}] must be an object`);
requireString(scene.id, `scenes[${index}].id`);
requireString(scene.name, `scenes[${index}].name`);
for (const field of ["rootCollectionId", "cameraObjectId", "worldId"] as const) {
if (scene[field] !== undefined && scene[field] !== null) requireString(scene[field], `scenes[${index}].${field}`);
}
if (scene.renderEngine !== undefined) requireString(scene.renderEngine, `scenes[${index}].renderEngine`);
if (scene.colorManagement !== undefined) {
if (!isRecord(scene.colorManagement)) throw new Error(`SceneIR.scenes[${index}].colorManagement must be an object`);
for (const field of ["displayDevice", "viewTransform", "look"] as const) requireString(scene.colorManagement[field], `scenes[${index}].colorManagement.${field}`);
for (const field of ["exposure", "gamma"] as const) requireNumber(scene.colorManagement[field], `scenes[${index}].colorManagement.${field}`);
for (const field of ["temperature", "tint"] as const) if (scene.colorManagement[field] !== undefined) requireNumber(scene.colorManagement[field], `scenes[${index}].colorManagement.${field}`);
if (scene.colorManagement.whiteBalanceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.colorManagement.whiteBalanceStatus as string)) throw new Error(`scenes[${index}].colorManagement.whiteBalanceStatus is invalid`);
}
if (scene.compositorStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.compositorStatus as string)) {
throw new Error(`scenes[${index}].compositorStatus is invalid`);
}
if (scene.compositorGraph !== undefined) {
parseCompositorGraph(scene.compositorGraph);
if (scene.compositorStatus !== "AVAILABLE") throw new Error(`scenes[${index}].compositorStatus must be AVAILABLE when a graph is present`);
}
if (scene.sequencerStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.sequencerStatus as string)) {
throw new Error(`scenes[${index}].sequencerStatus is invalid`);
}
if (scene.sequencerTimeline !== undefined) {
parseSequencerTimeline(scene.sequencerTimeline);
if (scene.sequencerStatus !== "AVAILABLE") throw new Error(`scenes[${index}].sequencerStatus must be AVAILABLE when a timeline is present`);
}
}
for (const [index, animation] of (value.animations as unknown[]).entries()) {
if (!isRecord(animation)) throw new Error(`SceneIR.animations[${index}] must be an object`);
requireString(animation.id, `animations[${index}].id`);
requireString(animation.name, `animations[${index}].name`);
requireString(animation.targetId, `animations[${index}].targetId`);
requireNumber(animation.frameStart, `animations[${index}].frameStart`);
requireNumber(animation.frameEnd, `animations[${index}].frameEnd`);
const channels = requireArray(animation.channels, `animations[${index}].channels`);
for (const [channelIndex, channel] of channels.entries()) {
if (!isRecord(channel)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}] must be an object`);
requireString(channel.path, `animations[${index}].channels[${channelIndex}].path`);
const keyframes = requireArray(channel.keyframes, `animations[${index}].channels[${channelIndex}].keyframes`);
for (const [keyframeIndex, keyframe] of keyframes.entries()) {
if (!isRecord(keyframe)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}] must be an object`);
requireNumber(keyframe.frame, `animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}].frame`);
const values = requireArray(keyframe.value, `animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}].value`);
if (values.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}].value must contain finite numbers`);
}
}
}
}
if (value.nlaTracks !== undefined) parseNlaTracks(value.nlaTracks);
if (value.trackingMaskStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.trackingMaskStatus as string)) {
throw new Error("SceneIR.trackingMaskStatus is invalid");
}
if (value.trackingMasks !== undefined) {
parseTrackingMaskProject(value.trackingMasks);
if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present");
}
if (value.libraryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.libraryStatus as string)) {
throw new Error("SceneIR.libraryStatus is invalid");
}
if (value.libraries !== undefined) {
const libraries = value.libraries as unknown[];
if (libraries.length > 0 && value.libraryStatus !== "AVAILABLE") throw new Error("SceneIR.libraryStatus must be AVAILABLE when libraries are present");
if (libraries.length > 1024) throw new Error("SceneIR.libraries exceeds the budget");
const libraryIds = new Set<string>();
for (const [index, library] of libraries.entries()) {
if (!isRecord(library)) throw new Error(`SceneIR.libraries[${index}] must be an object`);
const id = requireString(library.id, `libraries[${index}].id`);
if (!id || id.length > 256 || libraryIds.has(id)) throw new Error(`SceneIR.libraries[${index}].id is invalid`);
libraryIds.add(id);
requireString(library.name, `libraries[${index}].name`);
const sourcePath = requireString(library.sourcePath, `libraries[${index}].sourcePath`);
if (typeof library.packed !== "boolean" || library.readOnly !== true || !Array.isArray(library.dependencyIds) ||
library.dependencyIds.length > 1024 || library.dependencyIds.some((dependency) => typeof dependency !== "string") ||
new Set(library.dependencyIds).size !== library.dependencyIds.length ||
!["PACKED", "EXTERNAL_REQUIRED"].includes(library.status as string)) {
throw new Error(`SceneIR.libraries[${index}] is invalid`);
}
let projectPath = true;
try { normalizeProjectAssetPath(sourcePath); } catch { projectPath = false; }
if (!projectPath) {
throw new Error(`SceneIR.libraries[${index}].sourcePath is outside the project`);
}
if (library.status === "PACKED" && (!library.packed || !Number.isSafeInteger(library.packedByteLength) || (library.packedByteLength as number) <= 0)) {
throw new Error(`SceneIR.libraries[${index}] packed payload is invalid`);
}
if (library.status === "EXTERNAL_REQUIRED" && (library.packed || library.errorCode !== "LINKED_LIBRARY_RESOURCE_REQUIRED")) {
throw new Error(`SceneIR.libraries[${index}] external resource state is invalid`);
}
}
for (const [index, library] of libraries.entries()) {
const dependencies = (library as Record<string, unknown>).dependencyIds as string[];
if (dependencies.some((dependency) => !libraryIds.has(dependency))) throw new Error(`SceneIR.libraries[${index}] references a missing dependency`);
}
const byId = new Map(libraries.map((library) => {
const record = library as Record<string, unknown>;
return [record.id as string, record.dependencyIds as string[]] as const;
}));
const active = new Set<string>();
const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new Error(`SceneIR.libraries dependency cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
for (const dependency of byId.get(id) ?? []) visit(dependency);
active.delete(id);
complete.add(id);
};
byId.forEach((_dependencies, id) => visit(id));
}
if (value.editorWorkflowStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.editorWorkflowStatus as string)) {
throw new Error("SceneIR.editorWorkflowStatus is invalid");
}
if (value.editorWorkflow !== undefined) {
parseEditorWorkflow(value.editorWorkflow);
if (value.editorWorkflowStatus !== "AVAILABLE") throw new Error("SceneIR.editorWorkflowStatus must be AVAILABLE when editorWorkflow is present");
}
if (value.scriptSourceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptSourceStatus as string)) {
throw new Error("SceneIR.scriptSourceStatus is invalid");
}
if (value.scriptSources !== undefined) {
parseScriptSourceInventory(value.scriptSources);
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
}
if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation);
return value as unknown as SceneSnapshotIR;
}