2001 lines
119 KiB
TypeScript
2001 lines
119 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";
|
|
import { GEOMETRY_NODE_GRAPH_BUDGET, parseGeometryNodeGraph, type GeometryNodeGraphIR } from "./geometry-nodes";
|
|
|
|
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;
|
|
assetData?: AssetMetadataIR;
|
|
constraints?: ConstraintIR[];
|
|
lightgroup?: string | null;
|
|
}
|
|
|
|
export interface AssetMetadataIR {
|
|
marked: true;
|
|
author: string;
|
|
description: string;
|
|
copyright: string;
|
|
license: string;
|
|
catalogSimpleName: string;
|
|
activeTag: number;
|
|
preferredImportMethod: number;
|
|
tags: string[];
|
|
}
|
|
|
|
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[];
|
|
/** SHA-256 of the serialized bounded shader graph when a node tree was read. */
|
|
shaderGraphHash?: 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;
|
|
lightgroup?: string | null;
|
|
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;
|
|
colorSpace?: "SRGB" | "NON_COLOR" | "LINEAR";
|
|
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;
|
|
packedByteLength?: number;
|
|
sha256?: string;
|
|
}
|
|
|
|
export interface SoundResourceIR {
|
|
id: string;
|
|
name: string;
|
|
sourcePath: string;
|
|
packed: boolean;
|
|
packedByteLength?: number;
|
|
volume: number;
|
|
pitch: number;
|
|
audioChannels: number;
|
|
sampleRate: number;
|
|
}
|
|
|
|
export interface SpeakerResourceIR {
|
|
id: string;
|
|
name: string;
|
|
soundId?: string;
|
|
volumeMax: number;
|
|
volumeMin: number;
|
|
distanceMax: number;
|
|
distanceReference: number;
|
|
attenuation: number;
|
|
coneAngleOuter: number;
|
|
coneAngleInner: number;
|
|
coneVolumeOuter: number;
|
|
volume: number;
|
|
pitch: number;
|
|
}
|
|
|
|
export interface TextResourceIR {
|
|
id: string;
|
|
name: string;
|
|
source: string;
|
|
sourceSha256: string;
|
|
byteLength: number;
|
|
lineCount: number;
|
|
internal: boolean;
|
|
sourcePath?: string;
|
|
isDirty: boolean;
|
|
useModule: boolean;
|
|
}
|
|
|
|
export interface TextureResourceIR {
|
|
id: string;
|
|
name: string;
|
|
type: number;
|
|
noiseScale: number;
|
|
noiseDepth: number;
|
|
intensity: number;
|
|
contrast: number;
|
|
saturation: number;
|
|
}
|
|
|
|
export interface WindowManagerResourceIR {
|
|
id: string;
|
|
name: string;
|
|
presetName: string;
|
|
windowCount: number;
|
|
interfaceLocked: boolean;
|
|
}
|
|
|
|
export interface WorkspaceResourceIR { id: string; name: string; screenCount: number }
|
|
|
|
export interface NonMeshVolumePropertiesIR {
|
|
displayDensity: number;
|
|
interpolation: "NEAREST" | "LINEAR";
|
|
stepSize: number;
|
|
velocityGrid: string;
|
|
velocityScale: number;
|
|
}
|
|
|
|
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[];
|
|
volumeProperties?: NonMeshVolumePropertiesIR;
|
|
errorCode?: "NON_MESH_DATA_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_RESOURCE_MISSING" | "NON_MESH_BINARY_INVALID" | "NON_MESH_RESOURCE_OUTSIDE_PROJECT" | "NON_MESH_VDB_BUDGET_EXCEEDED";
|
|
}
|
|
|
|
export interface AnimationIR {
|
|
id: string;
|
|
name: string;
|
|
targetId: string;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; 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";
|
|
};
|
|
renderSettings?: {
|
|
resolutionX: number;
|
|
resolutionY: number;
|
|
resolutionPercentage: number;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
outputPath: string;
|
|
};
|
|
audioSettings?: {
|
|
volume: number;
|
|
speedOfSound: number;
|
|
dopplerFactor: number;
|
|
distanceModel: number;
|
|
muted: boolean;
|
|
sync: boolean;
|
|
};
|
|
unitSettings?: {
|
|
system: number;
|
|
scaleLength: number;
|
|
rotationSystem: number;
|
|
lengthUnit: number;
|
|
massUnit: number;
|
|
timeUnit: number;
|
|
temperatureUnit: number;
|
|
};
|
|
timelineSettings?: {
|
|
fps: number;
|
|
fpsBase: number;
|
|
frameStep: number;
|
|
usePreviewRange: boolean;
|
|
previewStart: number;
|
|
previewEnd: number;
|
|
};
|
|
pipelineSettings?: {
|
|
useCompositor: boolean;
|
|
useSequencer: boolean;
|
|
noOverwrite: boolean;
|
|
saveOutput: boolean;
|
|
alphaPremultiplied: boolean;
|
|
};
|
|
viewLayers?: Array<{ name: string; useRender: boolean; useFreestyle: boolean; samples: number; passMask?: number; cryptomatteMask?: number; cryptomatteLevels?: number; alphaThreshold?: number; aovs?: Array<{ name: string; type: number }>; lightgroups?: string[] }>;
|
|
borderSettings?: { enabled: boolean; minX: number; maxX: number; minY: number; maxY: number };
|
|
simplifySettings?: { subsurfViewport: number; subsurfRender: number; particlesViewport: number; particlesRender: number; volumes: number };
|
|
imageFormatSettings?: { fileType: string; colorMode: number; depth: number; quality: number; compression: number; exrCodec: number };
|
|
motionBlurSettings?: { enabled: boolean; shutter: number; position: number };
|
|
hairSettings?: { type: number; subdivisions: number };
|
|
compositorSettings?: { device: number; precision: number; denoiseDevice: number; previewQuality: number; finalQuality: number };
|
|
performanceSettings?: { fixedThreads: boolean; threads: number; persistentData: boolean; highQualityNormals: boolean };
|
|
samplingSettings?: { pixelAspectX: number; pixelAspectY: number; filterSize: number; dither: number };
|
|
multiviewSettings?: { enabled: boolean; viewsFormat: number; imageViewsFormat: number; stereoDisplayMode: number; interlaceSwap: boolean; sideBySideCrossed: boolean; squeezedFrame: boolean };
|
|
stampSettings?: { stampMask: number; fontId: number; userText: string; foreground: number[]; background: number[] };
|
|
lineSettings?: { mode: number; thickness: number };
|
|
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[];
|
|
brushes?: BrushIR[];
|
|
lineStyles?: FreestyleLineStyleIR[];
|
|
lattices?: LatticeIR[];
|
|
nonMeshData?: NonMeshDataIR[];
|
|
vfonts?: VFontResourceIR[];
|
|
greasePencils?: GreasePencilDataIR[];
|
|
trackingMasks?: TrackingMaskProjectIR;
|
|
trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
|
|
libraryStatus?: "AVAILABLE" | "BLOCKED";
|
|
editorWorkflow?: EditorWorkflowIR;
|
|
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
|
scriptSources?: ScriptSourceInventoryIR;
|
|
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
|
renderRoutingStatus?: "AVAILABLE" | "BLOCKED";
|
|
renderRouting?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:render_capability_routing";
|
|
source: { sha256: string; label?: string };
|
|
settings: Record<string, unknown> & { sha256: string };
|
|
build: { version: string; buildSha256: string };
|
|
routes: Array<{ name: string; target: string; status: "READY" | "BLOCKED"; [key: string]: unknown }>;
|
|
recovery: { cancellation: "OPEN_CANCELLED"; deviceLoss: "RELEASED"; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
};
|
|
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED";
|
|
serverJobBinding?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:server_job_result_binding";
|
|
source: { sha256: string; [key: string]: unknown };
|
|
settings: { settingsSha256: string; [key: string]: unknown };
|
|
build: { version: string; buildSha256: string };
|
|
request: { requestSha256: string; [key: string]: unknown };
|
|
result: { status: "SUCCEEDED"; resultSha256: string; [key: string]: unknown };
|
|
lifecycle: { duplicate: "REUSED"; stale: "BLOCKED"; cancel: "CANCELLED"; timeout: "TIMEOUT"; oom: "OOM"; publishedOnFailure: false; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
};
|
|
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED";
|
|
scriptingPolicy?: {
|
|
schemaVersion: 1;
|
|
policyId: "workflow:scripting_default_deny_audit";
|
|
defaults: { autorun: false; network: false; addonInstall: false; driverExpressions: false; module: false };
|
|
scope: { dom: false; hostWorker: false; opfs: false; indexedDB: false; network: false };
|
|
sandbox: {
|
|
execution: "DISABLED";
|
|
cancellation: "SCRIPT_SANDBOX_CANCELLED";
|
|
dispose: "RELEASED";
|
|
mainRevisionUnchanged: true;
|
|
publishedResults: 0;
|
|
temporaryBytes: 0;
|
|
};
|
|
audit: { decision: "DENY"; approvedKey: false; reason: "SCRIPT_SIGNATURE_INVALID" | "SCRIPT_SANDBOX_UNAVAILABLE"; [key: string]: unknown };
|
|
boundaries: { secretAccess: "BLOCKED"; filesystemEscape: "BLOCKED"; network: "BLOCKED" };
|
|
budget: { maxSourceBytes: number; maxScripts: number; maxAuditEntries: number };
|
|
policyHash: string;
|
|
[key: string]: unknown;
|
|
};
|
|
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED";
|
|
performanceFaultMatrix?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:performance_fault_recovery_matrix";
|
|
thresholds: Array<{ id: string; limit: number; status: "PASS"; [key: string]: unknown }>;
|
|
faults: Record<string, { status: "BLOCKED"; code: string; publishedResults: 0; [key: string]: unknown }>;
|
|
recovery: { oldRevisionUsable: true; revisionBefore: 1; revisionAfter: 1; resourcesAfter: 0; cache: "QUARANTINED"; workerRestart: "RECOVERED"; [key: string]: unknown };
|
|
budget: { maxGeometryTriangles: number; maxTextureBytes: number; maxMediaBytes: number; maxCacheBytes: number; [key: string]: unknown };
|
|
matrixHash: string;
|
|
[key: string]: unknown;
|
|
};
|
|
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED";
|
|
releaseCandidate?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:offline_ci_sbom_and_rollback_release";
|
|
releaseId: string;
|
|
ci: { quick: { status: "READY"; reportSha256: string }; [key: string]: unknown };
|
|
gates: { offlineReproducibility: { status: "READY" }; sbom: { status: "READY" }; rollback: { status: "READY" }; [key: string]: unknown };
|
|
archives: { binary: { bytes: number; sha256: string }; source: { bytes: number; sha256: string }; [key: string]: unknown };
|
|
sbom: { sha256: string; [key: string]: unknown };
|
|
rollback: { runbookSha256: string; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
};
|
|
gapInventoryStatus?: "AVAILABLE" | "BLOCKED";
|
|
gapInventory?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:cluster_full_blender_gap_inventory";
|
|
runtime: "BLENDER_5_2_INVENTORY";
|
|
inventorySource: { path: string; sha256: string; entryCount: 6900; [key: string]: unknown };
|
|
classificationPolicy: { featureParityRequires: string[]; negativeOnlyClasses: string[]; unprovenClass: "METADATA_ONLY"; parityLevel: "L0"; [key: string]: unknown };
|
|
clusters: Array<{
|
|
capabilityId: string;
|
|
family: string;
|
|
layer: string;
|
|
entryCount: number;
|
|
legacyCompleted: number;
|
|
legacyPending: number;
|
|
legacyActive: number;
|
|
targetParityLevel: string;
|
|
currentParityLevel: "L0";
|
|
completionClass: "METADATA_ONLY";
|
|
evidenceStatus: "NOT_RUN";
|
|
webStatus: "GAP";
|
|
riskScore: number;
|
|
riskBand: string;
|
|
representativeIds: string[];
|
|
blockedReasons: string[];
|
|
[key: string]: unknown;
|
|
}>;
|
|
summary: { inventoryEntries: 6900; clusterCount: 15; unknownEntries: 0; webGapEntries: 6900; currentParityClaims: 0; currentParityLevel: "L0"; [key: string]: unknown };
|
|
riskRanking: string[];
|
|
nextTask: "WBV2-P7-002";
|
|
[key: string]: unknown;
|
|
};
|
|
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED";
|
|
atomicGapQueue?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:generate_atomic_family_gap_queue";
|
|
runtime: "BLENDER_5_2_INVENTORY";
|
|
parent: { task: "WBV2-P7-001"; manifestPath: string; manifestSha256: string; receiptPath: string; receiptSha256: string; [key: string]: unknown };
|
|
queue: { activeTaskId: string; activeCount: 1; plannedCount: 44; ordering: string; behaviors: string[]; implementationClasses: string[]; [key: string]: unknown };
|
|
tasks: Array<{ id: string; parityId: string; capabilityId: string; family: string; behavior: string; implementationClass: "LOCAL_BOUNDED"; parentTask: "WBV2-P7-001"; parentReceiptSha256: string; state: "active" | "pending"; ready: boolean; [key: string]: unknown }>;
|
|
nextTask: "WBV2-P7-003";
|
|
[key: string]: unknown;
|
|
};
|
|
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED";
|
|
parityReleaseGate?: {
|
|
schemaVersion: 1;
|
|
receiptId: "workflow:full_parity_candidate_release_gate";
|
|
runtime: "BLENDER_DESKTOP_WASM_MAIN";
|
|
inputs: Record<string, { path: string; sha256: string; [key: string]: unknown }>;
|
|
parityClosure: { status: "BLOCKED"; inventoryEntries: 6900; clusterCount: 15; atomicTaskCount: 45; currentParityLevel: "L0"; featureParityClaims: 0; reasonCodes: string[]; [key: string]: unknown };
|
|
evidenceHealth: { status: "BLOCKED"; historicalEvidencePreserved: true; m12ProvenanceState: "BLOCKED_SOURCE_LOST"; m12Entries: number; reasonCodes: string[]; [key: string]: unknown };
|
|
recoveryHealth: { status: "READY"; quickCi: "READY"; inventorySaveReopen: "EXACT"; queueSaveReopen: "EXACT"; malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION"; [key: string]: unknown };
|
|
releaseDecision: { status: "BLOCKED"; reasonCodes: string[]; nextTask: null; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
};
|
|
physicsSimulation?: PhysicsSimulationManifestIR;
|
|
particleSettings?: ParticleSettingsIR[];
|
|
sounds?: SoundResourceIR[];
|
|
speakers?: SpeakerResourceIR[];
|
|
texts?: TextResourceIR[];
|
|
textures?: TextureResourceIR[];
|
|
windowManager?: WindowManagerResourceIR;
|
|
workspaces?: WorkspaceResourceIR[];
|
|
geometryNodeGraphs?: GeometryNodeGraphIR[];
|
|
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 };
|
|
}
|
|
|
|
export interface ParticleSettingsIR {
|
|
id: string;
|
|
name: string;
|
|
type: number;
|
|
from: number;
|
|
distribution: number;
|
|
physicsType: number;
|
|
totalParticles: number;
|
|
start: number;
|
|
end: number;
|
|
lifetime: number;
|
|
size: number;
|
|
drawSize: number;
|
|
}
|
|
|
|
export interface BrushIR {
|
|
id: string;
|
|
name: string;
|
|
size: number;
|
|
alpha: number;
|
|
hardness: number;
|
|
spacing: number;
|
|
jitter: number;
|
|
sculptTool: number;
|
|
}
|
|
|
|
export interface FreestyleLineStyleIR {
|
|
id: string;
|
|
name: string;
|
|
color: [number, number, number];
|
|
alpha: number;
|
|
thickness: number;
|
|
chaining: number;
|
|
caps: number;
|
|
dashed: boolean;
|
|
modifierCounts: { color: number; alpha: number; thickness: number; geometry: number };
|
|
}
|
|
|
|
export type LatticeInterpolationIR = "KEY_LINEAR" | "KEY_CARDINAL" | "KEY_BSPLINE" | "KEY_CATMULL_ROM";
|
|
|
|
export interface LatticePointIR {
|
|
coDeform: [number, number, number];
|
|
weight: number;
|
|
selected: boolean;
|
|
}
|
|
|
|
export interface LatticeIR {
|
|
id: string;
|
|
name: string;
|
|
dimensions: [number, number, number];
|
|
pointCount: number;
|
|
interpolation: [LatticeInterpolationIR, LatticeInterpolationIR, LatticeInterpolationIR];
|
|
useOutside: boolean;
|
|
activePoint: number;
|
|
vertexGroup: string;
|
|
status: "AVAILABLE" | "BLOCKED";
|
|
points?: LatticePointIR[];
|
|
errorCode?: "LATTICE_DIMENSIONS_INVALID" | "LATTICE_POINTS_UNREADABLE";
|
|
}
|
|
|
|
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.brushes !== undefined) {
|
|
const brushes = requireArray(value.brushes, "brushes");
|
|
for (const [index, brush] of brushes.entries()) {
|
|
if (!isRecord(brush)) throw new Error(`SceneIR.brushes[${index}] must be an object`);
|
|
requireString(brush.id, `brushes[${index}].id`);
|
|
requireString(brush.name, `brushes[${index}].name`);
|
|
const size = requireNumber(brush.size, `brushes[${index}].size`);
|
|
const alpha = requireNumber(brush.alpha, `brushes[${index}].alpha`);
|
|
const hardness = requireNumber(brush.hardness, `brushes[${index}].hardness`);
|
|
const spacing = requireNumber(brush.spacing, `brushes[${index}].spacing`);
|
|
const jitter = requireNumber(brush.jitter, `brushes[${index}].jitter`);
|
|
const sculptTool = requireNumber(brush.sculptTool, `brushes[${index}].sculptTool`);
|
|
if (!Number.isSafeInteger(size) || size < 0 || size > 100000 || alpha < 0 || alpha > 1 || hardness < 0 || hardness > 1 || !Number.isSafeInteger(spacing) || spacing < 1 || spacing > 1000 || jitter < 0 || jitter > 1 || !Number.isSafeInteger(sculptTool) || sculptTool < 0) {
|
|
throw new Error(`SceneIR.brushes[${index}] is outside the bounded brush contract`);
|
|
}
|
|
}
|
|
}
|
|
if (value.lineStyles !== undefined) {
|
|
const lineStyles = requireArray(value.lineStyles, "lineStyles");
|
|
for (const [index, lineStyle] of lineStyles.entries()) {
|
|
if (!isRecord(lineStyle)) throw new Error(`SceneIR.lineStyles[${index}] must be an object`);
|
|
requireString(lineStyle.id, `lineStyles[${index}].id`);
|
|
requireString(lineStyle.name, `lineStyles[${index}].name`);
|
|
requireTuple(lineStyle.color, 3, `lineStyles[${index}].color`);
|
|
const alpha = requireNumber(lineStyle.alpha, `lineStyles[${index}].alpha`);
|
|
const thickness = requireNumber(lineStyle.thickness, `lineStyles[${index}].thickness`);
|
|
requireNumber(lineStyle.chaining, `lineStyles[${index}].chaining`);
|
|
requireNumber(lineStyle.caps, `lineStyles[${index}].caps`);
|
|
requireBoolean(lineStyle.dashed, `lineStyles[${index}].dashed`);
|
|
if (!isRecord(lineStyle.modifierCounts)) throw new Error(`SceneIR.lineStyles[${index}].modifierCounts must be an object`);
|
|
for (const field of ["color", "alpha", "thickness", "geometry"] as const) {
|
|
const count = requireNumber(lineStyle.modifierCounts[field], `lineStyles[${index}].modifierCounts.${field}`);
|
|
if (!Number.isSafeInteger(count) || count < 0 || count > 1024) throw new Error(`SceneIR.lineStyles[${index}].modifierCounts.${field} is outside budget`);
|
|
}
|
|
if (alpha < 0 || alpha > 1 || thickness < 0 || thickness > 10000) throw new Error(`SceneIR.lineStyles[${index}] is outside bounds`);
|
|
}
|
|
}
|
|
if (value.lattices !== undefined) {
|
|
const lattices = requireArray(value.lattices, "lattices");
|
|
if (lattices.length > 4096) throw new Error("SceneIR.lattices exceeds the data-block budget");
|
|
const ids = new Set<string>();
|
|
for (const [index, lattice] of lattices.entries()) {
|
|
if (!isRecord(lattice)) throw new Error(`SceneIR.lattices[${index}] must be an object`);
|
|
const id = requireString(lattice.id, `lattices[${index}].id`);
|
|
if (!id.startsWith("lattice:") || ids.has(id)) throw new Error(`SceneIR.lattices[${index}].id is invalid`);
|
|
ids.add(id);
|
|
requireString(lattice.name, `lattices[${index}].name`);
|
|
if (!Array.isArray(lattice.dimensions) || lattice.dimensions.length !== 3 || lattice.dimensions.some((item) => !Number.isSafeInteger(item) || item < 1 || item > 64)) throw new Error(`SceneIR.lattices[${index}].dimensions is invalid`);
|
|
const pointCount = requireNumber(lattice.pointCount, `lattices[${index}].pointCount`);
|
|
const expectedCount = (lattice.dimensions as number[]).reduce((product, item) => product * item, 1);
|
|
if (!Number.isSafeInteger(pointCount) || pointCount !== expectedCount || pointCount > 64 * 64 * 64) throw new Error(`SceneIR.lattices[${index}].pointCount is invalid`);
|
|
const interpolation = ["KEY_LINEAR", "KEY_CARDINAL", "KEY_BSPLINE", "KEY_CATMULL_ROM"];
|
|
if (!Array.isArray(lattice.interpolation) || lattice.interpolation.length !== 3 || lattice.interpolation.some((item) => !interpolation.includes(item as string))) throw new Error(`SceneIR.lattices[${index}].interpolation is invalid`);
|
|
requireBoolean(lattice.useOutside, `lattices[${index}].useOutside`);
|
|
const activePoint = requireNumber(lattice.activePoint, `lattices[${index}].activePoint`);
|
|
if (!Number.isSafeInteger(activePoint) || activePoint < -1 || activePoint >= pointCount) throw new Error(`SceneIR.lattices[${index}].activePoint is invalid`);
|
|
requireString(lattice.vertexGroup, `lattices[${index}].vertexGroup`);
|
|
if (lattice.status !== "AVAILABLE" && lattice.status !== "BLOCKED") throw new Error(`SceneIR.lattices[${index}].status is invalid`);
|
|
if (lattice.status === "AVAILABLE") {
|
|
if (!Array.isArray(lattice.points) || lattice.points.length !== pointCount) throw new Error(`SceneIR.lattices[${index}].points is incomplete`);
|
|
for (const [pointIndex, point] of lattice.points.entries()) {
|
|
if (!isRecord(point)) throw new Error(`SceneIR.lattices[${index}].points[${pointIndex}] is invalid`);
|
|
requireTuple(point.coDeform, 3, `lattices[${index}].points[${pointIndex}].coDeform`);
|
|
const weight = requireNumber(point.weight, `lattices[${index}].points[${pointIndex}].weight`);
|
|
if (weight < 0.01 || weight > 100) throw new Error(`SceneIR.lattices[${index}].points[${pointIndex}].weight is invalid`);
|
|
requireBoolean(point.selected, `lattices[${index}].points[${pointIndex}].selected`);
|
|
}
|
|
if (lattice.errorCode !== undefined) throw new Error(`SceneIR.lattices[${index}] available data cannot contain errorCode`);
|
|
}
|
|
else if (!lattice.errorCode || lattice.points !== undefined) {
|
|
throw new Error(`SceneIR.lattices[${index}] blocked data is incomplete`);
|
|
}
|
|
}
|
|
}
|
|
if (value.nonMeshData !== undefined) requireArray(value.nonMeshData, "nonMeshData");
|
|
if (value.particleSettings !== undefined) {
|
|
const settings = requireArray(value.particleSettings, "particleSettings");
|
|
if (settings.length > 4096) throw new Error("SceneIR.particleSettings exceeds the data-block budget");
|
|
const ids = new Set<string>();
|
|
for (const [index, setting] of settings.entries()) {
|
|
if (!isRecord(setting)) throw new Error(`SceneIR.particleSettings[${index}] must be an object`);
|
|
const id = requireString(setting.id, `particleSettings[${index}].id`);
|
|
if (!id.startsWith("particle-settings:") || ids.has(id)) throw new Error(`SceneIR.particleSettings[${index}].id is invalid`);
|
|
ids.add(id);
|
|
requireString(setting.name, `particleSettings[${index}].name`);
|
|
for (const field of ["type", "from", "distribution", "physicsType", "totalParticles"] as const) {
|
|
const number = requireNumber(setting[field], `particleSettings[${index}].${field}`);
|
|
if (!Number.isSafeInteger(number) || number < 0) throw new Error(`SceneIR.particleSettings[${index}].${field} is invalid`);
|
|
}
|
|
for (const field of ["start", "end", "lifetime", "size", "drawSize"] as const) {
|
|
const number = requireNumber(setting[field], `particleSettings[${index}].${field}`);
|
|
if (number < 0 || number > 1_000_000) throw new Error(`SceneIR.particleSettings[${index}].${field} is invalid`);
|
|
}
|
|
}
|
|
}
|
|
if (value.sounds !== undefined) {
|
|
const sounds = requireArray(value.sounds, "sounds");
|
|
if (sounds.length > 4096) throw new Error("SceneIR.sounds exceeds the data-block budget");
|
|
const ids = new Set<string>();
|
|
for (const [index, sound] of sounds.entries()) {
|
|
if (!isRecord(sound)) throw new Error(`SceneIR.sounds[${index}] must be an object`);
|
|
const id = requireString(sound.id, `sounds[${index}].id`);
|
|
if (!id.startsWith("sound:") || ids.has(id)) throw new Error(`SceneIR.sounds[${index}].id is invalid`);
|
|
ids.add(id);
|
|
requireString(sound.name, `sounds[${index}].name`);
|
|
requireString(sound.sourcePath, `sounds[${index}].sourcePath`);
|
|
requireBoolean(sound.packed, `sounds[${index}].packed`);
|
|
for (const field of ["volume", "pitch"] as const) {
|
|
const number = requireNumber(sound[field], `sounds[${index}].${field}`);
|
|
if (number < 0 || number > 1000) throw new Error(`SceneIR.sounds[${index}].${field} is outside bounds`);
|
|
}
|
|
for (const field of ["audioChannels", "sampleRate"] as const) {
|
|
const number = requireNumber(sound[field], `sounds[${index}].${field}`);
|
|
if (!Number.isSafeInteger(number) || number < 0 || number > 1_000_000) throw new Error(`SceneIR.sounds[${index}].${field} is invalid`);
|
|
}
|
|
if (sound.packedByteLength !== undefined && (!Number.isSafeInteger(sound.packedByteLength) || (sound.packedByteLength as number) < 0)) throw new Error(`SceneIR.sounds[${index}].packedByteLength is invalid`);
|
|
if (sound.packed && sound.packedByteLength === undefined) throw new Error(`SceneIR.sounds[${index}] packed identity is incomplete`);
|
|
}
|
|
}
|
|
if (value.speakers !== undefined) {
|
|
const speakers = requireArray(value.speakers, "speakers");
|
|
if (speakers.length > 4096) throw new Error("SceneIR.speakers exceeds the data-block budget");
|
|
const ids = new Set<string>();
|
|
for (const [index, speaker] of speakers.entries()) {
|
|
if (!isRecord(speaker)) throw new Error(`SceneIR.speakers[${index}] must be an object`);
|
|
const id = requireString(speaker.id, `speakers[${index}].id`);
|
|
if (!id.startsWith("speaker:") || ids.has(id)) throw new Error(`SceneIR.speakers[${index}].id is invalid`);
|
|
ids.add(id);
|
|
requireString(speaker.name, `speakers[${index}].name`);
|
|
if (speaker.soundId !== undefined && (!Array.isArray(value.sounds) || typeof speaker.soundId !== "string" || !speaker.soundId.startsWith("sound:"))) {
|
|
throw new Error(`SceneIR.speakers[${index}].soundId is invalid`);
|
|
}
|
|
for (const field of ["volumeMax", "volumeMin", "distanceMax", "distanceReference", "attenuation"] as const) {
|
|
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
|
|
if (number < 0 || number > 1_000_000_000) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
|
|
}
|
|
for (const field of ["coneAngleOuter", "coneAngleInner"] as const) {
|
|
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
|
|
if (number < 0 || number > 360) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
|
|
}
|
|
const coneVolumeOuter = requireNumber(speaker.coneVolumeOuter, `speakers[${index}].coneVolumeOuter`);
|
|
if (coneVolumeOuter < 0 || coneVolumeOuter > 1) throw new Error(`SceneIR.speakers[${index}].coneVolumeOuter is outside bounds`);
|
|
for (const field of ["volume", "pitch"] as const) {
|
|
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
|
|
if (number < 0 || number > 1000) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
|
|
}
|
|
}
|
|
}
|
|
if (value.texts !== undefined) {
|
|
const texts = requireArray(value.texts, "texts");
|
|
if (texts.length > 4096) throw new Error("SceneIR.texts exceeds the data-block budget");
|
|
const ids = new Set<string>();
|
|
for (const [index, text] of texts.entries()) {
|
|
if (!isRecord(text)) throw new Error(`SceneIR.texts[${index}] must be an object`);
|
|
const id = requireString(text.id, `texts[${index}].id`);
|
|
if (!id.startsWith("text:") || ids.has(id)) throw new Error(`SceneIR.texts[${index}].id is invalid`);
|
|
ids.add(id);
|
|
requireString(text.name, `texts[${index}].name`);
|
|
const source = requireString(text.source, `texts[${index}].source`);
|
|
const sourceSha256 = requireString(text.sourceSha256, `texts[${index}].sourceSha256`);
|
|
if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new Error(`SceneIR.texts[${index}].sourceSha256 is invalid`);
|
|
const byteLength = requireNumber(text.byteLength, `texts[${index}].byteLength`);
|
|
const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`);
|
|
if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`);
|
|
if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`);
|
|
requireBoolean(text.internal, `texts[${index}].internal`);
|
|
if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`);
|
|
requireBoolean(text.isDirty, `texts[${index}].isDirty`);
|
|
requireBoolean(text.useModule, `texts[${index}].useModule`);
|
|
}
|
|
}
|
|
if (value.textures !== undefined) {
|
|
const textures = requireArray(value.textures, "textures");
|
|
if (textures.length > 4096) throw new Error("SceneIR.textures exceeds the data-block budget");
|
|
const ids = new Set<string>();
|
|
for (const [index, texture] of textures.entries()) {
|
|
if (!isRecord(texture)) throw new Error(`SceneIR.textures[${index}] must be an object`);
|
|
const id = requireString(texture.id, `textures[${index}].id`);
|
|
if (!id.startsWith("texture:") || ids.has(id)) throw new Error(`SceneIR.textures[${index}].id is invalid`);
|
|
ids.add(id);
|
|
requireString(texture.name, `textures[${index}].name`);
|
|
const type = requireNumber(texture.type, `textures[${index}].type`);
|
|
const noiseDepth = requireNumber(texture.noiseDepth, `textures[${index}].noiseDepth`);
|
|
if (!Number.isSafeInteger(type) || type < 0 || !Number.isSafeInteger(noiseDepth) || noiseDepth < 0 || noiseDepth > 30) throw new Error(`SceneIR.textures[${index}] integer fields are invalid`);
|
|
for (const field of ["noiseScale", "intensity", "contrast", "saturation"] as const) {
|
|
const number = requireNumber(texture[field], `textures[${index}].${field}`);
|
|
if (number < 0 || number > 1000) throw new Error(`SceneIR.textures[${index}].${field} is outside bounds`);
|
|
}
|
|
}
|
|
}
|
|
if (value.windowManager !== undefined) {
|
|
if (!isRecord(value.windowManager)) throw new Error("SceneIR.windowManager must be an object");
|
|
const manager = value.windowManager;
|
|
const id = requireString(manager.id, "windowManager.id");
|
|
if (id !== "window-manager:WinMan") throw new Error("SceneIR.windowManager.id is invalid");
|
|
requireString(manager.name, "windowManager.name");
|
|
requireString(manager.presetName, "windowManager.presetName");
|
|
const windowCount = requireNumber(manager.windowCount, "windowManager.windowCount");
|
|
if (!Number.isSafeInteger(windowCount) || windowCount < 0 || windowCount > 1024) throw new Error("SceneIR.windowManager.windowCount is invalid");
|
|
requireBoolean(manager.interfaceLocked, "windowManager.interfaceLocked");
|
|
}
|
|
if (value.workspaces !== undefined) {
|
|
const workspaces = requireArray(value.workspaces, "workspaces");
|
|
const ids = new Set<string>();
|
|
for (const [index, workspace] of workspaces.entries()) {
|
|
if (!isRecord(workspace)) throw new Error(`SceneIR.workspaces[${index}] must be an object`);
|
|
const id = requireString(workspace.id, `workspaces[${index}].id`);
|
|
if (!id.startsWith("workspace:") || ids.has(id)) throw new Error(`SceneIR.workspaces[${index}].id is invalid`); ids.add(id);
|
|
requireString(workspace.name, `workspaces[${index}].name`); const count = requireNumber(workspace.screenCount, `workspaces[${index}].screenCount`);
|
|
if (!Number.isSafeInteger(count) || count < 0 || count > 64) throw new Error(`SceneIR.workspaces[${index}].screenCount is invalid`);
|
|
}
|
|
}
|
|
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.colorSpace !== undefined && !["SRGB", "NON_COLOR", "LINEAR"].includes(image.colorSpace as string)) throw new Error(`images[${index}].colorSpace is invalid`);
|
|
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`);
|
|
if (font.packed) {
|
|
if (!Number.isSafeInteger(font.packedByteLength) || (font.packedByteLength as number) <= 0 ||
|
|
typeof font.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(font.sha256)) {
|
|
throw new Error(`SceneIR.vfonts[${index}] packed identity is invalid`);
|
|
}
|
|
}
|
|
else if (font.packedByteLength !== undefined || font.sha256 !== undefined) {
|
|
throw new Error(`SceneIR.vfonts[${index}] unpacked identity is invalid`);
|
|
}
|
|
}
|
|
}
|
|
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.lightgroup !== undefined && world.lightgroup !== null) requireString(world.lightgroup, `worlds[${index}].lightgroup`);
|
|
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.renderSettings !== undefined) {
|
|
if (!isRecord(scene.renderSettings)) throw new Error(`SceneIR.scenes[${index}].renderSettings must be an object`);
|
|
for (const field of ["resolutionX", "resolutionY", "resolutionPercentage", "frameStart", "frameEnd"] as const) requireNumber(scene.renderSettings[field], `scenes[${index}].renderSettings.${field}`);
|
|
requireString(scene.renderSettings.outputPath, `scenes[${index}].renderSettings.outputPath`);
|
|
}
|
|
if (scene.audioSettings !== undefined) {
|
|
if (!isRecord(scene.audioSettings)) throw new Error(`SceneIR.scenes[${index}].audioSettings must be an object`);
|
|
for (const field of ["volume", "speedOfSound", "dopplerFactor", "distanceModel"] as const) requireNumber(scene.audioSettings[field], `scenes[${index}].audioSettings.${field}`);
|
|
for (const field of ["muted", "sync"] as const) requireBoolean(scene.audioSettings[field], `scenes[${index}].audioSettings.${field}`);
|
|
}
|
|
if (scene.unitSettings !== undefined) {
|
|
if (!isRecord(scene.unitSettings)) throw new Error(`SceneIR.scenes[${index}].unitSettings must be an object`);
|
|
for (const field of ["system", "scaleLength", "rotationSystem", "lengthUnit", "massUnit", "timeUnit", "temperatureUnit"] as const) requireNumber(scene.unitSettings[field], `scenes[${index}].unitSettings.${field}`);
|
|
}
|
|
if (scene.timelineSettings !== undefined) {
|
|
if (!isRecord(scene.timelineSettings)) throw new Error(`SceneIR.scenes[${index}].timelineSettings must be an object`);
|
|
for (const field of ["fps", "fpsBase", "frameStep", "previewStart", "previewEnd"] as const) requireNumber(scene.timelineSettings[field], `scenes[${index}].timelineSettings.${field}`);
|
|
requireBoolean(scene.timelineSettings.usePreviewRange, `scenes[${index}].timelineSettings.usePreviewRange`);
|
|
}
|
|
if (scene.pipelineSettings !== undefined) {
|
|
if (!isRecord(scene.pipelineSettings)) throw new Error(`SceneIR.scenes[${index}].pipelineSettings must be an object`);
|
|
for (const field of ["useCompositor", "useSequencer", "noOverwrite", "saveOutput", "alphaPremultiplied"] as const) requireBoolean(scene.pipelineSettings[field], `scenes[${index}].pipelineSettings.${field}`);
|
|
}
|
|
if (scene.viewLayers !== undefined) {
|
|
if (!Array.isArray(scene.viewLayers)) throw new Error(`SceneIR.scenes[${index}].viewLayers must be an array`);
|
|
for (const [layerIndex, layer] of scene.viewLayers.entries()) {
|
|
if (!isRecord(layer)) throw new Error(`SceneIR.scenes[${index}].viewLayers[${layerIndex}] must be an object`);
|
|
requireString(layer.name, `scenes[${index}].viewLayers[${layerIndex}].name`);
|
|
requireBoolean(layer.useRender, `scenes[${index}].viewLayers[${layerIndex}].useRender`);
|
|
requireBoolean(layer.useFreestyle, `scenes[${index}].viewLayers[${layerIndex}].useFreestyle`);
|
|
requireNumber(layer.samples, `scenes[${index}].viewLayers[${layerIndex}].samples`);
|
|
for (const field of ["passMask", "cryptomatteMask", "cryptomatteLevels", "alphaThreshold"] as const) if (layer[field] !== undefined) requireNumber(layer[field], `scenes[${index}].viewLayers[${layerIndex}].${field}`);
|
|
if (layer.aovs !== undefined) {
|
|
if (!Array.isArray(layer.aovs)) throw new Error(`SceneIR.scenes[${index}].viewLayers[${layerIndex}].aovs must be an array`);
|
|
for (const [aovIndex, aov] of layer.aovs.entries()) { if (!isRecord(aov)) throw new Error(`SceneIR.scenes[${index}].viewLayers[${layerIndex}].aovs[${aovIndex}] must be an object`); requireString(aov.name, `scenes[${index}].viewLayers[${layerIndex}].aovs[${aovIndex}].name`); requireNumber(aov.type, `scenes[${index}].viewLayers[${layerIndex}].aovs[${aovIndex}].type`); }
|
|
}
|
|
if (layer.lightgroups !== undefined) { if (!Array.isArray(layer.lightgroups)) throw new Error(`SceneIR.scenes[${index}].viewLayers[${layerIndex}].lightgroups must be an array`); for (const [groupIndex, group] of layer.lightgroups.entries()) requireString(group, `scenes[${index}].viewLayers[${layerIndex}].lightgroups[${groupIndex}]`); }
|
|
}
|
|
}
|
|
if (scene.borderSettings !== undefined) {
|
|
if (!isRecord(scene.borderSettings)) throw new Error(`SceneIR.scenes[${index}].borderSettings must be an object`);
|
|
requireBoolean(scene.borderSettings.enabled, `scenes[${index}].borderSettings.enabled`);
|
|
for (const field of ["minX", "maxX", "minY", "maxY"] as const) requireNumber(scene.borderSettings[field], `scenes[${index}].borderSettings.${field}`);
|
|
}
|
|
if (scene.simplifySettings !== undefined) {
|
|
if (!isRecord(scene.simplifySettings)) throw new Error(`SceneIR.scenes[${index}].simplifySettings must be an object`);
|
|
for (const field of ["subsurfViewport", "subsurfRender", "particlesViewport", "particlesRender", "volumes"] as const) requireNumber(scene.simplifySettings[field], `scenes[${index}].simplifySettings.${field}`);
|
|
}
|
|
if (scene.imageFormatSettings !== undefined) {
|
|
if (!isRecord(scene.imageFormatSettings)) throw new Error(`SceneIR.scenes[${index}].imageFormatSettings must be an object`);
|
|
requireString(scene.imageFormatSettings.fileType, `scenes[${index}].imageFormatSettings.fileType`);
|
|
for (const field of ["colorMode", "depth", "quality", "compression", "exrCodec"] as const) requireNumber(scene.imageFormatSettings[field], `scenes[${index}].imageFormatSettings.${field}`);
|
|
}
|
|
if (scene.motionBlurSettings !== undefined) {
|
|
if (!isRecord(scene.motionBlurSettings)) throw new Error(`SceneIR.scenes[${index}].motionBlurSettings must be an object`);
|
|
requireBoolean(scene.motionBlurSettings.enabled, `scenes[${index}].motionBlurSettings.enabled`);
|
|
for (const field of ["shutter", "position"] as const) requireNumber(scene.motionBlurSettings[field], `scenes[${index}].motionBlurSettings.${field}`);
|
|
}
|
|
if (scene.hairSettings !== undefined) {
|
|
if (!isRecord(scene.hairSettings)) throw new Error(`SceneIR.scenes[${index}].hairSettings must be an object`);
|
|
requireNumber(scene.hairSettings.type, `scenes[${index}].hairSettings.type`);
|
|
requireNumber(scene.hairSettings.subdivisions, `scenes[${index}].hairSettings.subdivisions`);
|
|
}
|
|
if (scene.compositorSettings !== undefined) {
|
|
if (!isRecord(scene.compositorSettings)) throw new Error(`SceneIR.scenes[${index}].compositorSettings must be an object`);
|
|
for (const field of ["device", "precision", "denoiseDevice", "previewQuality", "finalQuality"] as const) requireNumber(scene.compositorSettings[field], `scenes[${index}].compositorSettings.${field}`);
|
|
}
|
|
if (scene.performanceSettings !== undefined) {
|
|
if (!isRecord(scene.performanceSettings)) throw new Error(`SceneIR.scenes[${index}].performanceSettings must be an object`);
|
|
for (const field of ["fixedThreads", "persistentData", "highQualityNormals"] as const) requireBoolean(scene.performanceSettings[field], `scenes[${index}].performanceSettings.${field}`);
|
|
requireNumber(scene.performanceSettings.threads, `scenes[${index}].performanceSettings.threads`);
|
|
}
|
|
if (scene.samplingSettings !== undefined) {
|
|
if (!isRecord(scene.samplingSettings)) throw new Error(`SceneIR.scenes[${index}].samplingSettings must be an object`);
|
|
for (const field of ["pixelAspectX", "pixelAspectY", "filterSize", "dither"] as const) requireNumber(scene.samplingSettings[field], `scenes[${index}].samplingSettings.${field}`);
|
|
}
|
|
if (scene.multiviewSettings !== undefined) {
|
|
if (!isRecord(scene.multiviewSettings)) throw new Error(`SceneIR.scenes[${index}].multiviewSettings must be an object`);
|
|
for (const field of ["viewsFormat", "imageViewsFormat", "stereoDisplayMode"] as const) requireNumber(scene.multiviewSettings[field], `scenes[${index}].multiviewSettings.${field}`);
|
|
for (const field of ["enabled", "interlaceSwap", "sideBySideCrossed", "squeezedFrame"] as const) requireBoolean(scene.multiviewSettings[field], `scenes[${index}].multiviewSettings.${field}`);
|
|
}
|
|
if (scene.stampSettings !== undefined) {
|
|
if (!isRecord(scene.stampSettings)) throw new Error(`SceneIR.scenes[${index}].stampSettings must be an object`);
|
|
for (const field of ["stampMask", "fontId"] as const) requireNumber(scene.stampSettings[field], `scenes[${index}].stampSettings.${field}`);
|
|
requireString(scene.stampSettings.userText, `scenes[${index}].stampSettings.userText`);
|
|
for (const field of ["foreground", "background"] as const) requireTuple(scene.stampSettings[field], 4, `scenes[${index}].stampSettings.${field}`);
|
|
}
|
|
if (scene.lineSettings !== undefined) {
|
|
if (!isRecord(scene.lineSettings)) throw new Error(`SceneIR.scenes[${index}].lineSettings must be an object`);
|
|
requireNumber(scene.lineSettings.mode, `scenes[${index}].lineSettings.mode`);
|
|
requireNumber(scene.lineSettings.thickness, `scenes[${index}].lineSettings.thickness`);
|
|
}
|
|
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.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.renderRoutingStatus as string)) {
|
|
throw new Error("SceneIR.renderRoutingStatus is invalid");
|
|
}
|
|
if (value.renderRouting !== undefined) {
|
|
if (!isRecord(value.renderRouting) || value.renderRouting.schemaVersion !== 1 ||
|
|
value.renderRouting.receiptId !== "workflow:render_capability_routing" ||
|
|
!isRecord(value.renderRouting.source) || typeof value.renderRouting.source.sha256 !== "string" ||
|
|
!isRecord(value.renderRouting.settings) || typeof value.renderRouting.settings.sha256 !== "string" ||
|
|
!isRecord(value.renderRouting.build) || typeof value.renderRouting.build.version !== "string" ||
|
|
typeof value.renderRouting.build.buildSha256 !== "string" || !Array.isArray(value.renderRouting.routes) ||
|
|
!isRecord(value.renderRouting.recovery) || value.renderRouting.recovery.cancellation !== "OPEN_CANCELLED" ||
|
|
value.renderRouting.recovery.deviceLoss !== "RELEASED") {
|
|
throw new Error("SceneIR.renderRouting is invalid");
|
|
}
|
|
if (value.renderRoutingStatus !== "AVAILABLE") throw new Error("SceneIR.renderRoutingStatus must be AVAILABLE when renderRouting is present");
|
|
}
|
|
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.serverJobBindingStatus as string)) {
|
|
throw new Error("SceneIR.serverJobBindingStatus is invalid");
|
|
}
|
|
if (value.serverJobBinding !== undefined) {
|
|
const binding = value.serverJobBinding;
|
|
if (!isRecord(binding) || binding.schemaVersion !== 1 || binding.receiptId !== "workflow:server_job_result_binding" ||
|
|
!isRecord(binding.source) || typeof binding.source.sha256 !== "string" ||
|
|
!isRecord(binding.settings) || typeof binding.settings.settingsSha256 !== "string" ||
|
|
!isRecord(binding.build) || typeof binding.build.version !== "string" || typeof binding.build.buildSha256 !== "string" ||
|
|
!isRecord(binding.request) || typeof binding.request.requestSha256 !== "string" ||
|
|
!isRecord(binding.result) || binding.result.status !== "SUCCEEDED" || typeof binding.result.resultSha256 !== "string" ||
|
|
!isRecord(binding.lifecycle) || binding.lifecycle.duplicate !== "REUSED" || binding.lifecycle.stale !== "BLOCKED" ||
|
|
binding.lifecycle.cancel !== "CANCELLED" || binding.lifecycle.timeout !== "TIMEOUT" || binding.lifecycle.oom !== "OOM" ||
|
|
binding.lifecycle.publishedOnFailure !== false) {
|
|
throw new Error("SceneIR.serverJobBinding is invalid");
|
|
}
|
|
if (value.serverJobBindingStatus !== "AVAILABLE") throw new Error("SceneIR.serverJobBindingStatus must be AVAILABLE when serverJobBinding is present");
|
|
}
|
|
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptingPolicyStatus as string)) {
|
|
throw new Error("SceneIR.scriptingPolicyStatus is invalid");
|
|
}
|
|
if (value.scriptingPolicy !== undefined) {
|
|
const policy = value.scriptingPolicy;
|
|
const defaults = isRecord(policy) ? policy.defaults : undefined;
|
|
const scope = isRecord(policy) ? policy.scope : undefined;
|
|
const sandbox = isRecord(policy) ? policy.sandbox : undefined;
|
|
const audit = isRecord(policy) ? policy.audit : undefined;
|
|
const boundaries = isRecord(policy) ? policy.boundaries : undefined;
|
|
const budget = isRecord(policy) ? policy.budget : undefined;
|
|
if (!isRecord(policy) || policy.schemaVersion !== 1 || policy.policyId !== "workflow:scripting_default_deny_audit" ||
|
|
!isRecord(defaults) || defaults.autorun !== false || defaults.network !== false || defaults.addonInstall !== false ||
|
|
defaults.driverExpressions !== false || defaults.module !== false || !isRecord(scope) || scope.dom !== false ||
|
|
scope.hostWorker !== false || scope.opfs !== false || scope.indexedDB !== false || scope.network !== false ||
|
|
!isRecord(sandbox) || sandbox.execution !== "DISABLED" || sandbox.cancellation !== "SCRIPT_SANDBOX_CANCELLED" ||
|
|
sandbox.dispose !== "RELEASED" || sandbox.mainRevisionUnchanged !== true || sandbox.publishedResults !== 0 ||
|
|
sandbox.temporaryBytes !== 0 || !isRecord(audit) || audit.decision !== "DENY" || audit.approvedKey !== false ||
|
|
!["SCRIPT_SIGNATURE_INVALID", "SCRIPT_SANDBOX_UNAVAILABLE"].includes(audit.reason as string) ||
|
|
!isRecord(boundaries) || boundaries.secretAccess !== "BLOCKED" || boundaries.filesystemEscape !== "BLOCKED" ||
|
|
boundaries.network !== "BLOCKED" || !isRecord(budget) || !Number.isSafeInteger(budget.maxSourceBytes) ||
|
|
(budget.maxSourceBytes as number) < 1 || !Number.isSafeInteger(budget.maxScripts) || (budget.maxScripts as number) < 1 ||
|
|
!Number.isSafeInteger(budget.maxAuditEntries) || (budget.maxAuditEntries as number) < 1 ||
|
|
typeof policy.policyHash !== "string" || !/^[a-f0-9]{64}$/u.test(policy.policyHash)) {
|
|
throw new Error("SceneIR.scriptingPolicy is invalid");
|
|
}
|
|
if (value.scriptingPolicyStatus !== "AVAILABLE") throw new Error("SceneIR.scriptingPolicyStatus must be AVAILABLE when scriptingPolicy is present");
|
|
}
|
|
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.performanceFaultMatrixStatus as string)) {
|
|
throw new Error("SceneIR.performanceFaultMatrixStatus is invalid");
|
|
}
|
|
if (value.performanceFaultMatrix !== undefined) {
|
|
const matrix = value.performanceFaultMatrix;
|
|
const thresholds = isRecord(matrix) ? matrix.thresholds : undefined;
|
|
const faults = isRecord(matrix) ? matrix.faults : undefined;
|
|
const recovery = isRecord(matrix) ? matrix.recovery : undefined;
|
|
const budget = isRecord(matrix) ? matrix.budget : undefined;
|
|
const thresholdIds = new Set(Array.isArray(thresholds) ? thresholds.map((entry) => isRecord(entry) ? entry.id : undefined) : []);
|
|
if (!isRecord(matrix) || matrix.schemaVersion !== 1 || matrix.receiptId !== "workflow:performance_fault_recovery_matrix" ||
|
|
!Array.isArray(thresholds) || thresholds.length < 7 || thresholds.some((entry) => !isRecord(entry) || typeof entry.id !== "string" || entry.status !== "PASS" || !Number.isSafeInteger(entry.limit) || (entry.limit as number) < 1) ||
|
|
!thresholdIds.has("geometry-100k") || !thresholdIds.has("geometry-1m") || !thresholdIds.has("geometry-10m") || !thresholdIds.has("texture-4k") || !thresholdIds.has("texture-8k") || !thresholdIds.has("long-media") || !thresholdIds.has("simulation-cache") ||
|
|
!isRecord(faults) || Object.values(faults).some((fault) => !isRecord(fault) || fault.status !== "BLOCKED" || typeof fault.code !== "string" || fault.publishedResults !== 0) ||
|
|
!isRecord(recovery) || recovery.oldRevisionUsable !== true || recovery.revisionBefore !== 1 || recovery.revisionAfter !== 1 || recovery.resourcesAfter !== 0 || recovery.cache !== "QUARANTINED" || recovery.workerRestart !== "RECOVERED" ||
|
|
!isRecord(budget) || Object.values(budget).some((limit) => typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1) ||
|
|
typeof matrix.matrixHash !== "string" || !/^[a-f0-9]{64}$/u.test(matrix.matrixHash)) {
|
|
throw new Error("SceneIR.performanceFaultMatrix is invalid");
|
|
}
|
|
if (value.performanceFaultMatrixStatus !== "AVAILABLE") throw new Error("SceneIR.performanceFaultMatrixStatus must be AVAILABLE when performanceFaultMatrix is present");
|
|
}
|
|
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.releaseCandidateStatus as string)) {
|
|
throw new Error("SceneIR.releaseCandidateStatus is invalid");
|
|
}
|
|
if (value.releaseCandidate !== undefined) {
|
|
const receipt = value.releaseCandidate;
|
|
const ci = isRecord(receipt) ? receipt.ci : undefined;
|
|
const gates = isRecord(receipt) ? receipt.gates : undefined;
|
|
const archives = isRecord(receipt) ? receipt.archives : undefined;
|
|
const binary = isRecord(archives) ? archives.binary : undefined;
|
|
const source = isRecord(archives) ? archives.source : undefined;
|
|
const sbom = isRecord(receipt) ? receipt.sbom : undefined;
|
|
const rollback = isRecord(receipt) ? receipt.rollback : undefined;
|
|
if (!isRecord(receipt) || receipt.schemaVersion !== 1 || receipt.receiptId !== "workflow:offline_ci_sbom_and_rollback_release" ||
|
|
typeof receipt.releaseId !== "string" || !isRecord(ci) || !isRecord(ci.quick) || ci.quick.status !== "READY" ||
|
|
typeof ci.quick.reportSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(ci.quick.reportSha256) || !isRecord(gates) ||
|
|
!isRecord(gates.offlineReproducibility) || gates.offlineReproducibility.status !== "READY" || !isRecord(gates.sbom) ||
|
|
gates.sbom.status !== "READY" || !isRecord(gates.rollback) || gates.rollback.status !== "READY" || !isRecord(binary) ||
|
|
!Number.isSafeInteger(binary.bytes) || (binary.bytes as number) < 1 || typeof binary.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(binary.sha256) ||
|
|
!isRecord(source) || !Number.isSafeInteger(source.bytes) || (source.bytes as number) < 1 || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sha256) ||
|
|
!isRecord(sbom) || typeof sbom.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(sbom.sha256) || !isRecord(rollback) ||
|
|
typeof rollback.runbookSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(rollback.runbookSha256)) {
|
|
throw new Error("SceneIR.releaseCandidate is invalid");
|
|
}
|
|
if (value.releaseCandidateStatus !== "AVAILABLE") throw new Error("SceneIR.releaseCandidateStatus must be AVAILABLE when releaseCandidate is present");
|
|
}
|
|
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.gapInventoryStatus as string)) {
|
|
throw new Error("SceneIR.gapInventoryStatus is invalid");
|
|
}
|
|
if (value.gapInventory !== undefined) {
|
|
const inventory = value.gapInventory;
|
|
const source = isRecord(inventory) ? inventory.inventorySource : undefined;
|
|
const policy = isRecord(inventory) ? inventory.classificationPolicy : undefined;
|
|
const summary = isRecord(inventory) ? inventory.summary : undefined;
|
|
const clusters = isRecord(inventory) ? inventory.clusters : undefined;
|
|
if (!isRecord(inventory) || inventory.schemaVersion !== 1 || inventory.receiptId !== "workflow:cluster_full_blender_gap_inventory" ||
|
|
inventory.runtime !== "BLENDER_5_2_INVENTORY" || !isRecord(source) || typeof source.path !== "string" ||
|
|
typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sha256) || source.entryCount !== 6900 ||
|
|
!isRecord(policy) || policy.unprovenClass !== "METADATA_ONLY" || policy.parityLevel !== "L0" ||
|
|
!Array.isArray(policy.featureParityRequires) || policy.featureParityRequires.length !== 3 ||
|
|
!Array.isArray(policy.negativeOnlyClasses) || !Array.isArray(clusters) || clusters.length !== 15 ||
|
|
!isRecord(summary) || summary.inventoryEntries !== 6900 || summary.clusterCount !== 15 || summary.unknownEntries !== 0 ||
|
|
summary.webGapEntries !== 6900 || summary.currentParityClaims !== 0 || summary.currentParityLevel !== "L0" ||
|
|
!Array.isArray(inventory.riskRanking) || inventory.riskRanking.length !== 15 || inventory.nextTask !== "WBV2-P7-002") {
|
|
throw new Error("SceneIR.gapInventory is invalid");
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const cluster of clusters) {
|
|
if (!isRecord(cluster) || typeof cluster.capabilityId !== "string" || seen.has(cluster.capabilityId) ||
|
|
!Number.isSafeInteger(cluster.entryCount) || (cluster.entryCount as number) < 1 ||
|
|
cluster.currentParityLevel !== "L0" || cluster.completionClass !== "METADATA_ONLY" ||
|
|
cluster.evidenceStatus !== "NOT_RUN" || cluster.webStatus !== "GAP" ||
|
|
!Number.isSafeInteger(cluster.riskScore) || !Array.isArray(cluster.representativeIds) || cluster.representativeIds.length < 1) {
|
|
throw new Error("SceneIR.gapInventory cluster is invalid");
|
|
}
|
|
seen.add(cluster.capabilityId);
|
|
}
|
|
if (seen.size !== inventory.riskRanking.length || inventory.riskRanking.some((id) => typeof id !== "string" || !seen.has(id))) {
|
|
throw new Error("SceneIR.gapInventory risk ranking is invalid");
|
|
}
|
|
if (value.gapInventoryStatus !== "AVAILABLE") throw new Error("SceneIR.gapInventoryStatus must be AVAILABLE when gapInventory is present");
|
|
}
|
|
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.atomicGapQueueStatus as string)) {
|
|
throw new Error("SceneIR.atomicGapQueueStatus is invalid");
|
|
}
|
|
if (value.atomicGapQueue !== undefined) {
|
|
const queue = value.atomicGapQueue;
|
|
const parent = isRecord(queue) ? queue.parent : undefined;
|
|
const details = isRecord(queue) ? queue.queue : undefined;
|
|
const tasks = isRecord(queue) ? queue.tasks : undefined;
|
|
if (!isRecord(queue) || queue.schemaVersion !== 1 || queue.receiptId !== "workflow:generate_atomic_family_gap_queue" ||
|
|
queue.runtime !== "BLENDER_5_2_INVENTORY" || !isRecord(parent) || parent.task !== "WBV2-P7-001" ||
|
|
typeof parent.manifestPath !== "string" || typeof parent.receiptPath !== "string" ||
|
|
typeof parent.manifestSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(parent.manifestSha256) ||
|
|
typeof parent.receiptSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(parent.receiptSha256) ||
|
|
!isRecord(details) || details.activeCount !== 1 || details.plannedCount !== 44 ||
|
|
typeof details.activeTaskId !== "string" || !Array.isArray(details.behaviors) || details.behaviors.length !== 3 ||
|
|
!Array.isArray(details.implementationClasses) || details.implementationClasses.length !== 1 ||
|
|
details.implementationClasses[0] !== "LOCAL_BOUNDED" || !Array.isArray(tasks) || tasks.length !== 45 ||
|
|
queue.nextTask !== "WBV2-P7-003") {
|
|
throw new Error("SceneIR.atomicGapQueue is invalid");
|
|
}
|
|
const ids = new Set<string>();
|
|
for (const [index, task] of tasks.entries()) {
|
|
if (!isRecord(task) || typeof task.id !== "string" || ids.has(task.id) ||
|
|
typeof task.parityId !== "string" || task.parityId !== task.capabilityId ||
|
|
task.implementationClass !== "LOCAL_BOUNDED" || task.parentTask !== "WBV2-P7-001" ||
|
|
typeof task.parentReceiptSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(task.parentReceiptSha256) ||
|
|
task.state !== (index === 0 ? "active" : "pending") || task.ready !== (index === 0)) {
|
|
throw new Error("SceneIR.atomicGapQueue task is invalid");
|
|
}
|
|
ids.add(task.id);
|
|
}
|
|
if (details.activeTaskId !== tasks[0].id || value.atomicGapQueueStatus !== "AVAILABLE") {
|
|
throw new Error("SceneIR.atomicGapQueueStatus must be AVAILABLE for a valid queue");
|
|
}
|
|
}
|
|
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.parityReleaseGateStatus as string)) {
|
|
throw new Error("SceneIR.parityReleaseGateStatus is invalid");
|
|
}
|
|
if (value.parityReleaseGate !== undefined) {
|
|
const gate = value.parityReleaseGate;
|
|
const closure = isRecord(gate) ? gate.parityClosure : undefined;
|
|
const evidence = isRecord(gate) ? gate.evidenceHealth : undefined;
|
|
const recovery = isRecord(gate) ? gate.recoveryHealth : undefined;
|
|
const decision = isRecord(gate) ? gate.releaseDecision : undefined;
|
|
if (!isRecord(gate) || gate.schemaVersion !== 1 || gate.receiptId !== "workflow:full_parity_candidate_release_gate" ||
|
|
gate.runtime !== "BLENDER_DESKTOP_WASM_MAIN" || !isRecord(gate.inputs) || Object.keys(gate.inputs).length !== 4 ||
|
|
Object.values(gate.inputs).some((input) => !isRecord(input) || typeof input.path !== "string" || typeof input.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(input.sha256)) ||
|
|
!isRecord(closure) || closure.status !== "BLOCKED" || closure.inventoryEntries !== 6900 || closure.clusterCount !== 15 ||
|
|
closure.atomicTaskCount !== 45 || closure.currentParityLevel !== "L0" || closure.featureParityClaims !== 0 ||
|
|
!Array.isArray(closure.reasonCodes) || closure.reasonCodes.length < 1 || !isRecord(evidence) || evidence.status !== "BLOCKED" ||
|
|
evidence.historicalEvidencePreserved !== true || evidence.m12ProvenanceState !== "BLOCKED_SOURCE_LOST" ||
|
|
!Number.isSafeInteger(evidence.m12Entries) || (evidence.m12Entries as number) < 1 || !Array.isArray(evidence.reasonCodes) || evidence.reasonCodes.length < 1 ||
|
|
!isRecord(recovery) || recovery.status !== "READY" || recovery.quickCi !== "READY" || recovery.inventorySaveReopen !== "EXACT" ||
|
|
recovery.queueSaveReopen !== "EXACT" || recovery.malformedBlend !== "REJECTED_WITHOUT_MAIN_MUTATION" || !isRecord(decision) ||
|
|
decision.status !== "BLOCKED" || decision.nextTask !== null || !Array.isArray(decision.reasonCodes) || decision.reasonCodes.length < 1) {
|
|
throw new Error("SceneIR.parityReleaseGate is invalid");
|
|
}
|
|
if (value.parityReleaseGateStatus !== "AVAILABLE") throw new Error("SceneIR.parityReleaseGateStatus must be AVAILABLE when parityReleaseGate is present");
|
|
}
|
|
if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation);
|
|
if (value.geometryNodeGraphs !== undefined) {
|
|
const graphs = requireArray(value.geometryNodeGraphs, "geometryNodeGraphs");
|
|
if (graphs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs) throw new Error("SceneIR.geometryNodeGraphs exceeds the graph budget");
|
|
const graphIds = new Set<string>();
|
|
for (const [index, graph] of graphs.entries()) {
|
|
const parsed = parseGeometryNodeGraph(graph);
|
|
if (!graphIds.add(parsed.id)) throw new Error(`SceneIR.geometryNodeGraphs[${index}] has a duplicate graph ID`);
|
|
}
|
|
}
|
|
return value as unknown as SceneSnapshotIR;
|
|
}
|