Files
workinf_Blender_Wasm/web/protocol/editor-workflow.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

1704 lines
140 KiB
TypeScript

import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
import type {NativeUVPackOptions} from './uv-pack-options';
import {parseNativeUVPackTarget,type NativeUVPackTarget} from './uv-pack-target';
import {parseNativeUVBounds,type NativeUVBounds,type NativeUVCustomRegion} from './uv-custom-region';
import type { NativeViewportState } from './viewport-camera';
import { isModifierCurveProfile } from './modifier';
export const EDITOR_WORKFLOW_SCHEMA = 1 as const;
export const EDITOR_WORKFLOW_BUDGET = { maxWorkspaces: 64, maxAreas: 1_024, maxRegions: 4_096, maxSelection: 100_000, maxKeymaps: 4_096, maxPropertiesDataFields: 128 } as const;
export const EDITOR_TYPES = ["VIEW_3D", "OUTLINER", "PROPERTIES", "UV_IMAGE", "NODE", "GRAPH", "DOPE_SHEET", "NLA", "SPREADSHEET", "TIMELINE", "CLIP", "MASK", "SEQUENCER", "TEXT", "CONSOLE", "INFO", "FILE_BROWSER", "PREFERENCES", "TOPBAR", "STATUSBAR", "UNSUPPORTED"] as const;
export type EditorTypeIR = typeof EDITOR_TYPES[number];
export type EditorRegionKind = "HEADER" | "MAIN" | "TOOLBAR" | "SIDEBAR" | "FOOTER" | "OTHER";
export const EDITOR_MODES = ["OBJECT", "EDIT", "POSE", "SCULPT", "TEXTURE_PAINT", "VERTEX_PAINT", "WEIGHT_PAINT"] as const;
export type EditorMode = typeof EDITOR_MODES[number];
export const GRAPH_INTERPOLATION_MODES = [
["CONSTANT", "Constant", "Interpolation"], ["LINEAR", "Linear", "Interpolation"], ["BEZIER", "Bézier", "Interpolation"],
["SINE", "Sinusoidal", "Easing (by strength)"], ["QUAD", "Quadratic", "Easing (by strength)"],
["CUBIC", "Cubic", "Easing (by strength)"], ["QUART", "Quartic", "Easing (by strength)"], ["QUINT", "Quintic", "Easing (by strength)"],
["EXPO", "Exponential", "Easing (by strength)"], ["CIRC", "Circular", "Easing (by strength)"],
["BACK", "Back", "Dynamic Effects"], ["BOUNCE", "Bounce", "Dynamic Effects"], ["ELASTIC", "Elastic", "Dynamic Effects"],
] as const;
export type GraphInterpolation = typeof GRAPH_INTERPOLATION_MODES[number][0];
export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: boolean; active?: boolean; quadView?: boolean; toggleable?: boolean; flippable?: boolean; alignment?: 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'OTHER' }
export interface TextCursorIR { line: number; column: number }
export const UV_UNWRAP_METHODS = ['ANGLE_BASED', 'CONFORMAL', 'MINIMUM_STRETCH'] as const;
export type NativeUVTransform = { mode: 'TRANSLATE'; value: [number,number] } | (({ mode: 'SCALE'; value: [number,number] } | { mode: 'ROTATE'; angle: number }) & { center?: [number,number] });
export const UV_SELECTION_MODES = ['VERTEX', 'EDGE', 'FACE'] as const;
export const UV_STICKY_MODES = ['DISABLED', 'SHARED_LOCATION', 'SHARED_VERTEX'] as const;
export type NativeUVEdit = { operation: 'SET_PACK_TILE'; tile: number } | { operation: 'CUSTOM_REGION_SET'; bounds: NativeUVBounds } | { operation: 'CUSTOM_REGION_ENABLE'; enabled: boolean } | { operation: 'PACK_ISLANDS'; options: NativeUVPackOptions } | { operation: 'SELECT_STICKY'; mode: typeof UV_STICKY_MODES[number] } | { operation: 'SELECT_MODE'; mode: typeof UV_SELECTION_MODES[number] } | { operation: 'SELECT_SYNC' | 'SELECT_ISLAND'; enabled: boolean } |
{ operation: 'SELECT'; location: [number, number]; pixelsPerUV: number; toggle?: boolean } | { operation: 'SELECT_ALL' | 'DESELECT_ALL' | 'INVERT_SELECTION' | 'PIN' | 'UNPIN' | 'INVERT_PIN' } | { operation: 'UNWRAP'; options: NativeUVUnwrapOptions } | { operation: 'TRANSFORM'; transform: NativeUVTransform } | { operation: 'STITCH'; options: NativeUVStitchOptions };
export const UV_STITCH_BOOLEANS = ['use_limit','snap_islands','midpoint_snap','clear_seams'] as const;
export type NativeUVStitchOptions = Partial<Record<typeof UV_STITCH_BOOLEANS[number],boolean> & {mode:'VERTEX'|'EDGE';limit:number;static_island:number;active_object_index:number}>;
export const UV_UNWRAP_MARGIN_METHODS = ['SCALED', 'ADD', 'FRACTION'] as const;
export const UV_UNWRAP_BOOLEANS = ['fill_holes', 'correct_aspect', 'use_subsurf_data', 'use_original_bounds', 'no_flip', 'use_weights'] as const;
export type NativeUVUnwrapOptions = Partial<Record<typeof UV_UNWRAP_BOOLEANS[number], boolean> & {
method: typeof UV_UNWRAP_METHODS[number]; margin_method: typeof UV_UNWRAP_MARGIN_METHODS[number];
margin: number; iterations: number; weight_factor: number; weight_group: string;
}>;
export interface TextSelectionIR { anchor: TextCursorIR; focus: TextCursorIR }
export interface NativeTextEdit { operation: 'INDENT' | 'UNINDENT' | 'DUPLICATE_LINE' | 'COMMENT_TOGGLE' | 'COMMENT' | 'UNCOMMENT' | 'LINE_BREAK' | 'WHITESPACE_SPACES' | 'WHITESPACE_TABS' | 'DELETE_PREVIOUS_CHARACTER' | 'DELETE_NEXT_CHARACTER' | 'DELETE_PREVIOUS_WORD' | 'DELETE_NEXT_WORD'; dataId: string; source: string; selection: TextSelectionIR }
export interface NativeImageView { zoom: number; offsetX: number; offsetY: number; width: number; height: number; aspectX: number; aspectY: number }
export const PROPERTIES_OBJECT_BOOLEAN_FIELDS = ['hide_viewport', 'hide_render', 'hide_select',
'show_name', 'show_axis', 'show_wire', 'show_all_edges', 'show_bounds', 'use_camera_lock_parent'] as const;
export const PROPERTIES_OBJECT_ENUM_FIELDS = ['display_type', 'display_bounds_type', 'rotation_mode', 'track_axis', 'up_axis'] as const;
export const PROPERTIES_OBJECT_RELATION_FIELDS = ['use_camera_lock_parent', 'track_axis', 'up_axis'] as const;
export type PropertiesObjectRelationField = typeof PROPERTIES_OBJECT_RELATION_FIELDS[number];
export type PropertiesObjectField = typeof PROPERTIES_OBJECT_BOOLEAN_FIELDS[number] | typeof PROPERTIES_OBJECT_ENUM_FIELDS[number];
export const PROPERTIES_OBJECT_TRANSFORM_LENGTHS = {
location: 3, rotation_euler: 3, rotation_quaternion: 4, rotation_axis_angle: 4, scale: 3, dimensions: 3,
delta_location: 3, delta_rotation_euler: 3, delta_rotation_quaternion: 4, delta_scale: 3,
} as const;
export type PropertiesObjectTransform = keyof typeof PROPERTIES_OBJECT_TRANSFORM_LENGTHS;
export const PROPERTIES_OBJECT_LOCK_LENGTHS = {
lock_location: 3, lock_rotation: 3, lock_scale: 3, lock_rotation_w: 1, lock_rotations_4d: 1,
} as const;
export type PropertiesObjectLock = keyof typeof PROPERTIES_OBJECT_LOCK_LENGTHS;
export interface NativePropertiesLock {
id: PropertiesObjectLock;
label: string;
values: boolean[];
editable: boolean[];
}
export interface NativePropertiesTransform {
id: PropertiesObjectTransform;
label: string;
values: number[];
editable: boolean[];
minimum: number;
maximum: number;
}
export type PropertiesParentSetting = 'parent_vertices' | 'use_parent_final_indices' | 'parent_bone_head_tail_factor';
export interface NativePropertiesParentSetting {
id: PropertiesParentSetting;
index: number;
label: string;
value: number | boolean;
minimum: number;
maximum: number;
editable: boolean;
}
export interface NativePropertiesObject {
id: string;
name: string;
meshId?: string | null;
fields: Array<{ id: PropertiesObjectField; label: string; editable: boolean;
value: boolean | string; items: Array<{ id: string; label: string }> }>;
transforms?: NativePropertiesTransform[];
deltaTransforms?: NativePropertiesTransform[];
dimensions?: NativePropertiesTransform[];
locks?: NativePropertiesLock[];
relations?: {
parentId: string | null;
parentName: string | null;
editable: boolean;
candidates: Array<{ id: string; name: string }>;
truncated: boolean;
parenting?: {
type: string | null;
types: Array<{ id: string; label: string }>;
typeEditable: boolean;
bone: string;
boneEditable: boolean;
bones: string[];
bonesTruncated: boolean;
settings?: NativePropertiesParentSetting[];
};
};
modifiers?: Array<{
uuid: string;
type: string;
name: string;
enabled: boolean;
showViewport: boolean;
showRender: boolean;
showEditMode?: boolean;
showOnCage?: boolean;
diagnostics?: Array<{ kind: 'INFO' | 'ERROR'; message: string }>;
bind?: { bound: boolean; enabled: boolean; reason: string };
duplicate?: { enabled: boolean; reason: string };
apply?: { enabled: boolean; reason: string; requiresSingleUser: boolean; supportsAllKeyframes?: boolean; supportsMergeCustomData?: boolean };
applyAsShapeKey?: { enabled: boolean; reason: string };
copyToSelected?: { enabled: boolean; reason: string; targets: string[] };
skinActions?: Array<{ id: 'CREATE_ARMATURE' | 'ADD_SKIN_DATA' | 'MARK_LOOSE' | 'CLEAR_LOOSE' | 'MARK_ROOT' | 'EQUALIZE_RADII'; label: string; enabled: boolean; reason: string }>;
hookActions?: Array<{ id: 'RESET' | 'RECENTER' | 'SELECT' | 'ASSIGN'; label: string; enabled: boolean; reason: string }>;
dataLayoutActions?: Array<{ id: 'GENERATE_DATA_LAYERS'; label: string; enabled: boolean; reason: string }>;
multiresActions?: Array<{ id: 'DELETE_HIGHER_LEVELS' | 'SUBDIVIDE' | 'RESHAPE' | 'BASE_APPLY' | 'UNSUBDIVIDE' | 'REBUILD_SUBDIV' | 'EXTERNAL_SAVE' | 'EXTERNAL_PACK'; label: string; enabled: boolean; reason: string }>;
parameters: Record<string, unknown>;
editableParameters?: Array<{ key: string; label?: string; description?: string; editable?: boolean; nullable?: boolean; visible?: boolean; enabled?: boolean; suggestionsOnly?: boolean; maxLength?: number; unit?: 'ANGLE'; kind: 'boolean' | 'number' | 'enum' | 'enum_set' | 'object' | 'collection' | 'texture' | 'cachefile' | 'curve_mapping' | 'string' | 'profile'; value: unknown; integer: boolean; length: number; minimum?: number; maximum?: number; items?: Array<{ id: string; label: string }> }>;
}>;
modifierTypes?: Array<{ id: string; label: string; category: string; enabled: boolean; reason: string }>;
activeModifierUuid?: string | null;
constraints?: Array<{ reorderable?: boolean; name: string; type: string; enabled: boolean; influence: number; targetObjectId?: string | null; targetBoneName?: string }>;
activeConstraintName?: string | null;
stackEditable?: boolean;
constraintTargets?: Array<{ id: string; name: string; kind: 'ARMATURE' | 'CURVE' | 'MESH' | 'LATTICE' | 'OTHER'; bones: string[]; vertexGroups?: string[] }>;
poseBones?: string[];
poseConstraints?: Array<{ reorderable?: boolean; targetBoneName?: string; boneName: string; name: string; type: string; enabled: boolean; influence: number; targetObjectId?: string | null }>;
activePoseConstraint?: { boneName: string; name: string } | null;
}
export type PropertiesDataValue = boolean | number | string | number[];
export interface NativeArmatureConstraintTarget {
objectId: string;
bone: string;
weight: number;
}
export interface NativePropertiesDataField {
id: string;
label: string;
description?: string;
editable: boolean;
value: PropertiesDataValue;
integer?: boolean;
arrayLayout?: 'COLOR' | 'VECTOR' | 'MATRIX' | 'SEQUENCE';
minimum?: number;
maximum?: number;
status?: string;
items: Array<{ id: string; label: string }>;
}
export interface NativePropertiesData {
lightgroups?: { sceneId: string; layerName: string; editable: boolean; activeIndex: number; rows: string[] };
aovs?: { sceneId: string; layerName: string; editable: boolean; activeIndex: number; rows: Array<{ name: string; type: 0 | 1; isValid: boolean }> };
id: string;
name: string;
fields: NativePropertiesDataField[];
shapeKeys?: {
objectId: string;
keyId: string;
activeUid: number;
editable: boolean;
rows: Array<{ uid: number; name: string; selected: boolean; muted: boolean; locked: boolean; fields?: NativePropertiesDataField[] }>;
operations: Array<{ id: string; label: string; enabled: boolean; reason: string; sources?: string[] }>;
};
shapeKeyMirror?: {
objectId: string; keyId: string; activeUid: number; name: string;
enabled: boolean; topologyEnabled: boolean; reason: string;
};
constraintOperations?: Array<{ id: string; label: string; description: string; enabled: boolean; reason: string; menu?: boolean;
parameters?: Array<{ id: string; label: string; description: string; defaultValue: number; minimum: number; maximum: number }> }>;
armatureTargets?: NativeArmatureConstraintTarget[];
targetsEditable?: boolean;
armatures?: Array<{ id: string; name: string; bones: string[] }>;
cacheLayers?: Array<{ filepath: string; hidden: boolean }>;
cacheActiveLayer?: number;
cacheLayersEditable?: boolean;
}
export interface NativePropertiesContext {
context: string | null;
contexts: Array<{ id: string; label: string }>;
viewLayers?: Array<{ name: string; active: boolean; editable: boolean }>;
pinned: boolean;
canPin: boolean;
pinId: string | null;
pinName: string | null;
outlinerSync?: { value: string | null; items: Array<{ id: string; label: string }>; editable: boolean };
object?: NativePropertiesObject | null;
data?: NativePropertiesData | null;
}
export interface NativeSpreadsheetRule {
enabled: boolean;
show_expanded: boolean;
column_name: string;
operation: 'EQUAL' | 'GREATER' | 'LESS';
value_float: number;
value_int: number;
value_float2: [number, number];
value_float3: [number, number, number];
value_float4: [number, number, number, number];
value_color: [number, number, number, number];
value_int2: [number, number];
value_int3: [number, number, number];
value_string: string;
value_boolean: boolean;
threshold: number;
}
export const SPREADSHEET_CONTEXT_PROPERTIES = ['object_eval_state', 'geometry_component_type', 'attribute_domain'] as const;
export type SpreadsheetContextProperty = typeof SPREADSHEET_CONTEXT_PROPERTIES[number];
export const SPREADSHEET_VALUE_PROPERTIES = ['value_float', 'value_int', 'value_boolean', 'value_string',
'value_float2', 'value_float3', 'value_float4', 'value_color', 'value_int2', 'value_int3'] as const;
export interface NativeSpreadsheetColumn {
name: string;
valueProperty: typeof SPREADSHEET_VALUE_PROPERTIES[number] | null;
comparison: boolean;
threshold: boolean;
}
export type SpreadsheetClosure = 'NONE' | 'INPUT' | 'OUTPUT';
export type SpreadsheetNavigationTarget = 'VIEWER_OUTPUTS' | 'VIEWER_BUNDLE_CHILDREN' | 'GEOMETRY_BUNDLE_CHILDREN' | 'INSTANCE_REFERENCES';
export interface NativeSpreadsheetBundleNavigation {
active: boolean;
available: boolean;
path: string[];
pathValid: boolean;
closure: SpreadsheetClosure;
hasInputs: boolean;
hasOutputs: boolean;
children: string[];
childrenTruncated: boolean;
childrenPage: number;
childrenPageCount: number;
}
export interface NativeSpreadsheetViewerNavigation {
available: boolean;
itemIdentifier: number;
items: Array<{ identifier: number; label: string }>;
itemsTruncated: boolean;
itemsPage: number;
itemsPageCount: number;
selection: NativeSpreadsheetBundleNavigation;
}
export interface NativeSpreadsheetContext {
bundle?: NativeSpreadsheetBundleNavigation;
viewer?: NativeSpreadsheetViewerNavigation;
useFilter: boolean;
rules: NativeSpreadsheetRule[];
instances?: {
path: number[];
breadcrumbs: string[];
geometryAvailable: boolean;
pathValid: boolean;
children: Array<{ referenceIndex: number; label: string; userCount: number }>;
childrenTruncated: boolean;
childrenPage: number;
childrenPageCount: number;
};
viewState?: {
pinned: boolean;
canPin: boolean;
showOnlySelected: boolean;
selectionFilterAvailable: boolean;
dataSourceAvailable: boolean;
};
context?: Record<SpreadsheetContextProperty, { value: string | null; choices: Array<{ id: string; label: string }> }>;
columns?: NativeSpreadsheetColumn[];
columnsTruncated?: boolean;
}
export type SpreadsheetRuleEdit = { [K in keyof NativeSpreadsheetRule]: {
operation: 'UPDATE_RULE'; index: number; property: K; value: NativeSpreadsheetRule[K];
} }[keyof NativeSpreadsheetRule];
export type NativeSpreadsheetEdit =
| { operation: 'ADD_RULE' }
| { operation: 'REMOVE_RULE'; index: number }
| { operation: 'SET_FILTER'; enabled: boolean }
| { operation: 'SET_PIN'; pinned: boolean }
| { operation: 'SET_SELECTED_ONLY'; enabled: boolean }
| { operation: 'SET_INSTANCE_PATH'; path: number[] }
| { operation: 'SET_BUNDLE_PATH'; path: string[]; closure: SpreadsheetClosure }
| { operation: 'SET_VIEWER_PATH'; itemIdentifier: number; path: string[]; closure: SpreadsheetClosure }
| { operation: 'SET_NAVIGATION_PAGE'; target: SpreadsheetNavigationTarget; page: number }
| { operation: 'SET_CONTEXT'; property: SpreadsheetContextProperty; value: string }
| SpreadsheetRuleEdit;
export function validSpreadsheetBundlePath(value: unknown): value is string[] {
return Array.isArray(value) && value.length <= 64 && value.every(key =>
typeof key === 'string' && key.length > 0 && !key.includes('\0') &&
new TextEncoder().encode(key).byteLength <= 4096);
}
export function validSpreadsheetBundleSelection(
bundle: NativeSpreadsheetBundleNavigation, path: unknown, closure: unknown,
): boolean {
if (!bundle.available || !validSpreadsheetBundlePath(path) ||
!['NONE', 'INPUT', 'OUTPUT'].includes(closure as string)) return false;
const ancestor = path.length <= bundle.path.length && path.every((key, depth) => key === bundle.path[depth]);
if (closure === 'NONE' && ancestor) return true;
if (!bundle.pathValid) return false;
const same = ancestor && path.length === bundle.path.length;
if (same && ((closure === 'INPUT' && bundle.hasInputs) || (closure === 'OUTPUT' && bundle.hasOutputs))) return true;
return closure === 'NONE' && path.length === bundle.path.length + 1 &&
bundle.path.every((key, depth) => key === path[depth]) && bundle.children.includes(path[path.length - 1]);
}
export function validSpreadsheetInstancePath(value: unknown): value is number[] {
return Array.isArray(value) && value.length <= 64 &&
value.every(index => Number.isInteger(index) && index >= 0 && index <= 2147483647);
}
export function validSpreadsheetViewerSelection(
viewer: NativeSpreadsheetViewerNavigation, itemIdentifier: unknown, path: unknown, closure: unknown,
): boolean {
if (!viewer.available || !viewer.selection.active || !Number.isInteger(itemIdentifier) ||
!viewer.items.some(item => item.identifier === itemIdentifier) ||
!validSpreadsheetBundlePath(path)) return false;
if (path.length === 0 && closure === 'NONE') return true;
return itemIdentifier === viewer.itemIdentifier &&
validSpreadsheetBundleSelection(viewer.selection, path, closure);
}
export function validSpreadsheetRuleValue(property: string, value: unknown): boolean {
const numeric = (item: unknown, integral = false) => typeof item === 'number' && Number.isFinite(item) &&
(integral ? Number.isInteger(item) && item >= -2147483648 && item <= 2147483647 : Math.abs(item) <= 3.4028234663852886e38);
if (['enabled', 'show_expanded', 'value_boolean'].includes(property)) return typeof value === 'boolean';
if (property === 'column_name' || property === 'value_string') return typeof value === 'string' && !value.includes('\0') &&
new TextEncoder().encode(value).byteLength <= (property === 'column_name' ? 63 : 4096);
if (property === 'operation') return typeof value === 'string' && ['EQUAL', 'GREATER', 'LESS'].includes(value);
if (property === 'value_int') return numeric(value, true);
if (property === 'value_float' || property === 'threshold') return numeric(value) && (property !== 'threshold' || (value as number) >= 0);
const sizes: Record<string, number> = { value_float2: 2, value_float3: 3, value_float4: 4, value_color: 4, value_int2: 2, value_int3: 3 };
const size = sizes[property];
return size !== undefined && Array.isArray(value) && value.length === size && value.every(item => numeric(item, property.startsWith('value_int')));
}
export type NativeImageViewEdit = { dataId: string; viewport: { width: number; height: number } } & (
| { operation: 'FRAME_ALL' | 'FIT' }
| { operation: 'ZOOM_RATIO'; ratio: number }
| { operation: 'PAN'; pixelDelta: [number, number] }
| { operation: 'ZOOM_IN' | 'ZOOM_OUT'; pixelPosition: [number, number] }
| { operation: 'ZOOM_BORDER'; pixelBounds: [number, number, number, number]; zoomOut: boolean }
);
export interface NativeAreaLayout {
editorType: string;
editorTypes: { id: string; label: string }[];
joinTargets: string[];
editable: boolean;
screenState: 'NORMAL' | 'MAXIMIZED' | 'FULLSCREEN';
headerMenusVisible?: boolean;
}
export interface NativeOutlinerRow { id: string; label: string; depth: number; selected: boolean; active: boolean; expanded: boolean; hasChildren: boolean; objectId: string | null; remapId?: string | null }
export interface NativeOutlinerRemapID { id: string; name: string; idType: number; library: string; editable: boolean }
export interface NativeOutlinerTree { rows: NativeOutlinerRow[]; truncated: boolean; syncSelection: boolean; remapIds?: NativeOutlinerRemapID[] }
export interface EditorAreaIR { nativeLayout?: NativeAreaLayout; id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean; filterText?: string; space?: { outliner?: NativeOutlinerTree; kind: string; dataId?: string | null; view?: NativeViewportState; textSelection?: TextSelectionIR; imageView?: NativeImageView; uvPackTarget?: NativeUVPackTarget; properties?: NativePropertiesContext; spreadsheet?: NativeSpreadsheetContext } }
export interface EditorWorkspaceIR { id: string; name: string; screenId?: string; writable?: boolean; areas: EditorAreaIR[]; activeAreaId: string; revision: number }
// ACTION operator RNA identifiers; the native caller resolves them against the
// registered operator before changing Main.
export const TIMELINE_KEY_TYPES = {
SET_KEY_INTERPOLATION: ['CONSTANT', 'LINEAR', 'BEZIER', 'SINE', 'QUAD', 'CUBIC', 'QUART', 'QUINT', 'EXPO', 'CIRC', 'BACK', 'BOUNCE', 'ELASTIC'],
SET_KEY_EASING: ['AUTO', 'EASE_IN', 'EASE_OUT', 'EASE_IN_OUT'],
SET_KEY_HANDLE: ['FREE', 'ALIGNED', 'VECTOR', 'AUTO', 'AUTO_CLAMPED'],
SET_KEY_TYPE: ['KEYFRAME', 'BREAKDOWN', 'MOVING_HOLD', 'EXTREME', 'JITTER', 'GENERATED'],
SNAP_KEYS: ['CFRA', 'NEAREST_FRAME', 'NEAREST_SECOND', 'NEAREST_MARKER'],
MIRROR_KEYS: ['CFRA', 'MARKER', 'XAXIS'],
} as const;
export type NativeTimelineKeySetting = {
[K in keyof typeof TIMELINE_KEY_TYPES]: { operation: K; type: typeof TIMELINE_KEY_TYPES[K][number] }
}[keyof typeof TIMELINE_KEY_TYPES];
// Shared with ACTION_OT_paste's original keyframes_general.cc RNA enums.
export const TIMELINE_PASTE_OFFSETS = ['START', 'END', 'RELATIVE', 'NONE'] as const;
export const TIMELINE_PASTE_MERGES = ['MIX', 'OVER_ALL', 'OVER_RANGE', 'OVER_RANGE_ALL'] as const;
export const TIMELINE_KEY_COLUMN_MODES = ['KEYS', 'CFRA', 'MARKERS_COLUMN', 'MARKERS_BETWEEN'] as const;
export type NativeTimelineEdit =
| { operation: 'SELECT_FRAME_KEYS'; frame: number; extend: boolean; deselect: boolean }
| { operation: 'SELECT_KEY_COLUMNS'; mode: typeof TIMELINE_KEY_COLUMN_MODES[number] }
| { operation: 'SELECT_KEYS_SIDE'; side: 'LEFT' | 'RIGHT'; extend: boolean }
| NativeTimelineKeySetting
| { operation: 'COPY_KEYS' }
| { operation: 'DUPLICATE_MOVE_KEYS' | 'MOVE_KEYS'; delta: number }
| { operation: 'SCALE_KEYS'; factor: number }
| { operation: 'PASTE_KEYS'; offset: typeof TIMELINE_PASTE_OFFSETS[number];
merge: typeof TIMELINE_PASTE_MERGES[number]; flipped: boolean }
| { operation: 'DELETE_KEYS' | 'JUMP_SELECTED_KEYS' }
| { operation: 'FRAME_ALL_KEYS' | 'FRAME_SELECTED_KEYS' | 'CENTER_CURRENT_FRAME' |
'SELECT_ALL_KEYS' | 'DESELECT_ALL_KEYS' | 'INVERT_KEYS' |
'PREVIEW_SELECTED_KEYS' | 'CLEAR_PREVIEW_RANGE' }
| { operation: 'PLAY_FORWARD' | 'PLAY_REVERSE' | 'PAUSE_PLAYBACK' | 'CANCEL_PLAYBACK' }
| { operation: 'FRAME_OFFSET' | 'MOVE_MARKERS' | 'DUPLICATE_MARKERS'; delta: number }
| { operation: 'JUMP_START' | 'JUMP_END' | 'PREVIOUS_KEYFRAME' | 'NEXT_KEYFRAME' | 'PREVIOUS_MARKER' | 'NEXT_MARKER' |
'ADD_MARKER' | 'DELETE_MARKERS' | 'SELECT_ALL_MARKERS' | 'DESELECT_ALL_MARKERS' | 'INVERT_MARKERS' }
| { operation: 'RENAME_MARKER'; name: string };
// Playback needs the native WM timer/event pump; synchronous edits use registered storage.
export function isNativeTimelinePlayback(edit: NativeTimelineEdit): boolean {
return ['PLAY_FORWARD', 'PLAY_REVERSE', 'PAUSE_PLAYBACK', 'CANCEL_PLAYBACK'].includes(edit.operation);
}
export type NativeView3DNavigation =
| { operation: 'FRAME_ALL' | 'FRAME_SELECTED'; allRegions: boolean }
| { operation: 'VIEW_AXIS'; axis: 'FRONT' | 'BACK' | 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM'; alignActive: boolean }
| { operation: 'PAN' | 'ORBIT'; direction: 'LEFT' | 'RIGHT' | 'UP' | 'DOWN' }
| { operation: 'ROLL'; direction: 'LEFT' | 'RIGHT' }
| { operation: 'ZOOM'; delta: -1 | 1 }
| { operation: 'ZOOM_BORDER' }
| { operation: 'TOGGLE_PROJECTION' | 'TOGGLE_CAMERA' | 'TOGGLE_LOCAL_VIEW' | 'REMOVE_FROM_LOCAL_VIEW' };
export type NativeView3DSelection =
| { operation: 'SELECT_ALL' | 'DESELECT_ALL' | 'INVERT_SELECTION' }
| { operation: 'GP_SELECT_MODE'; mode: 'POINT' | 'STROKE' | 'SEGMENT' }
| { operation: 'GP_SELECT_PICK'; x: number; y: number; coordinateSpace?: 'WINDOW' | 'REGION'; deselectAll?: boolean }
| { operation: 'GP_SELECT_LINKED'; deselect: boolean }
| { operation: 'GP_SELECT_ALTERNATE'; deselectEnds: boolean }
| { operation: 'GP_SELECT_ENDS'; amountStart: number; amountEnd: number }
| { operation: 'GP_SELECT_SIMILAR'; mode: 'LAYER' | 'MATERIAL' | 'VERTEX_COLOR' | 'RADIUS' | 'OPACITY'; threshold: number }
| { operation: 'GP_SELECT_RANDOM'; ratio: number; seed: number }
| { operation: 'GP_SELECT_FILL' }
| { operation: 'GP_SELECT_MATERIAL'; deselect: boolean }
| { operation: 'GP_SELECT_BY_STROKE_TYPE'; type: 'STROKE' | 'FILL'; deselect: boolean }
| { operation: 'TEXT_SELECT_ALL' | 'TEXT_DESELECT_ALL' | 'TEXT_SELECT_WORD' | 'TEXT_SELECT_POINTER' }
| { operation: 'TEXT_MOVE'; extend: boolean;
type: 'LINE_BEGIN' | 'LINE_END' | 'TEXT_BEGIN' | 'TEXT_END' |
'PREVIOUS_CHARACTER' | 'NEXT_CHARACTER' | 'PREVIOUS_WORD' | 'NEXT_WORD' |
'PREVIOUS_LINE' | 'NEXT_LINE' | 'PREVIOUS_PAGE' | 'NEXT_PAGE' }
| { operation: 'SET_PAINT_MASK'; domain: 'NONE' | 'FACE' | 'VERTEX' }
| { operation: 'PAINT_HIDE'; unselected: boolean }
| { operation: 'PAINT_REVEAL'; select: boolean }
| { operation: 'PAINT_SELECT_UNGROUPED'; extend: boolean }
// WINDOW uses top-left canvas backing pixels; omitted/REGION uses native mval.
| { operation: 'SELECT_LINKED_PICK'; select: boolean; x: number; y: number; coordinateSpace?: 'WINDOW' | 'REGION' }
| { operation: 'SELECT_NEXT_ACTIVE' | 'SELECT_PREVIOUS_ACTIVE' }
| { operation: 'SELECT_EDGE_LOOPS'; delimit?: ('SEAM' | 'SHARP' | 'NGONS' | 'INNER_CORNERS' | 'OUTER_CORNERS')[] }
| { operation: 'SELECT_EDGE_RINGS'; delimit?: ('SEAM' | 'SHARP' | 'MATERIAL' | 'NGONS')[] }
| { operation: 'SELECT_BOUNDARY_LOOPS'; extend: boolean;
delimit?: ('SEAM' | 'SHARP' | 'NGONS' | 'INNER_CORNERS' | 'OUTER_CORNERS')[] }
| { operation: 'LOOP_TO_REGION'; selectBigger: boolean }
| { operation: 'REGION_TO_LOOP' }
| { operation: 'SELECT_SHORTEST_PATH'; useFaceStep: boolean; useTopologyDistance: boolean; useFill: boolean;
edgeMode?: 'SELECT' | 'SEAM' | 'SHARP' | 'CREASE' | 'BEVEL' | 'FREESTYLE';
skip: number; nth: number; offset: number }
| { operation: 'SELECT_LINKED_FLAT_FACES'; sharpnessDegrees: number }
| { operation: 'SELECT_MORE' | 'SELECT_LESS'; useFaceStep: boolean }
| { operation: 'SELECT_HIERARCHY'; direction: 'PARENT' | 'CHILD'; extend: boolean }
| { operation: 'SELECT_LINKED'; objectType: 'OBDATA' | 'MATERIAL' | 'DUPGROUP' | 'PARTICLE' | 'LIBRARY' | 'LIBRARY_OBDATA';
extend: boolean; delimit?: ('NORMAL' | 'MATERIAL' | 'SEAM' | 'SHARP' | 'UV')[] }
| { operation: 'BOX' | 'CIRCLE' }
| { operation: 'LASSO'; mode: 'SET' | 'ADD' | 'SUB' | 'XOR' | 'AND' };
export type EditorContextEdit = { workspaceId: string; screenId: string } & (
| { action: "SWITCH_WORKSPACE" }
| { action: "SET_ACTIVE_AREA"; areaId: string }
| ({ action: 'TIMELINE_EDIT'; areaId: string } & NativeTimelineEdit)
| ({ action: 'VIEW3D_NAVIGATE'; areaId: string; regionId: string } & NativeView3DNavigation)
| ({ action: 'VIEW3D_SELECT'; areaId: string; regionId: string } & NativeView3DSelection)
| { action: 'OUTLINER_REFRESH'; areaId: string }
| { action: 'OUTLINER_TOGGLE_EXPAND'; areaId: string; rowId: string; toggleAll: boolean }
| { action: 'OUTLINER_TREE_SELECT'; areaId: string; rowId: string; extend: boolean; range: boolean; activateData?: boolean; activateProperties?: boolean; recursive?: boolean }
| { action: 'OUTLINER_REMAP'; areaId: string; rowId: string; oldId: string; newId: string }
| { action: 'OUTLINER_SELECT'; areaId: string; objectId: string; extend: boolean; range: boolean }
| { action: 'OUTLINER_SELECT_ALL'; areaId: string; operation: 'SELECT' | 'DESELECT' | 'INVERT' | 'TOGGLE' }
| { action: "SET_EDITOR_TYPE"; areaId: string; editorType: string }
| { action: "SPLIT_AREA"; areaId: string; direction: 'HORIZONTAL' | 'VERTICAL'; factor: number }
| { action: "JOIN_AREA"; areaId: string; targetAreaId: string }
| { action: "SWAP_AREAS"; areaId: string; targetAreaId: string }
| { action: "MOVE_AREA_EDGE"; areaId: string; targetAreaId: string; delta: number; snap: boolean }
| { action: "TOGGLE_MAXIMIZE_AREA"; areaId: string }
| { action: "TOGGLE_FULLSCREEN_AREA"; areaId: string }
| { action: "TOGGLE_QUAD_VIEW" | "SET_ACTIVE_REGION"; areaId: string; regionId: string }
| { action: "TOGGLE_HEADER_MENUS"; areaId: string }
| { action: "RESIZE_REGION"; areaId: string; regionId: string; size: number }
| { action: "SET_TEXT" | "SET_IMAGE"; areaId: string; dataId: string | null }
| ({ action: "TEXT_EDIT"; areaId: string } & NativeTextEdit)
| ({ action: "IMAGE_VIEW"; areaId: string } & NativeImageViewEdit)
| { action: "NLA_EDIT"; areaId: string; operation:
'SELECT_ALL' | 'DESELECT_ALL' | 'INVERT_SELECTION' | 'TOGGLE_MUTE' | 'TRANSITION_ADD' | 'META_ADD' | 'META_REMOVE'
| 'TIME_WARP_ENABLE' | 'TIME_WARP_DISABLE' | 'TIME_WARP_CYCLIC_ENABLE' | 'TIME_WARP_CYCLIC_DISABLE' | 'DELETE_STRIP_TIME_KEY'
| 'INFLUENCE_ENABLE' | 'INFLUENCE_DISABLE' | 'DELETE_INFLUENCE_KEY'
| 'TRACK_SELECT_ALL' | 'TRACK_DESELECT_ALL' | 'TRACK_INVERT_SELECTION' | 'TRACK_ADD' | 'TRACK_ADD_ABOVE' | 'TRACK_DELETE'
| 'TRACK_MOVE_TOP' | 'TRACK_MOVE_UP' | 'TRACK_MOVE_DOWN' | 'TRACK_MOVE_BOTTOM'
| 'DUPLICATE' | 'DUPLICATE_LINKED' | 'DELETE' | 'SPLIT' | 'SNAP_CURRENT_FRAME' | 'SYNC_ACTION_LENGTH' | 'SYNC_SELECTED_ACTION_LENGTHS' }
| { action: "NLA_EDIT"; areaId: string; operation: 'KEY_STRIP_TIME'; stripTime: number }
| { action: "NLA_EDIT"; areaId: string; operation: 'KEY_INFLUENCE'; influence: number }
| { action: "NLA_EDIT"; areaId: string; operation: 'TWEAK_ENTER'; isolateAction: boolean; useUpperStack: boolean }
| { action: "NLA_EDIT"; areaId: string; operation: 'TWEAK_EXIT'; clearSolo: boolean }
| ({ action: "UV_EDIT"; areaId: string } & NativeUVEdit)
| { action: "SEQUENCER_EDIT"; areaId: string; operation: 'SELECT_ALL' | 'DESELECT_ALL' | 'INVERT_SELECTION' | 'MUTE' | 'UNMUTE' }
| { action: "NODE_EDIT"; areaId: string; operation: 'SELECT_ALL' | 'DESELECT_ALL' | 'INVERT_SELECTION' | 'TOGGLE_MUTE' | 'DELETE' }
| { action: "GRAPH_EDIT"; areaId: string; operation: 'SELECT_ALL' | 'DESELECT_ALL' | 'INVERT_SELECTION' | 'DELETE' }
| { action: "GRAPH_EDIT"; areaId: string; operation: 'SET_INTERPOLATION'; interpolation: GraphInterpolation }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_CONTEXT'; context: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'VIEW_LAYER_OPERATION'; method: 'SELECT' | 'ADD' | 'REMOVE'; target?: string; expectedLayers: string[]; expectedActive: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_PIN'; pinned: boolean; expectedPinId?: string | null }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_OUTLINER_SYNC'; value: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_FRAME_RATE'; context: 'OUTPUT' | 'RENDER'; dataId: string; fps: number; base: number; expectedFps: number; expectedBase: number }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_TIME_REMAP'; context: 'OUTPUT'; dataId: string; old: number; new: number; expectedOld: number; expectedNew: number }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'LIGHTGROUP_SYNC'; context: 'VIEW_LAYER'; dataId: string; layerName: string; method: 'ADD_USED' | 'REMOVE_UNUSED'; expectedGroups: string[] }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'LIGHTGROUP_RENAME'; context: 'VIEW_LAYER'; dataId: string; layerName: string; expectedGroups: string[]; renames: Array<{ from: string; to: string }> }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'RENDER_VIEW_OPERATION'; context: 'OUTPUT'; dataId: string; method: 'ADD' | 'REMOVE'; expectedViews: string[]; expectedActiveIndex: number }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_CONTEXT_PROPERTY'; context: string; dataId: string; property: string; value: PropertiesDataValue; expectedValue?: PropertiesDataValue; expectedCacheFileId?: string; expectedShapeObjectId?: string; expectedKeyId?: string; expectedActiveUid?: number; shapeRowUid?: number }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CAMERA_PRESET'; context: 'DATA'; dataId: string; method: 'ADD'; name: string; useFocalLength: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CAMERA_PRESET'; context: 'DATA'; dataId: string; method: 'REMOVE'; path: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CAMERA_CUSTOM_UPDATE'; context: 'DATA'; dataId: string }
// SELECT_RANGE extends selection from expectedActiveUid without moving the anchor.
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SHAPE_KEY_OPERATION'; objectId: string; expectedKeyId: string; expectedActiveUid: number; method: string; uid?: number; expectedOrder?: number[]; expectedSelection?: number[]; dropLocation?: 'BEFORE' | 'AFTER'; expectedSources?: string[]; transferMode?: 'OFFSET' | 'RELATIVE_FACE' | 'RELATIVE_EDGE'; useClamp?: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'RELOAD_CACHE'; context: string; dataId: string; expectedCacheFileId: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CACHE_OPEN'; dataId: string; expectedCacheFileId: string; path: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CACHE_LAYER'; dataId: string; expectedCacheFileId: string;
expectedLayers: Array<{ filepath: string; hidden: boolean }>; expectedActiveLayer: number;
method: 'ADD' | 'SELECT' | 'REMOVE' | 'MOVE_UP' | 'MOVE_DOWN' | 'SET_PATH' | 'SET_HIDE'; index: number; value?: string | boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CONSTRAINT_OPERATION'; context: string; dataId: string; operator: string; parameters?: Record<string, number> }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'EDIT_ARMATURE_TARGET'; dataId: string; method: 'ADD' | 'REMOVE' | 'CLEAR' | 'MOVE' | 'SET'; expectedTargets: NativeArmatureConstraintTarget[]; index?: number; step?: -1 | 1; target?: NativeArmatureConstraintTarget }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_ACTIVE_MODIFIER'; objectId: string; modifierUuid: string | null; expectedModifierUuid?: string | null }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'MODIFIER_BIND'; objectId: string; modifierUuid: string; expectedBound: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'DUPLICATE_MODIFIER'; objectId: string; modifierUuid: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'APPLY_MODIFIER'; objectId: string; modifierUuid: string; singleUser: boolean; mergeCustomData: boolean; allKeyframes?: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'APPLY_MODIFIER_AS_SHAPE_KEY'; objectId: string; modifierUuid: string; keepModifier: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SHAPE_KEY_MIRROR'; objectId: string; keyId: string; activeUid: number; useTopology: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'COPY_MODIFIER_TO_SELECTED'; objectId: string; modifierUuid: string; expectedTargets: string[] }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'HOOK_OPERATION'; objectId: string; modifierUuid: string; operator: 'RESET' | 'RECENTER' | 'SELECT' | 'ASSIGN' }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SKIN_OPERATION'; objectId: string; modifierUuid: string; operator: 'CREATE_ARMATURE' | 'ADD_SKIN_DATA' | 'MARK_LOOSE' | 'CLEAR_LOOSE' | 'MARK_ROOT' | 'EQUALIZE_RADII' }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'DATA_LAYOUT_OPERATION'; objectId: string; modifierUuid: string; operator: 'GENERATE_DATA_LAYERS' }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'MULTIRES_OPERATION'; objectId: string; modifierUuid: string; operator: 'DELETE_HIGHER_LEVELS' | 'SUBDIVIDE' | 'RESHAPE' | 'BASE_APPLY' | 'UNSUBDIVIDE' | 'REBUILD_SUBDIV' | 'EXTERNAL_SAVE' | 'EXTERNAL_PACK'; path?: string }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_ACTIVE_CONSTRAINT'; objectId: string; constraintName: string | null; expectedConstraintName?: string | null }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_ACTIVE_POSE_CONSTRAINT'; objectId: string; boneName: string; constraintName: string | null; expectedBoneName?: string | null; expectedConstraintName?: string | null }
| { action: 'PROPERTIES_EDIT'; areaId: string; operation: 'SET_OBJECT_PROPERTY'; objectId: string; property: PropertiesObjectField; value: boolean | string; expectedValue?: boolean | string; expectedParentId?: string | null }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_OBJECT_TRANSFORM'; objectId: string; property: PropertiesObjectTransform; index: number; value: number }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_OBJECT_LOCK'; objectId: string; property: PropertiesObjectLock; index: number; value: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_OBJECT_PARENT'; objectId: string; expectedParentId: string | null; parentId: string; keepTransform: boolean }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'CLEAR_OBJECT_PARENT'; objectId: string; expectedParentId: string; mode: 'CLEAR' | 'CLEAR_KEEP_TRANSFORM' | 'CLEAR_INVERSE' }
| { action: "PROPERTIES_EDIT"; areaId: string; operation: 'SET_OBJECT_PARENT_TYPE' | 'SET_OBJECT_PARENT_BONE'; objectId: string; expectedParentId: string; expectedParentType: string | null; expectedParentBone: string; value: string }
| { action: 'PROPERTIES_EDIT'; areaId: string; operation: 'SET_OBJECT_PARENT_SETTING'; objectId: string; expectedParentId: string; expectedParentType: string | null; expectedParentBone: string; property: PropertiesParentSetting; index: number; expectedValue: number | boolean; value: number | boolean }
| ({ action: 'SPREADSHEET_EDIT'; areaId: string } & NativeSpreadsheetEdit)
| { action: "SET_VIEW"; areaId: string; view: NativeViewportState }
| { action: "TOGGLE_REGION"; areaId: string; regionId: string; visible: boolean }
| { action: "FLIP_REGION"; areaId: string; regionId: string }
);
export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number }
export interface KeymapBindingIR {
id: string;
key: string;
modifiers: string[];
command: string;
enabled: boolean;
workspaceIds?: string[];
editors?: EditorTypeIR[];
modes?: EditorMode[];
}
// Native directed UV edge: start/end coordinates, start selection, edge selection, start pin.
export type NativeUVEdge = [number, number, number, number, boolean, boolean, boolean];
export interface NativeUVSelection { objects: Array<{ objectId: string; uvMap: string; edges: NativeUVEdge[] }>; syncSelection: boolean; truncated: boolean; selectionMode?: typeof UV_SELECTION_MODES[number]; meshSelectMode?: number; islandSelection?: boolean; stickyMode?: typeof UV_STICKY_MODES[number]; customRegion?: NativeUVCustomRegion }
export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[]; uvSelection?: NativeUVSelection }
export interface KeyChordIR { key: string; modifiers: Array<"ALT" | "CTRL" | "META" | "SHIFT"> }
export type EditorWorkflowEditIR =
| { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string }
| { type: "SET_ACTIVE_AREA"; revision: number; areaId: string }
| { type: "SET_SELECTION"; revision: number; selectedIds: string[]; activeObjectId: string | null }
| { type: "TOGGLE_REGION"; revision: number; areaId: string; regionId: string; visible: boolean };
export class EditorWorkflowValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "EditorWorkflowValidationError"; this.code = code; }
}
const EDITOR_REGION_KINDS = new Set<EditorRegionKind>(["HEADER", "MAIN", "TOOLBAR", "SIDEBAR", "FOOTER", "OTHER"]);
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); return value; }
function finite(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; }
function parseSpreadsheetBundleNavigation(bundle: unknown): NativeSpreadsheetBundleNavigation {
if (!record(bundle) || !validSpreadsheetBundlePath(bundle.path) ||
typeof bundle.active !== 'boolean' || typeof bundle.available !== 'boolean' ||
typeof bundle.pathValid !== 'boolean' || typeof bundle.hasInputs !== 'boolean' ||
typeof bundle.hasOutputs !== 'boolean' || typeof bundle.childrenTruncated !== 'boolean' ||
typeof bundle.childrenPage !== 'number' || typeof bundle.childrenPageCount !== 'number' ||
!Number.isSafeInteger(bundle.childrenPage) || !Number.isSafeInteger(bundle.childrenPageCount) ||
bundle.childrenPage < 0 || bundle.childrenPageCount < 0 || bundle.childrenPage > 4095 ||
bundle.childrenPageCount > 4096 ||
!['NONE', 'INPUT', 'OUTPUT'].includes(bundle.closure as string) ||
!Array.isArray(bundle.children) || bundle.children.length > 256 ||
bundle.children.some(key => !validSpreadsheetBundlePath([key])) ||
new Set(bundle.children).size !== bundle.children.length ||
(bundle.pathValid && !bundle.available) ||
(!bundle.available && (bundle.path.length > 0 || bundle.children.length > 0)) ||
(bundle.childrenPageCount === 0 && bundle.childrenPage !== 0) ||
(bundle.childrenPageCount > 0 && bundle.childrenPage >= bundle.childrenPageCount) ||
(bundle.pathValid && bundle.closure === 'INPUT' && !bundle.hasInputs) ||
(bundle.pathValid && bundle.closure === 'OUTPUT' && !bundle.hasOutputs)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet bundle navigation');
}
return {
active: bundle.active, available: bundle.available, path: [...bundle.path],
pathValid: bundle.pathValid, closure: bundle.closure as SpreadsheetClosure,
hasInputs: bundle.hasInputs, hasOutputs: bundle.hasOutputs,
children: [...bundle.children] as string[], childrenTruncated: bundle.childrenTruncated,
childrenPage: bundle.childrenPage, childrenPageCount: bundle.childrenPageCount,
};
}
function rect(value: unknown, name: string): EditorAreaIR["rect"] {
if (!record(value)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`);
const next = { x: finite(value.x, `${name}.x`, 0, 1), y: finite(value.y, `${name}.y`, 0, 1), width: finite(value.width, `${name}.width`, 0.0001, 1), height: finite(value.height, `${name}.height`, 0.0001, 1) };
if (next.x + next.width > 1 || next.y + next.height > 1) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} exceeds workspace bounds`);
return next;
}
function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }
function scopeOverlaps<T>(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean { return !left?.length || !right?.length || left.some((value) => right.includes(value)); }
function keymapScopesOverlap(left: KeymapBindingIR, right: KeymapBindingIR): boolean { return scopeOverlaps(left.workspaceIds, right.workspaceIds) && scopeOverlaps(left.editors, right.editors) && scopeOverlaps(left.modes, right.modes); }
export function parseNativeUVTransform(value: unknown): NativeUVTransform {
if (!record(value) || !['TRANSLATE','ROTATE','SCALE'].includes(value.mode as string)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'UV transform mode is invalid');
const number = (item: unknown) => typeof item === 'number' && Number.isFinite(item) && Number.isFinite(Math.fround(item));
const vector = (item: unknown) => Array.isArray(item) && item.length === 2 && item.every(number);
const allowed = value.mode === 'ROTATE' ? ['mode','angle','center'] : value.mode === 'SCALE' ? ['mode','value','center'] : ['mode','value'];
if (Object.keys(value).some(key => !allowed.includes(key)) || (value.mode === 'ROTATE' ? !number(value.angle) : !vector(value.value)) || (value.center !== undefined && !vector(value.center))) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'UV transform requires finite native values and a valid center');
return value as unknown as NativeUVTransform;
}
export function parseNativeUVStitchOptions(value: unknown): NativeUVStitchOptions {
if (!record(value) || Object.keys(value).length > 8) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'UV Stitch options must be a bounded object');
for (const [key,option] of Object.entries(value)) {
const valid = (UV_STITCH_BOOLEANS as readonly string[]).includes(key) ? typeof option === 'boolean'
: key === 'mode' ? option === 'VERTEX' || option === 'EDGE'
: key === 'limit' ? typeof option === 'number' && Number.isFinite(option) && Number.isFinite(Math.fround(option)) && option >= 0
: key === 'static_island' || key === 'active_object_index' ? typeof option === 'number' && Number.isSafeInteger(option) && option >= 0 && option <= 0x7fffffff : false;
if (!valid) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `Invalid UV Stitch option: ${key}`);
}
return {...value} as NativeUVStitchOptions;
}
export function parseNativeUVUnwrapOptions(value: unknown): NativeUVUnwrapOptions {
if (!record(value) || Object.keys(value).length > 12) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'UV Unwrap options must be a bounded object');
for (const [key, option] of Object.entries(value)) {
let valid = false;
if ((UV_UNWRAP_BOOLEANS as readonly string[]).includes(key)) valid = typeof option === 'boolean';
else if (key === 'method') valid = typeof option === 'string' && (UV_UNWRAP_METHODS as readonly string[]).includes(option);
else if (key === 'margin_method') valid = typeof option === 'string' && (UV_UNWRAP_MARGIN_METHODS as readonly string[]).includes(option);
else if (key === 'margin') valid = typeof option === 'number' && Number.isFinite(option) && option >= 0 && option <= 1;
else if (key === 'iterations') valid = typeof option === 'number' && Number.isSafeInteger(option) && option >= 0 && option <= 10000;
else if (key === 'weight_factor') valid = typeof option === 'number' && Number.isFinite(option) && Math.abs(option) <= 10000;
else if (key === 'weight_group') valid = typeof option === 'string' && !option.includes('\0') && new TextEncoder().encode(option).byteLength < 64;
if (!valid) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `Invalid UV Unwrap option: ${key}`);
}
return { ...value } as NativeUVUnwrapOptions;
}
function parseUVSelection(value: unknown): NativeUVSelection | undefined {
if (value === undefined) return undefined;
if (!record(value) || !Array.isArray(value.objects) || value.objects.length > 128 || typeof value.syncSelection !== 'boolean' || typeof value.truncated !== 'boolean') throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Native UV selection metadata is invalid');
if ((value.selectionMode !== undefined && !UV_SELECTION_MODES.includes(value.selectionMode as typeof UV_SELECTION_MODES[number])) ||
(value.meshSelectMode !== undefined && (typeof value.meshSelectMode !== 'number' || !Number.isInteger(value.meshSelectMode) || value.meshSelectMode < 1 || value.meshSelectMode > 7)) ||
(value.islandSelection !== undefined && typeof value.islandSelection !== 'boolean')) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Native UV selection settings are invalid');
let edgeCount = 0;
let customRegion: NativeUVCustomRegion | undefined;
if (value.customRegion !== undefined) {
if (!record(value.customRegion) || typeof value.customRegion.enabled !== 'boolean') throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Native UV custom region is invalid');
customRegion = {enabled:value.customRegion.enabled,bounds:parseNativeUVBounds(value.customRegion.bounds,false)};
}
if (value.stickyMode !== undefined && !UV_STICKY_MODES.includes(value.stickyMode as typeof UV_STICKY_MODES[number])) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Native UV sticky selection mode is invalid');
const ids = new Set<string>(), objects: NativeUVSelection['objects'] = [];
for (const item of value.objects) {
if (!record(item) || !Array.isArray(item.edges) || (edgeCount += item.edges.length) > 16384) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_BUDGET_EXCEEDED', 'Native UV edges exceed the budget or have invalid rows');
const objectId = text(item.objectId, 'uvSelection.objectId'), uvMap = text(item.uvMap, 'uvSelection.uvMap');
if (ids.has(objectId)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate native UV object');
ids.add(objectId);
const edges: NativeUVEdge[] = [];
for (const edge of item.edges) {
if (!Array.isArray(edge) || edge.length !== 7 || edge.slice(0,4).some(n => typeof n !== 'number' || !Number.isFinite(n)) || edge.slice(4).some(flag => typeof flag !== 'boolean')) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Native UV edge tuple is invalid');
edges.push([...edge] as NativeUVEdge);
}
objects.push({ objectId, uvMap, edges });
}
return { objects, syncSelection: value.syncSelection, truncated: value.truncated,
selectionMode: value.selectionMode as NativeUVSelection['selectionMode'], meshSelectMode: value.meshSelectMode as number | undefined,
islandSelection: value.islandSelection as boolean | undefined, stickyMode: value.stickyMode as NativeUVSelection['stickyMode'], customRegion };
}
export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema");
if (value.workspaces.length > EDITOR_WORKFLOW_BUDGET.maxWorkspaces || value.keymaps.length > EDITOR_WORKFLOW_BUDGET.maxKeymaps) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Editor workflow exceeds the budget");
let areaCount = 0; let regionCount = 0; const workspaceIds = new Set<string>();
const workspaces = value.workspaces.map((workspaceValue, workspaceIndex): EditorWorkspaceIR => {
const name = `workspaces[${workspaceIndex}]`; if (!record(workspaceValue) || !Array.isArray(workspaceValue.areas)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`);
const id = text(workspaceValue.id, `${name}.id`); if (workspaceIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate workspace ${id}`); workspaceIds.add(id);
areaCount += workspaceValue.areas.length; if (areaCount > EDITOR_WORKFLOW_BUDGET.maxAreas) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Area count exceeds the budget");
const areaIds = new Set<string>();
const areas = workspaceValue.areas.map((areaValue, areaIndex): EditorAreaIR => {
const areaName = `${name}.areas[${areaIndex}]`; if (!record(areaValue) || !EDITOR_TYPES.includes(areaValue.editor as EditorTypeIR) || !Array.isArray(areaValue.regions)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${areaName} is invalid`);
const areaId = text(areaValue.id, `${areaName}.id`); if (areaIds.has(areaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate area ${areaId}`); areaIds.add(areaId);
regionCount += areaValue.regions.length; if (regionCount > EDITOR_WORKFLOW_BUDGET.maxRegions) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Region count exceeds the budget");
const regionIds = new Set<string>();
const regions = areaValue.regions.map((regionValue, regionIndex): EditorRegionIR => {
const regionName = `${areaName}.regions[${regionIndex}]`;
if (!record(regionValue) || !EDITOR_REGION_KINDS.has(regionValue.kind as EditorRegionKind)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${regionName} is invalid`);
const regionId = text(regionValue.id, `${regionName}.id`);
if (regionIds.has(regionId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate region ${regionId}`);
regionIds.add(regionId);
const alignment = regionValue.alignment;
if (alignment !== undefined && !['TOP', 'BOTTOM', 'LEFT', 'RIGHT', 'OTHER'].includes(alignment as string)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${regionName}.alignment is invalid`);
return {
id: regionId, kind: regionValue.kind as EditorRegionKind, visible: regionValue.visible !== false,
active: regionValue.active === true, quadView: regionValue.quadView === true,
toggleable: regionValue.toggleable === true, flippable: regionValue.flippable === true,
alignment: alignment as EditorRegionIR['alignment'],
};
});
let nativeLayout: NativeAreaLayout | undefined;
if (areaValue.nativeLayout !== undefined) {
const layout = areaValue.nativeLayout;
if (!record(layout) || typeof layout.editorType !== 'string' || layout.editorType.length > 128 ||
!Array.isArray(layout.editorTypes) || layout.editorTypes.length > 128 ||
!Array.isArray(layout.joinTargets) || layout.joinTargets.length > EDITOR_WORKFLOW_BUDGET.maxAreas ||
typeof layout.editable !== 'boolean' ||
(layout.screenState !== undefined && !['NORMAL', 'MAXIMIZED', 'FULLSCREEN'].includes(layout.screenState as string)) ||
(layout.headerMenusVisible !== undefined && typeof layout.headerMenusVisible !== 'boolean')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.nativeLayout is invalid`);
}
nativeLayout = {
editorType: layout.editorType,
editorTypes: layout.editorTypes.map((item, index) => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native editor choice');
return { id: text(item.id, `${areaName}.editorTypes[${index}].id`, 128),
label: text(item.label, `${areaName}.editorTypes[${index}].label`, 256) };
}),
joinTargets: layout.joinTargets.map(item => text(item, `${areaName}.joinTarget`)),
editable: layout.editable,
screenState: layout.screenState as NativeAreaLayout['screenState'] ?? 'NORMAL',
headerMenusVisible: layout.headerMenusVisible as boolean | undefined,
};
}
const filterText = areaValue.filterText;
if (filterText !== undefined && (typeof filterText !== "string" || filterText.length > 64)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${areaName}.filterText is invalid`);
let space: EditorAreaIR['space'];
if (areaValue.space !== undefined) {
if (!record(areaValue.space)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.space is invalid`);
space = { kind: text(areaValue.space.kind, `${areaName}.space.kind`, 64) };
if (areaValue.space.dataId !== undefined) space.dataId = areaValue.space.dataId === null ? null : text(areaValue.space.dataId, `${areaName}.space.dataId`);
if (areaValue.space.outliner !== undefined) {
const tree = areaValue.space.outliner;
if (areaValue.editor !== 'OUTLINER' || space.kind !== 'OUTLINER' || !record(tree) ||
!Array.isArray(tree.rows) || tree.rows.length > 65536 ||
typeof tree.truncated !== 'boolean' || typeof tree.syncSelection !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native Outliner tree');
}
const rowIds = new Set<string>();
const rows = tree.rows.map((row): NativeOutlinerRow => {
if (!record(row) || !Number.isInteger(row.depth) || Number(row.depth) < 0 || Number(row.depth) > 64 ||
typeof row.label !== 'string' || row.label.length > 65536 ||
['selected', 'active', 'expanded', 'hasChildren'].some(key => typeof row[key] !== 'boolean')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native Outliner row');
}
const id = text(row.id, 'outliner.row.id', 65536);
if (rowIds.has(id)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate native Outliner row');
rowIds.add(id);
return { id, label: row.label, depth: Number(row.depth), selected: row.selected as boolean,
active: row.active as boolean, expanded: row.expanded as boolean, hasChildren: row.hasChildren as boolean,
objectId: row.objectId === null ? null : text(row.objectId, 'outliner.row.objectId'),
remapId: row.remapId == null ? null : text(row.remapId, 'outliner.row.remapId', 65536) };
});
let remapIds: NativeOutlinerRemapID[] | undefined;
if (tree.remapIds !== undefined) {
if (!Array.isArray(tree.remapIds)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Remap IDs');
remapIds = tree.remapIds.map(value => {
if (!record(value) || !Number.isInteger(value.idType) || typeof value.editable !== 'boolean' ||
typeof value.library !== 'string' || value.library.length > 65536) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Remap ID');
}
return { id: text(value.id, 'outliner.remap.id', 65536), name: text(value.name, 'outliner.remap.name', 65536),
idType: Number(value.idType), library: value.library, editable: value.editable };
});
}
space.outliner = { rows, truncated: tree.truncated, syncSelection: tree.syncSelection, remapIds };
}
if (areaValue.space.spreadsheet !== undefined) {
const spreadsheet = areaValue.space.spreadsheet;
if (areaValue.editor !== 'SPREADSHEET' || space.kind !== 'SPREADSHEET' || !record(spreadsheet) ||
typeof spreadsheet.useFilter !== 'boolean' || !Array.isArray(spreadsheet.rules)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.spreadsheet is invalid`);
}
const fields = ['enabled', 'show_expanded', 'column_name', 'operation', 'value_float', 'value_int',
'value_float2', 'value_float3', 'value_float4', 'value_color', 'value_int2', 'value_int3',
'value_string', 'value_boolean', 'threshold'] as const;
const rules = spreadsheet.rules.map((rule, index) => {
if (!record(rule) || fields.some(field => !validSpreadsheetRuleValue(field, rule[field]))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.rules[${index}] is invalid`);
}
return Object.fromEntries(fields.map(field => [field, Array.isArray(rule[field]) ? [...(rule[field] as number[])] : rule[field]])) as unknown as NativeSpreadsheetRule;
});
space.spreadsheet = { useFilter: spreadsheet.useFilter, rules };
if (spreadsheet.instances !== undefined) {
const instances = spreadsheet.instances;
if (!record(instances) || !validSpreadsheetInstancePath(instances.path) ||
!Array.isArray(instances.breadcrumbs) || instances.breadcrumbs.length !== instances.path.length + 1 ||
typeof instances.geometryAvailable !== 'boolean' || typeof instances.pathValid !== 'boolean' ||
typeof instances.childrenTruncated !== 'boolean' ||
typeof instances.childrenPage !== 'number' || typeof instances.childrenPageCount !== 'number' ||
!Number.isSafeInteger(instances.childrenPage) ||
!Number.isSafeInteger(instances.childrenPageCount) || instances.childrenPage < 0 ||
instances.childrenPageCount < 0 || instances.childrenPage > 4095 ||
instances.childrenPageCount > 4096 ||
!Array.isArray(instances.children) || instances.children.length > 256 ||
(instances.pathValid && !instances.geometryAvailable) ||
(!instances.geometryAvailable && instances.path.length !== 0) ||
(!instances.pathValid && (instances.children.length !== 0 || instances.childrenTruncated ||
instances.childrenPage !== 0 || instances.childrenPageCount !== 0)) ||
(instances.childrenPageCount === 0 && instances.childrenPage !== 0) ||
(instances.childrenPageCount > 0 && instances.childrenPage >= instances.childrenPageCount) ||
instances.childrenTruncated !== (instances.childrenPageCount > 1)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet instance navigation');
}
const children = instances.children.map(child => {
if (!record(child) || !validSpreadsheetInstancePath([child.referenceIndex]) ||
typeof child.userCount !== 'number' || !Number.isInteger(child.userCount) ||
child.userCount < 0 || child.userCount > 2147483647) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet instance reference');
}
return { referenceIndex: child.referenceIndex as number,
label: text(child.label, 'Spreadsheet instance name', 4096), userCount: child.userCount };
});
if (new Set(children.map(child => child.referenceIndex)).size !== children.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate Spreadsheet instance reference');
}
space.spreadsheet.instances = {
path: [...instances.path],
breadcrumbs: instances.breadcrumbs.map(name => text(name, 'Spreadsheet instance name', 4096)),
geometryAvailable: instances.geometryAvailable, pathValid: instances.pathValid,
children, childrenTruncated: instances.childrenTruncated,
childrenPage: instances.childrenPage, childrenPageCount: instances.childrenPageCount,
};
}
if (spreadsheet.bundle !== undefined) {
space.spreadsheet.bundle = parseSpreadsheetBundleNavigation(spreadsheet.bundle);
}
if (spreadsheet.viewer !== undefined) {
const viewer = spreadsheet.viewer;
if (!record(viewer) || typeof viewer.available !== 'boolean' ||
typeof viewer.itemsPage !== 'number' || typeof viewer.itemsPageCount !== 'number' ||
typeof viewer.itemsTruncated !== 'boolean' || !Array.isArray(viewer.items) ||
viewer.items.length > 256 || !Number.isSafeInteger(viewer.itemsPage) ||
!Number.isSafeInteger(viewer.itemsPageCount) || viewer.itemsPage < 0 ||
viewer.itemsPageCount < 0 || viewer.itemsPage > 4095 || viewer.itemsPageCount > 4096 ||
(viewer.itemsPageCount === 0 && viewer.itemsPage !== 0) ||
(viewer.itemsPageCount > 0 && viewer.itemsPage >= viewer.itemsPageCount) ||
(!viewer.available && (viewer.items.length > 0 || viewer.itemsTruncated || viewer.itemsPageCount !== 0))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet Viewer outputs');
}
const itemIdentifier = integer(viewer.itemIdentifier, 'Viewer output identifier', -2147483648, 2147483647);
const items = viewer.items.map(item => {
if (!record(item) || typeof item.label !== 'string' || item.label.length > 4096 || item.label.includes('\0')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet Viewer output');
}
return { identifier: integer(item.identifier, 'Viewer output identifier', -2147483648, 2147483647), label: item.label };
});
const selection = parseSpreadsheetBundleNavigation(viewer.selection);
if (new Set(items.map(item => item.identifier)).size !== items.length ||
(viewer.available && !selection.active) ||
(selection.available && !viewer.available)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet Viewer selection');
}
space.spreadsheet.viewer = {
available: viewer.available, itemIdentifier, items,
itemsTruncated: viewer.itemsTruncated, itemsPage: viewer.itemsPage,
itemsPageCount: viewer.itemsPageCount, selection,
};
}
if (spreadsheet.viewState !== undefined) {
const view = spreadsheet.viewState;
if (!record(view) || typeof view.pinned !== 'boolean' || typeof view.canPin !== 'boolean' ||
typeof view.showOnlySelected !== 'boolean' || typeof view.selectionFilterAvailable !== 'boolean' ||
typeof view.dataSourceAvailable !== 'boolean' ||
(view.selectionFilterAvailable && !view.dataSourceAvailable)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet view state');
}
space.spreadsheet.viewState = {
pinned: view.pinned, canPin: view.canPin, showOnlySelected: view.showOnlySelected,
selectionFilterAvailable: view.selectionFilterAvailable, dataSourceAvailable: view.dataSourceAvailable,
};
}
if (spreadsheet.context !== undefined) {
const settings = spreadsheet.context;
if (!record(settings)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.spreadsheet.context is invalid`);
const parsed = {} as NonNullable<NativeSpreadsheetContext['context']>;
for (const property of SPREADSHEET_CONTEXT_PROPERTIES) {
const setting = settings[property];
if (!record(setting) || !Array.isArray(setting.choices) || setting.choices.length > 64) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.spreadsheet.${property} is invalid`);
}
const choices = setting.choices.map(item => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet context choice');
return { id: text(item.id, 'Spreadsheet choice id', 64), label: text(item.label, 'Spreadsheet choice label', 256) };
});
const value = setting.value === null ? null : text(setting.value, 'Spreadsheet context value', 64);
if (new Set(choices.map(item => item.id)).size !== choices.length ||
(value !== null && !choices.some(item => item.id === value))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet context selection');
}
parsed[property] = { value, choices };
}
space.spreadsheet.context = parsed;
}
if (spreadsheet.columns !== undefined) {
if (!Array.isArray(spreadsheet.columns) || spreadsheet.columns.length > 512 ||
typeof spreadsheet.columnsTruncated !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet columns');
}
space.spreadsheet.columns = spreadsheet.columns.map(column => {
if (!record(column) || typeof column.comparison !== 'boolean' || typeof column.threshold !== 'boolean' ||
(column.valueProperty !== null && !(SPREADSHEET_VALUE_PROPERTIES as readonly unknown[]).includes(column.valueProperty))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Spreadsheet column type');
}
return { name: text(column.name, 'Spreadsheet column name', 4096),
valueProperty: column.valueProperty as NativeSpreadsheetColumn['valueProperty'],
comparison: column.comparison, threshold: column.threshold };
});
space.spreadsheet.columnsTruncated = spreadsheet.columnsTruncated;
}
}
if (areaValue.space.properties !== undefined) {
const properties = areaValue.space.properties;
if (areaValue.editor !== 'PROPERTIES' || space.kind !== 'PROPERTIES' || !record(properties) ||
!Array.isArray(properties.contexts) || properties.contexts.length > 64 ||
typeof properties.pinned !== 'boolean' || typeof properties.canPin !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.properties is invalid`);
}
const contexts = properties.contexts.map((item, index) => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.contexts[${index}] is invalid`);
return { id: text(item.id, `${areaName}.context.id`, 64), label: text(item.label, `${areaName}.context.label`) };
});
const context = properties.context === null ? null : text(properties.context, `${areaName}.context`, 64);
const pinId = properties.pinId === null ? null : text(properties.pinId, `${areaName}.pinId`);
const pinName = properties.pinName === null ? null : text(properties.pinName, `${areaName}.pinName`);
if (new Set(contexts.map(item => item.id)).size !== contexts.length ||
(context !== null && !contexts.some(item => item.id === context)) ||
properties.pinned !== (pinId !== null) || properties.pinned !== (pinName !== null)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.properties context or pin is inconsistent`);
}
space.properties = { context, contexts, pinned: properties.pinned, canPin: properties.canPin, pinId, pinName };
if (properties.viewLayers !== undefined) {
if (!Array.isArray(properties.viewLayers) || properties.viewLayers.length > 64)
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties view layers');
space.properties.viewLayers = properties.viewLayers.map(item => {
if (!record(item) || typeof item.active !== 'boolean' || typeof item.editable !== 'boolean')
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties view layer entry');
return { name: text(item.name, `${areaName}.viewLayer.name`, 63), active: item.active, editable: item.editable };
});
}
if (properties.outlinerSync !== undefined) {
const sync = properties.outlinerSync;
if (!record(sync) || typeof sync.editable !== 'boolean' || !Array.isArray(sync.items) || sync.items.length > 64) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties Outliner sync');
}
const items = sync.items.map(item => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties sync item');
return { id: text(item.id, `${areaName}.sync.id`, 64), label: text(item.label, `${areaName}.sync.label`) };
});
const value = sync.value === null ? null : text(sync.value, `${areaName}.sync.value`, 64);
if (new Set(items.map(item => item.id)).size !== items.length ||
(value !== null && !items.some(item => item.id === value))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Inconsistent Properties sync value');
}
space.properties.outlinerSync = { value, items, editable: sync.editable };
}
if (properties.object !== undefined && properties.object !== null) {
const object = properties.object;
if (context === null || !['OBJECT', 'MODIFIER', 'CONSTRAINT', 'BONE', 'BONE_CONSTRAINT'].includes(context) || !record(object) || !Array.isArray(object.fields) ||
object.fields.length > PROPERTIES_OBJECT_BOOLEAN_FIELDS.length + PROPERTIES_OBJECT_ENUM_FIELDS.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties object');
}
const fields = object.fields.map((field): NativePropertiesObject['fields'][number] => {
if (!record(field) || typeof field.editable !== 'boolean' || !Array.isArray(field.items) || field.items.length > 32) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties object field');
}
const id = text(field.id, 'Properties object field', 64) as PropertiesObjectField;
const items = field.items.map(item => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties field option');
return { id: text(item.id, 'Properties field option', 64), label: text(item.label, 'Properties field label') };
});
const booleanField = (PROPERTIES_OBJECT_BOOLEAN_FIELDS as readonly string[]).includes(id);
const enumField = (PROPERTIES_OBJECT_ENUM_FIELDS as readonly string[]).includes(id);
if (new Set(items.map(item => item.id)).size !== items.length ||
(!booleanField && !enumField) ||
(booleanField && (typeof field.value !== 'boolean' || items.length !== 0)) ||
(enumField && (typeof field.value !== 'string' || !items.some(item => item.id === field.value)))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties object value');
}
return { id, label: text(field.label, 'Properties field label'), editable: field.editable,
value: field.value as boolean | string, items };
});
if (new Set(fields.map(field => field.id)).size !== fields.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate Properties object field');
}
const meshId = object.meshId === undefined || object.meshId === null ? null : text(object.meshId, 'Properties mesh ID');
const parseStack = (value: unknown, label: string) => {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > 256) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `Invalid ${label}`);
return value.map((entry, index) => {
if (!record(entry)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `Invalid ${label}[${index}]`);
if (label === 'Properties modifiers') text(entry.uuid, `${label}.uuid`, 256);
const name = text(entry.name, `${label}.name`, 256);
const type = text(entry.type, `${label}.type`, 128);
if (typeof entry.enabled !== 'boolean' ||
(label === 'Properties modifiers' && (typeof entry.showViewport !== 'boolean' || typeof entry.showRender !== 'boolean' || !record(entry.parameters))) ||
(label !== 'Properties modifiers' && (typeof entry.influence !== 'number' || !Number.isFinite(entry.influence)))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `Invalid ${label} entry`);
}
if (label === 'Properties modifiers' && entry.editableParameters !== undefined) {
if (!Array.isArray(entry.editableParameters) || entry.editableParameters.length > 64) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier editable parameters');
}
for (const parameter of entry.editableParameters) {
if (!record(parameter) || typeof parameter.key !== 'string' || parameter.key.length === 0 ||
typeof parameter.kind !== 'string' || typeof parameter.length !== 'number' ||
!['boolean', 'number', 'enum', 'enum_set', 'object', 'collection', 'texture', 'cachefile', 'curve_mapping', 'string', 'profile'].includes(parameter.kind) ||
!Number.isSafeInteger(parameter.length) || parameter.length < 0 || parameter.length > 4) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier parameter metadata');
}
if ((parameter.label !== undefined && typeof parameter.label !== 'string') ||
(parameter.description !== undefined && typeof parameter.description !== 'string') ||
(parameter.visible !== undefined && typeof parameter.visible !== 'boolean') ||
(parameter.enabled !== undefined && typeof parameter.enabled !== 'boolean') ||
(parameter.editable !== undefined && typeof parameter.editable !== 'boolean') ||
(parameter.nullable !== undefined && typeof parameter.nullable !== 'boolean') ||
(parameter.display !== undefined && typeof parameter.display !== 'string') ||
(parameter.unit !== undefined && parameter.unit !== 'ANGLE')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier parameter access metadata');
}
if (parameter.kind === 'string' && (typeof parameter.value !== 'string' ||
typeof parameter.maxLength !== 'number' ||
typeof parameter.suggestionsOnly !== 'boolean' || !Number.isSafeInteger(parameter.maxLength) ||
parameter.maxLength < 0 || parameter.maxLength > 65535 || parameter.length !== 0)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier string resource metadata');
}
if (parameter.kind === 'profile' && (parameter.key !== 'custom_profile' || parameter.length !== 0 || !isModifierCurveProfile(parameter.value))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier custom profile metadata');
}
if (parameter.kind === 'curve_mapping' && (!['map_curve', 'falloff_curve'].includes(parameter.key) || parameter.length !== 0 ||
!record(parameter.value) || !Array.isArray(parameter.value.clip) || parameter.value.clip.length !== 4 ||
!parameter.value.clip.every(value => typeof value === 'number' && Number.isFinite(value)) ||
!Array.isArray(parameter.value.points) || parameter.value.points.length < 2 || parameter.value.points.length > 256 ||
!parameter.value.points.every(point => Array.isArray(point) && point.length === 2 && point.every(value => typeof value === 'number' && Number.isFinite(value))))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier CurveMapping metadata');
}
if (parameter.kind === 'curve_mapping' && record(parameter.value) &&
['useClip', 'extend', 'handles', 'wrapping'].some(key => Object.prototype.hasOwnProperty.call(parameter.value, key))) {
const curve = parameter.value;
if (typeof curve.useClip !== 'boolean' || typeof curve.wrapping !== 'boolean' ||
typeof curve.extend !== 'string' || !['HORIZONTAL', 'EXTRAPOLATED'].includes(curve.extend) ||
!Array.isArray(curve.points) || !Array.isArray(curve.handles) || curve.handles.length !== curve.points.length ||
!curve.handles.every(handle => typeof handle === 'string' && ['AUTO', 'VECTOR', 'AUTO_CLAMPED'].includes(handle))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier CurveMapping tools metadata');
}
}
if (parameter.kind === 'curve_mapping' && record(parameter.value) &&
['view', 'selected', 'samples'].some(key => Object.prototype.hasOwnProperty.call(parameter.value, key))) {
const curve = parameter.value;
if (!Array.isArray(curve.view) || curve.view.length !== 4 ||
!curve.view.every(value => typeof value === 'number' && Number.isFinite(value)) ||
curve.view[0] >= curve.view[2] || curve.view[1] >= curve.view[3] ||
!Array.isArray(curve.points) || !Array.isArray(curve.selected) ||
curve.selected.length !== curve.points.length || !curve.selected.every(value => typeof value === 'boolean') ||
!Array.isArray(curve.samples) || curve.samples.length !== 257 ||
!curve.samples.every(point => Array.isArray(point) && point.length === 2 &&
point.every(value => typeof value === 'number' && Number.isFinite(value)))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native CurveMapping graph metadata');
}
}
if (parameter.kind === 'curve_mapping' && record(parameter.value) &&
['canZoomIn', 'canZoomOut', 'active'].some(key => Object.prototype.hasOwnProperty.call(parameter.value, key))) {
const curve = parameter.value;
if (typeof curve.canZoomIn !== 'boolean' || typeof curve.canZoomOut !== 'boolean' ||
!Number.isInteger(curve.active) || Number(curve.active) < -1 ||
!Array.isArray(curve.points) || Number(curve.active) >= curve.points.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native CurveMapping view controls');
}
}
if (parameter.kind === 'enum' || parameter.kind === 'enum_set' || parameter.kind === 'object' || parameter.kind === 'collection' || parameter.kind === 'texture' || parameter.kind === 'cachefile' || parameter.kind === 'string') {
if (!Array.isArray(parameter.items) || (parameter.kind === 'enum' && parameter.items.length === 0) ||
!parameter.items.every(item => record(item) && typeof item.id === 'string' && typeof item.label === 'string')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier enum metadata');
}
}
const enumItems = parameter.items;
if (parameter.kind === 'enum_set' && (parameter.length !== 0 ||
!Array.isArray(parameter.value) || !Array.isArray(enumItems) ||
enumItems.length > 64 || new Set(enumItems.map(item => item.id)).size !== enumItems.length ||
parameter.value.length > enumItems.length ||
!parameter.value.every(value => typeof value === 'string' && enumItems.some(item => item.id === value)) ||
new Set(parameter.value).size !== parameter.value.length)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier enum flag selection');
}
if (parameter.kind === 'object' && parameter.value !== null &&
(typeof parameter.value !== 'string' || !parameter.value.startsWith('object:'))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier object resource value');
}
if (parameter.kind === 'collection' && parameter.value !== null &&
(typeof parameter.value !== 'string' || !parameter.value.startsWith('collection:'))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier Collection resource value');
}
if (parameter.kind === 'texture' && parameter.value !== null &&
(typeof parameter.value !== 'string' || !parameter.value.startsWith('texture:'))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier Texture resource value');
}
if (parameter.kind === 'cachefile' && parameter.value !== null &&
(typeof parameter.value !== 'string' || !parameter.value.startsWith('cachefile:'))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier CacheFile resource value');
}
}
}
if (label === 'Properties modifiers' && entry.diagnostics !== undefined) {
if (!Array.isArray(entry.diagnostics) || entry.diagnostics.length > 8) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier diagnostics');
}
for (const diagnostic of entry.diagnostics) {
if (!record(diagnostic) || !['INFO', 'ERROR'].includes(String(diagnostic.kind))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier diagnostic');
}
text(diagnostic.message, 'Modifier diagnostic message', 4096);
}
}
if (label === 'Properties modifiers' && entry.bind !== undefined &&
(!record(entry.bind) || !['LaplacianDeform', 'CorrectiveSmooth', 'MeshDeform', 'SurfaceDeform'].includes(type) ||
typeof entry.bind.bound !== 'boolean' || typeof entry.bind.enabled !== 'boolean' ||
typeof entry.bind.reason !== 'string' || entry.bind.reason.length > 512)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier bind action');
}
if (label === 'Properties modifiers' && entry.duplicate !== undefined &&
(!record(entry.duplicate) || typeof entry.duplicate.enabled !== 'boolean' ||
typeof entry.duplicate.reason !== 'string' || entry.duplicate.reason.length > 512)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier duplicate action');
}
if (label === 'Properties modifiers' && entry.apply !== undefined &&
(!record(entry.apply) || typeof entry.apply.enabled !== 'boolean' ||
typeof entry.apply.reason !== 'string' || entry.apply.reason.length > 512 ||
typeof entry.apply.requiresSingleUser !== 'boolean' ||
(entry.apply.supportsAllKeyframes !== undefined && typeof entry.apply.supportsAllKeyframes !== 'boolean') ||
(entry.apply.supportsMergeCustomData !== undefined && typeof entry.apply.supportsMergeCustomData !== 'boolean'))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier apply action');
}
if (label === 'Properties modifiers' && entry.applyAsShapeKey !== undefined &&
(!record(entry.applyAsShapeKey) || typeof entry.applyAsShapeKey.enabled !== 'boolean' ||
typeof entry.applyAsShapeKey.reason !== 'string' || entry.applyAsShapeKey.reason.length > 512)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier shape key action');
}
if (label === 'Properties modifiers' && entry.copyToSelected !== undefined) {
const copy = entry.copyToSelected;
if (!record(copy) || typeof copy.enabled !== 'boolean' ||
typeof copy.reason !== 'string' || copy.reason.length > 512 ||
!Array.isArray(copy.targets) || copy.targets.length > 1024 ||
copy.targets.some((id: unknown) => typeof id !== 'string' || !id.startsWith('id:OB') || id.length > 256)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier copy destinations');
}
}
if (label === 'Properties modifiers' && entry.hookActions !== undefined) {
const ids = ['RESET', 'RECENTER', 'SELECT', 'ASSIGN'];
if (type !== 'Hook' || !Array.isArray(entry.hookActions) || entry.hookActions.length !== ids.length ||
entry.hookActions.some((action: unknown, actionIndex: number) => !record(action) || action.id !== ids[actionIndex] ||
typeof action.enabled !== 'boolean' || typeof action.reason !== 'string' || action.reason.length > 512 ||
typeof action.label !== 'string' || action.label.length > 128)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native Hook actions');
}
}
if (label === 'Properties modifiers' && entry.skinActions !== undefined) {
const ids = ['CREATE_ARMATURE', 'ADD_SKIN_DATA', 'MARK_LOOSE', 'CLEAR_LOOSE', 'MARK_ROOT', 'EQUALIZE_RADII'];
if (type !== 'Skin' || !Array.isArray(entry.skinActions) || entry.skinActions.length !== ids.length ||
entry.skinActions.some((action: unknown, actionIndex: number) => !record(action) || action.id !== ids[actionIndex] ||
typeof action.enabled !== 'boolean' || typeof action.reason !== 'string' || action.reason.length > 512 ||
typeof action.label !== 'string' || action.label.length > 128)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native Skin actions');
}
}
if (label === 'Properties modifiers' && entry.dataLayoutActions !== undefined) {
if (type !== 'Data Transfer' || !Array.isArray(entry.dataLayoutActions) || entry.dataLayoutActions.length !== 1 ||
!record(entry.dataLayoutActions[0]) || entry.dataLayoutActions[0].id !== 'GENERATE_DATA_LAYERS' ||
typeof entry.dataLayoutActions[0].enabled !== 'boolean' ||
typeof entry.dataLayoutActions[0].reason !== 'string' || entry.dataLayoutActions[0].reason.length > 512 ||
typeof entry.dataLayoutActions[0].label !== 'string' || entry.dataLayoutActions[0].label.length > 128) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native Data Transfer actions');
}
}
if (label === 'Properties modifiers' && entry.multiresActions !== undefined) {
const ids = ['DELETE_HIGHER_LEVELS', 'SUBDIVIDE', 'RESHAPE', 'BASE_APPLY', 'UNSUBDIVIDE', 'REBUILD_SUBDIV', 'EXTERNAL_SAVE', 'EXTERNAL_PACK'];
if (type !== 'Multires' || !Array.isArray(entry.multiresActions) || entry.multiresActions.length !== ids.length ||
entry.multiresActions.some((action: unknown, actionIndex: number) => !record(action) || action.id !== ids[actionIndex] ||
typeof action.enabled !== 'boolean' || typeof action.reason !== 'string' || action.reason.length > 512 ||
typeof action.label !== 'string' || action.label.length > 128)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native Multires actions');
}
}
if ((label === 'Properties constraints' || label === 'Properties pose constraints') &&
entry.reorderable !== undefined && typeof entry.reorderable !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint reorder availability');
}
if (label === 'Properties pose constraints') {
text(entry.boneName, `${label}.boneName`, 63);
if (entry.targetBoneName !== undefined && (typeof entry.targetBoneName !== 'string' || entry.targetBoneName.length >= 64)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint target bone');
}
}
if (label === 'Properties constraints' && entry.targetBoneName !== undefined &&
(typeof entry.targetBoneName !== 'string' || entry.targetBoneName.length >= 64)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid object constraint target bone');
}
return entry as any;
});
};
const modifiers = parseStack(object.modifiers, 'Properties modifiers') as NativePropertiesObject['modifiers'];
let modifierTypes: NativePropertiesObject['modifierTypes'];
if (object.modifierTypes !== undefined) {
if (!Array.isArray(object.modifierTypes) || object.modifierTypes.length > 256) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier choices');
}
const ids = new Set<string>();
modifierTypes = object.modifierTypes.map(item => {
if (!record(item) || typeof item.enabled !== 'boolean' ||
typeof item.category !== 'string' || item.category.length > 256 ||
typeof item.reason !== 'string' || item.reason.length > 1024) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid modifier choice');
}
const id = text(item.id, 'Modifier choice ID', 256);
if (ids.has(id)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate modifier choice');
ids.add(id);
return { id, label: text(item.label, 'Modifier choice label', 256),
category: item.category, enabled: item.enabled, reason: item.reason };
});
}
const constraints = parseStack(object.constraints, 'Properties constraints') as NativePropertiesObject['constraints'];
const poseConstraints = parseStack(object.poseConstraints, 'Properties pose constraints') as NativePropertiesObject['poseConstraints'];
const activeModifierUuid = object.activeModifierUuid === undefined || object.activeModifierUuid === null ? null : text(object.activeModifierUuid, 'Properties active modifier UUID', 256);
const activeConstraintName = object.activeConstraintName === undefined || object.activeConstraintName === null ? null : text(object.activeConstraintName, 'Properties active constraint name', 63);
let activePoseConstraint: NativePropertiesObject['activePoseConstraint'] = null;
if (object.activePoseConstraint !== undefined && object.activePoseConstraint !== null) {
if (!record(object.activePoseConstraint)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid active pose constraint');
activePoseConstraint = { boneName: text(object.activePoseConstraint.boneName, 'Properties active pose bone', 63), name: text(object.activePoseConstraint.name, 'Properties active pose constraint', 63) };
}
space.properties.object = { id: text(object.id, 'Properties object ID'), name: text(object.name, 'Properties object name'), fields, meshId, modifiers, modifierTypes, activeModifierUuid, constraints, activeConstraintName, poseConstraints, activePoseConstraint };
if (object.stackEditable !== undefined) {
if (typeof object.stackEditable !== 'boolean') throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid stack editability');
space.properties.object.stackEditable = object.stackEditable;
}
if (object.poseBones !== undefined) {
if (!Array.isArray(object.poseBones) || object.poseBones.length > 65536) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid pose bones');
space.properties.object.poseBones = object.poseBones.map(bone => text(bone, 'Properties pose bone', 63));
}
if (object.constraintTargets !== undefined) {
if (!Array.isArray(object.constraintTargets)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint target choices');
}
space.properties.object.constraintTargets = object.constraintTargets.map(item => {
if (!record(item) || !['ARMATURE', 'CURVE', 'MESH', 'LATTICE', 'OTHER'].includes(item.kind as string) ||
!Array.isArray(item.bones) || item.bones.length > 65536) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint target choice');
}
const id = text(item.id, 'Constraint target ID');
if (!id.startsWith('object:')) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint target identity');
if (item.vertexGroups !== undefined && (!Array.isArray(item.vertexGroups) || item.vertexGroups.length > 65536)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint target vertex groups');
}
return { id, name: text(item.name, 'Constraint target name'),
kind: item.kind as 'ARMATURE' | 'CURVE' | 'MESH' | 'LATTICE' | 'OTHER',
bones: item.bones.map(bone => text(bone, 'Constraint target bone', 63)),
...(Array.isArray(item.vertexGroups) ? {
vertexGroups: item.vertexGroups.map(group => text(group, 'Constraint target vertex group', 63)),
} : {}) };
});
}
if (object.relations !== undefined) {
const relations = object.relations;
if (!record(relations) || typeof relations.editable !== 'boolean' ||
typeof relations.truncated !== 'boolean' || !Array.isArray(relations.candidates) ||
relations.candidates.length > 4096) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties relations');
}
const parentId = relations.parentId === null ? null : text(relations.parentId, 'Properties parent ID');
const parentName = relations.parentName === null ? null : text(relations.parentName, 'Properties parent name');
const candidates = relations.candidates.map(item => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties parent candidate');
return { id: text(item.id, 'Properties parent candidate ID'), name: text(item.name, 'Properties parent candidate name') };
});
if ((parentId === null) !== (parentName === null) || parentId === object.id ||
candidates.some(item => item.id === object.id) ||
new Set(candidates.map(item => item.id)).size !== candidates.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Inconsistent Properties relations');
}
space.properties.object.relations = { parentId, parentName, editable: relations.editable,
candidates, truncated: relations.truncated };
if (relations.parenting !== undefined) {
const parenting = relations.parenting;
const validBone = (value: unknown): value is string => typeof value === 'string' &&
!value.includes('\0') && new TextEncoder().encode(value).length < 64;
if (!record(parenting) || !Array.isArray(parenting.types) || parenting.types.length > 6 ||
typeof parenting.typeEditable !== 'boolean' || typeof parenting.boneEditable !== 'boolean' ||
!validBone(parenting.bone) || !Array.isArray(parenting.bones) || parenting.bones.length > 4096 ||
!parenting.bones.every(value => validBone(value) && value.length > 0) ||
typeof parenting.bonesTruncated !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties parenting settings');
}
const types = parenting.types.map(item => {
if (!record(item) || !['OBJECT', 'ARMATURE', 'LATTICE', 'VERTEX', 'VERTEX_3', 'BONE'].includes(String(item.id))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties parent type');
}
return { id: text(item.id, 'Parent type ID'), label: text(item.label, 'Parent type label') };
});
const type = parenting.type === null ? null : text(parenting.type, 'Parent type');
if ((type !== null && !types.some(item => item.id === type)) ||
new Set(types.map(item => item.id)).size !== types.length ||
new Set(parenting.bones).size !== parenting.bones.length ||
(parenting.typeEditable && (!relations.editable || !parentId)) ||
(parenting.boneEditable && (!relations.editable || !parentId || type !== 'BONE'))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Inconsistent Properties parenting settings');
}
space.properties.object.relations.parenting = { type, types,
typeEditable: parenting.typeEditable, bone: parenting.bone, boneEditable: parenting.boneEditable,
bones: parenting.bones as string[], bonesTruncated: parenting.bonesTruncated };
if (parenting.settings !== undefined) {
if (!Array.isArray(parenting.settings) || parenting.settings.length > 4) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid parent detail settings');
}
const keys = new Set<string>();
space.properties.object.relations.parenting.settings = parenting.settings.map(item => {
if (!record(item) || !['parent_vertices', 'use_parent_final_indices', 'parent_bone_head_tail_factor'].includes(String(item.id)) ||
!Number.isSafeInteger(item.index) || typeof item.index !== 'number' ||
typeof item.editable !== 'boolean' || typeof item.minimum !== 'number' ||
typeof item.maximum !== 'number' || !Number.isFinite(item.minimum) ||
!Number.isFinite(item.maximum) || item.minimum > item.maximum) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid parent detail field');
}
const vertices = item.id === 'parent_vertices';
const finalIndices = item.id === 'use_parent_final_indices';
const vertexMode = type === 'VERTEX' || type === 'VERTEX_3';
const key = `${item.id}:${item.index}`;
if (!parentId || keys.has(key) ||
((vertices || finalIndices) ? !vertexMode : type !== 'BONE') ||
item.index < 0 || item.index >= (vertices && type === 'VERTEX_3' ? 3 : 1) ||
(item.editable && !relations.editable) ||
(vertices ? !Number.isSafeInteger(item.minimum) || !Number.isSafeInteger(item.maximum) ||
item.minimum < 0 || item.maximum > 2147483647 : item.minimum !== 0 || item.maximum !== 1) ||
(finalIndices ? typeof item.value !== 'boolean' : typeof item.value !== 'number' ||
!Number.isFinite(item.value) || item.value < item.minimum || item.value > item.maximum ||
(vertices && !Number.isSafeInteger(item.value)))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Inconsistent parent detail field');
}
keys.add(key);
return { id: item.id as PropertiesParentSetting, index: item.index,
label: text(item.label, 'Parent setting label'), value: item.value as number | boolean,
minimum: item.minimum, maximum: item.maximum, editable: item.editable };
});
}
}
}
for (const group of ['transforms', 'deltaTransforms', 'dimensions'] as const) {
if (object[group] === undefined) continue;
const mode = fields.find(field => field.id === 'rotation_mode')?.value;
const rotation = mode === 'QUATERNION' ? 'rotation_quaternion' : mode === 'AXIS_ANGLE' ? 'rotation_axis_angle' : 'rotation_euler';
const expected = group === 'dimensions' ? ['dimensions'] : group === 'transforms' ? ['location', rotation, 'scale'] :
['delta_location', ...(mode === 'AXIS_ANGLE' ? [] : [`delta_${rotation}`]), 'delta_scale'];
const nativeTransforms = object[group];
if (!Array.isArray(nativeTransforms) || nativeTransforms.length !== expected.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties transforms');
}
const transforms = nativeTransforms.map((field): NativePropertiesTransform => {
if (!record(field)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties transform');
const id = text(field.id, 'Properties transform field', 64) as PropertiesObjectTransform;
const length = Object.prototype.hasOwnProperty.call(PROPERTIES_OBJECT_TRANSFORM_LENGTHS, id) ? PROPERTIES_OBJECT_TRANSFORM_LENGTHS[id] : 0;
if (!length || !Array.isArray(field.values) || field.values.length !== length ||
!Array.isArray(field.editable) || field.editable.length !== length ||
!field.editable.every(value => typeof value === 'boolean')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties transform components');
}
const minimum = finite(field.minimum, 'Properties transform minimum', -3.4028234663852886e38, 3.4028234663852886e38);
const maximum = finite(field.maximum, 'Properties transform maximum', minimum, 3.4028234663852886e38);
if (id === 'dimensions' && minimum < 0) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties dimensions range');
}
return { id, label: text(field.label, 'Properties transform label'), minimum, maximum,
values: field.values.map(value => finite(value, 'Properties transform value', -3.4028234663852886e38, 3.4028234663852886e38)),
editable: field.editable as boolean[] };
});
if ((group !== 'dimensions' && typeof mode !== 'string') || new Set(transforms.map(field => field.id)).size !== expected.length ||
!expected.every(id => transforms.some(field => field.id === id))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Properties transform mode is inconsistent');
}
space.properties.object[group] = transforms;
}
if (object.locks !== undefined) {
if (!Array.isArray(object.locks) || object.locks.length !== 5) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties transform locks');
}
const locks = object.locks.map((field): NativePropertiesLock => {
if (!record(field)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties transform lock');
const id = text(field.id, 'Properties transform lock', 64) as PropertiesObjectLock;
const length = Object.prototype.hasOwnProperty.call(PROPERTIES_OBJECT_LOCK_LENGTHS, id) ? PROPERTIES_OBJECT_LOCK_LENGTHS[id] : 0;
if (!length || !Array.isArray(field.values) || field.values.length !== length ||
!field.values.every(value => typeof value === 'boolean') ||
!Array.isArray(field.editable) || field.editable.length !== length ||
!field.editable.every(value => typeof value === 'boolean')) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties transform lock components');
}
return { id, label: text(field.label, 'Properties transform lock label'),
values: field.values as boolean[], editable: field.editable as boolean[] };
});
if (new Set(locks.map(field => field.id)).size !== 5) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate Properties transform lock');
}
space.properties.object.locks = locks;
}
}
else if (properties.object === null) space.properties.object = null;
if (properties.data !== undefined && properties.data !== null) {
const data = properties.data;
if (!record(data) || !Array.isArray(data.fields) || data.fields.length > EDITOR_WORKFLOW_BUDGET.maxPropertiesDataFields) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data context');
}
const fields = data.fields.map((field): NativePropertiesDataField => {
if (!record(field)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data field');
const arrayLayout = field.arrayLayout;
const array = Array.isArray(field.value) ? field.value : null;
const arrayShapeValid = array !== null && (
arrayLayout === 'MATRIX' ? array.length === 16 :
arrayLayout === 'SEQUENCE' ? field.id === 'joint_bindings' && array.length <= 256 &&
(array.length > 0 || field.editable === false) :
array.length >= 2 && array.length <= 4);
if (arrayLayout !== undefined &&
(!['COLOR', 'VECTOR', 'MATRIX', 'SEQUENCE'].includes(arrayLayout as string) || !arrayShapeValid)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties array layout');
}
if (!record(field) || typeof field.editable !== 'boolean' || !Array.isArray(field.items) ||
(typeof field.value !== 'boolean' && typeof field.value !== 'number' && typeof field.value !== 'string' &&
!(Array.isArray(field.value) && arrayShapeValid &&
field.value.every(component => typeof component === 'number' && Number.isFinite(component)))) ||
(typeof field.value === 'number' && !Number.isFinite(field.value))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data field');
}
const items = field.items.map(item => {
if (!record(item)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data option');
return { id: item.id === '' ? '' : text(item.id, 'Properties data option'), label: text(item.label, 'Properties data option label') };
});
if (new Set(items.map(item => item.id)).size !== items.length ||
(items.length > 0 && (typeof field.value !== 'string' || !items.some(item => item.id === field.value)))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Inconsistent Properties data field');
}
const minimum = field.minimum;
const maximum = field.maximum;
if (field.integer !== undefined && (typeof field.integer !== 'boolean' ||
(field.integer && !Number.isSafeInteger(field.value)))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties integer field');
}
if ((minimum !== undefined && (typeof minimum !== 'number' || !Number.isFinite(minimum))) ||
(maximum !== undefined && (typeof maximum !== 'number' || !Number.isFinite(maximum))) ||
(minimum !== undefined && maximum !== undefined && minimum > maximum)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data field range');
}
if (field.status !== undefined && (typeof field.status !== 'string' || field.status.length > 4096)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data field status');
}
if (field.description !== undefined && (typeof field.description !== 'string' || field.description.length > 4096)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Properties data field description');
}
return { id: text(field.id, 'Properties data field', 64), label: text(field.label, 'Properties data field label'),
editable: field.editable, value: field.value, items,
...(field.description !== undefined ? { description: field.description } : {}),
...(field.integer !== undefined ? { integer: field.integer as boolean } : {}),
...(arrayLayout !== undefined ? { arrayLayout: arrayLayout as NativePropertiesDataField['arrayLayout'] } : {}),
...(minimum !== undefined ? { minimum } : {}), ...(maximum !== undefined ? { maximum } : {}),
...(field.status !== undefined ? { status: field.status } : {}) };
});
if (new Set(fields.map(field => field.id)).size !== fields.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate Properties data field');
}
if (context === null || !['TOOL', 'SCENE', 'RENDER', 'OUTPUT', 'VIEW_LAYER', 'WORLD', 'COLLECTION', 'OBJECT', 'CONSTRAINT', 'MODIFIER', 'DATA', 'BONE', 'BONE_CONSTRAINT', 'MATERIAL', 'TEXTURE', 'PARTICLES', 'PHYSICS', 'SHADERFX', 'STRIP', 'STRIP_MODIFIER'].includes(context)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `Unsupported Properties data context: ${String(context)} at ${areaName}`);
space.properties.data = { id: text(data.id, 'Properties data ID'), name: text(data.name, 'Properties data name'), fields };
if (data.aovs !== undefined) {
const aovs = data.aovs;
if (context !== 'VIEW_LAYER' || !record(aovs) || typeof aovs.editable !== 'boolean' || !Array.isArray(aovs.rows)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid View Layer AOV context');
const rows = aovs.rows.map(row => {
if (!record(row) || typeof row.isValid !== 'boolean') throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid View Layer AOV row');
return { name: text(row.name, 'AOV name'), type: integer(row.type, 'AOV type', 0, 1) as 0 | 1, isValid: row.isValid }; });
space.properties.data.aovs = { sceneId: text(aovs.sceneId, 'AOV Scene'), layerName: text(aovs.layerName, 'AOV View Layer'), editable: aovs.editable, activeIndex: integer(aovs.activeIndex, 'AOV active index', -1, Math.max(-1, rows.length - 1)), rows };
}
if (data.lightgroups !== undefined) { const groups = data.lightgroups; if (context !== 'VIEW_LAYER' || !record(groups) || typeof groups.editable !== 'boolean' || !Array.isArray(groups.rows)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid View Layer Light Groups context'); const rows = groups.rows.map(name => text(name, 'Light Group name')); space.properties.data.lightgroups = { sceneId: text(groups.sceneId, 'Light Group Scene'), layerName: text(groups.layerName, 'Light Group View Layer'), editable: groups.editable, activeIndex: integer(groups.activeIndex, 'Light Group active index', -1, Math.max(-1, rows.length - 1)), rows }; }
if (data.shapeKeys !== undefined) {
const shapes = data.shapeKeys;
if (context !== 'DATA' || !record(shapes) || typeof shapes.editable !== 'boolean' ||
typeof shapes.activeUid !== 'number' || !Number.isSafeInteger(shapes.activeUid) ||
!Array.isArray(shapes.rows) || !Array.isArray(shapes.operations)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Shape Key context');
}
const rows = shapes.rows.map(row => {
if (!record(row) || typeof row.uid !== 'number' || !Number.isSafeInteger(row.uid) ||
typeof row.selected !== 'boolean' || typeof row.muted !== 'boolean' || typeof row.locked !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Shape Key row');
}
return { uid: row.uid, name: text(row.name, 'Shape Key name'),
selected: row.selected, muted: row.muted, locked: row.locked };
});
const operations = shapes.operations.map(operation => {
if (!record(operation) || typeof operation.enabled !== 'boolean' ||
typeof operation.reason !== 'string' || operation.reason.length > 4096 ||
(operation.sources !== undefined && (!Array.isArray(operation.sources) || operation.sources.length > 1024))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Shape Key operation');
}
return { id: text(operation.id, 'Shape Key operation'), label: text(operation.label, 'Shape Key label'),
enabled: operation.enabled, reason: operation.reason,
...(Array.isArray(operation.sources) ? { sources: operation.sources.map(source => text(source, 'Shape Key source', 2048)) } : {}) };
});
if (new Set(rows.map(row => row.uid)).size !== rows.length ||
new Set(operations.map(operation => operation.id)).size !== operations.length ||
(shapes.activeUid !== 0 && !rows.some(row => row.uid === shapes.activeUid))) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Inconsistent Shape Key context');
}
space.properties.data.shapeKeys = {
objectId: text(shapes.objectId, 'Shape Key object ID'),
keyId: shapes.keyId === '' ? '' : text(shapes.keyId, 'Shape Key data ID'),
activeUid: shapes.activeUid, editable: shapes.editable, rows, operations,
};
}
if (data.shapeKeyMirror !== undefined) {
const mirror = data.shapeKeyMirror;
if (context !== 'DATA' || !record(mirror) ||
typeof mirror.activeUid !== 'number' || !Number.isSafeInteger(mirror.activeUid) ||
typeof mirror.enabled !== 'boolean' || typeof mirror.topologyEnabled !== 'boolean' ||
typeof mirror.reason !== 'string' || mirror.reason.length > 4096 ||
(mirror.topologyEnabled && !mirror.enabled)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Shape Key mirror context');
}
space.properties.data.shapeKeyMirror = {
objectId: text(mirror.objectId, 'Shape Key object ID'),
keyId: mirror.keyId === '' ? '' : text(mirror.keyId, 'Shape Key data ID'),
activeUid: mirror.activeUid,
name: mirror.name === '' ? '' : text(mirror.name, 'Shape Key name'),
enabled: mirror.enabled, topologyEnabled: mirror.topologyEnabled, reason: mirror.reason,
};
}
if (data.cacheLayers !== undefined) {
if (!['MODIFIER', 'CONSTRAINT', 'BONE_CONSTRAINT'].includes(context) ||
!Array.isArray(data.cacheLayers) || data.cacheLayers.length > 128 ||
typeof data.cacheActiveLayer !== 'number' || !Number.isInteger(data.cacheActiveLayer) ||
data.cacheActiveLayer < -1 || data.cacheActiveLayer >= data.cacheLayers.length ||
typeof data.cacheLayersEditable !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid CacheFile layers');
}
space.properties.data.cacheLayers = data.cacheLayers.map(layer => {
if (!record(layer) || typeof layer.hidden !== 'boolean') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid CacheFile layer');
}
return { filepath: text(layer.filepath, 'CacheFile layer path', 4096), hidden: layer.hidden };
});
space.properties.data.cacheActiveLayer = data.cacheActiveLayer;
space.properties.data.cacheLayersEditable = data.cacheLayersEditable;
}
if (data.constraintOperations !== undefined) {
if (!['CONSTRAINT', 'BONE_CONSTRAINT'].includes(context) || !Array.isArray(data.constraintOperations)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint operations');
}
space.properties.data.constraintOperations = data.constraintOperations.map(operation => {
if (!record(operation) || typeof operation.enabled !== 'boolean' || typeof operation.reason !== 'string' ||
(operation.menu !== undefined && typeof operation.menu !== 'boolean') ||
operation.reason.length > 4096 || typeof operation.description !== 'string') {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native constraint operation');
}
if (operation.parameters !== undefined && !Array.isArray(operation.parameters)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid constraint operation parameters');
}
const parameters = (operation.parameters ?? []).map(parameter => {
if (!record(parameter) || typeof parameter.description !== 'string' ||
!Number.isSafeInteger(parameter.minimum) || !Number.isSafeInteger(parameter.maximum) ||
!Number.isSafeInteger(parameter.defaultValue) || Number(parameter.minimum) > Number(parameter.maximum)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid native constraint integer parameter');
}
return { id: text(parameter.id, 'Constraint parameter', 64), label: text(parameter.label, 'Constraint parameter label'),
description: parameter.description, minimum: Number(parameter.minimum), maximum: Number(parameter.maximum),
defaultValue: finite(parameter.defaultValue, 'Constraint parameter default', Number(parameter.minimum), Number(parameter.maximum)) };
});
if (new Set(parameters.map(parameter => parameter.id)).size !== parameters.length) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Duplicate constraint operation parameter');
}
return { id: text(operation.id, 'Constraint operator', 128), label: text(operation.label, 'Constraint operation label'),
description: operation.description, enabled: operation.enabled, reason: operation.reason,
menu: operation.menu === true, parameters };
});
}
if (data.armatureTargets !== undefined) {
if (!['CONSTRAINT', 'BONE_CONSTRAINT'].includes(context) || !Array.isArray(data.armatureTargets) ||
typeof data.targetsEditable !== 'boolean' || !Array.isArray(data.armatures)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Armature target collection');
}
space.properties.data.armatureTargets = data.armatureTargets.map(target => {
if (!record(target)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Armature target');
return { objectId: target.objectId === '' ? '' : text(target.objectId, 'Armature target object'),
bone: target.bone === '' ? '' : text(target.bone, 'Armature target bone', 63),
weight: finite(target.weight, 'Armature target weight', 0, 1) };
});
space.properties.data.targetsEditable = data.targetsEditable;
space.properties.data.armatures = data.armatures.map(armature => {
if (!record(armature) || !Array.isArray(armature.bones)) {
throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', 'Invalid Armature target candidates');
}
return { id: text(armature.id, 'Armature candidate'), name: text(armature.name, 'Armature name'),
bones: armature.bones.map(bone => text(bone, 'Armature bone', 63)) };
});
}
}
else if (properties.data === null) space.properties.data = null;
}
if (areaValue.space.imageView !== undefined) {
const view = areaValue.space.imageView;
if (!record(view)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.imageView is invalid`);
space.imageView = {
zoom: finite(view.zoom, `${areaName}.zoom`, Number.MIN_VALUE, Number.MAX_VALUE),
offsetX: finite(view.offsetX, `${areaName}.offsetX`, -Number.MAX_VALUE, Number.MAX_VALUE),
offsetY: finite(view.offsetY, `${areaName}.offsetY`, -Number.MAX_VALUE, Number.MAX_VALUE),
width: integer(view.width, `${areaName}.imageWidth`, 1, 2147483647),
height: integer(view.height, `${areaName}.imageHeight`, 1, 2147483647),
aspectX: finite(view.aspectX, `${areaName}.aspectX`, Number.MIN_VALUE, Number.MAX_VALUE),
aspectY: finite(view.aspectY, `${areaName}.aspectY`, Number.MIN_VALUE, Number.MAX_VALUE),
};
}
if (areaValue.space.uvPackTarget !== undefined) space.uvPackTarget = parseNativeUVPackTarget(areaValue.space.uvPackTarget);
if (areaValue.space.textSelection !== undefined) {
const selection = areaValue.space.textSelection;
if (!record(selection)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.textSelection is invalid`);
const position = (key: 'anchor' | 'focus'): TextCursorIR => {
const cursor = selection[key];
if (!record(cursor)) throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID', `${areaName}.${key} is invalid`);
return {line: integer(cursor.line, `${areaName}.${key}.line`, 0, 65535), column: integer(cursor.column, `${areaName}.${key}.column`, 0, 1048576)};
};
space.textSelection = {anchor: position('anchor'), focus: position('focus')};
}
if (areaValue.space.view !== undefined) {
const source=areaValue.space.view;
if(!record(source)||!Array.isArray(source.offset)||source.offset.length!==3||!Array.isArray(source.rotation)||source.rotation.length!==4||![...source.offset,...source.rotation,source.distance,source.lens,source.near,source.far].every(item=>typeof item==='number'&&Number.isFinite(item))||!['PERSPECTIVE','ORTHOGRAPHIC','CAMERA'].includes(source.projection as string))throw new EditorWorkflowValidationError('EDITOR_LAYOUT_INVALID',`${areaName}.view is invalid`);
space.view={offset:[...source.offset] as NativeViewportState['offset'],rotation:[...source.rotation] as NativeViewportState['rotation'],distance:source.distance as number,lens:source.lens as number,near:source.near as number,far:source.far as number,projection:source.projection as NativeViewportState['projection'],locked:source.locked===true};
}
}
return { id: areaId, editor: areaValue.editor as EditorTypeIR, regions, rect: rect(areaValue.rect, `${areaName}.rect`), maximized: areaValue.maximized === true, nativeLayout, filterText: filterText as string | undefined, space };
});
const activeAreaId = text(workspaceValue.activeAreaId, `${name}.activeAreaId`); if (!areaIds.has(activeAreaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name}.activeAreaId is missing`);
for (let index = 0; index < areas.length; index++) for (let other = index + 1; other < areas.length; other++) if (!areas[index].maximized && !areas[other].maximized && overlap(areas[index].rect, areas[other].rect)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} has overlapping areas`);
areas.forEach((area, areaIndex) => area.regions.forEach((region, regionIndex) => {
const source = (workspaceValue.areas as Array<{ regions: Array<Record<string, unknown>> }>)[areaIndex].regions[regionIndex];
region.toggleable = source.toggleable === true;
}));
return { id, name: text(workspaceValue.name, `${name}.name`), screenId: workspaceValue.screenId === undefined ? undefined : text(workspaceValue.screenId, `${name}.screenId`), writable: workspaceValue.writable === true, areas, activeAreaId, revision: integer(workspaceValue.revision, `${name}.revision`, 0, Number.MAX_SAFE_INTEGER) };
});
const contextValue = value.context; const workspaceId = text(contextValue.workspaceId, "context.workspaceId"); const workspace = workspaces.find((item) => item.id === workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context workspace is missing");
const activeAreaId = text(contextValue.activeAreaId, "context.activeAreaId"); const activeArea = workspace.areas.find((item) => item.id === activeAreaId); if (!activeArea || activeArea.editor !== contextValue.activeEditor) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context active area/editor is inconsistent");
if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !EDITOR_MODES.includes(contextValue.mode as EditorMode) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid");
if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget");
if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid");
const keymapIds = new Set<string>(); const keymaps: KeymapBindingIR[] = [];
value.keymaps.forEach((bindingValue, index) => {
const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`);
const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id);
const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16).toUpperCase()); if (new Set(modifiers).size !== modifiers.length || modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers are invalid or duplicate`); modifiers.sort();
const parseScope = <T extends string>(field: "workspaceIds" | "editors" | "modes", allowed?: readonly T[]): T[] | undefined => {
const source = bindingValue[field]; if (source === undefined) return undefined;
if (!Array.isArray(source) || source.length === 0 || source.length > 64) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid`);
const parsed = source.map((item, scopeIndex) => text(item, `${name}.${field}[${scopeIndex}]`, 256) as T);
if (new Set(parsed).size !== parsed.length || (allowed && parsed.some((item) => !allowed.includes(item)))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid or duplicated`);
return parsed;
};
const binding: KeymapBindingIR = { id, key: text(bindingValue.key, `${name}.key`, 32).toUpperCase(), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false, workspaceIds: parseScope("workspaceIds"), editors: parseScope("editors", EDITOR_TYPES), modes: parseScope("modes", EDITOR_MODES) };
if (binding.workspaceIds?.some((workspace) => !workspaceIds.has(workspace))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.workspaceIds references a missing workspace`);
if (binding.enabled && keymaps.some((candidate) => candidate.enabled && candidate.key === binding.key && candidate.modifiers.join("+") === binding.modifiers.join("+") && keymapScopesOverlap(candidate, binding))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} conflicts with another enabled keymap in the same context`);
keymaps.push(binding);
});
return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, uvSelection: parseUVSelection(value.uvSelection), context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps };
}
export function keyChordFromKeyboardEvent(event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">): KeyChordIR {
const key = text(event.key, "event.key", 32).toUpperCase();
const modifiers: KeyChordIR["modifiers"] = [];
if (event.altKey) modifiers.push("ALT");
if (event.ctrlKey) modifiers.push("CTRL");
if (event.metaKey) modifiers.push("META");
if (event.shiftKey) modifiers.push("SHIFT");
return { key, modifiers };
}
export function resolveKeymapCommand(value: unknown, chordValue: unknown): string | null {
const workflow = parseEditorWorkflow(value);
if (!record(chordValue) || !Array.isArray(chordValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord is invalid");
const chord = { key: text(chordValue.key, "keyChord.key", 32).toUpperCase(), modifiers: chordValue.modifiers.map((modifier, index) => text(modifier, `keyChord.modifiers[${index}]`, 16).toUpperCase()).sort() };
if (new Set(chord.modifiers).size !== chord.modifiers.length || chord.modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord modifiers are invalid");
return workflow.keymaps.find((binding) => binding.enabled && binding.key === chord.key && binding.modifiers.join("+") === chord.modifiers.join("+") &&
(!binding.workspaceIds || binding.workspaceIds.includes(workflow.context.workspaceId)) &&
(!binding.editors || binding.editors.includes(workflow.context.activeEditor)) &&
(!binding.modes || binding.modes.includes(workflow.context.mode)))?.command ?? null;
}
export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR {
const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow);
if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; }
else if (edit.type === "SET_ACTIVE_AREA") { const workspace = clone.workspaces.find((item) => item.id === clone.context.workspaceId); const area = workspace?.areas.find((item) => item.id === edit.areaId); if (!area) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown area ${edit.areaId}`); clone.context.activeAreaId = area.id; clone.context.activeEditor = area.editor; }
else if (edit.type === "SET_SELECTION") { if (new Set(edit.selectedIds).size !== edit.selectedIds.length || edit.selectedIds.length > EDITOR_WORKFLOW_BUDGET.maxSelection || edit.selectedIds.some((id) => typeof id !== "string" || !id)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection is invalid"); if (edit.activeObjectId !== null && !edit.selectedIds.includes(edit.activeObjectId)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object must be selected"); clone.context.selection = [...edit.selectedIds]; clone.context.activeObjectId = edit.activeObjectId; }
else { const area = clone.workspaces.find((item) => item.id === clone.context.workspaceId)?.areas.find((item) => item.id === edit.areaId); const region = area?.regions.find((item) => item.id === edit.regionId); if (!region) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown region ${edit.regionId}`); region.visible = edit.visible; }
clone.context.revision += 1; return parseEditorWorkflow(clone);
}
export function gateEditorOperation(operation: "READ_ONLY_VIEW" | "SELECTION_SYNC" | "KEYMAP" | "WRITER" | "GIZMO" | "TOUCH_DRAG"): CapabilityGateResult {
if (["READ_ONLY_VIEW", "SELECTION_SYNC", "KEYMAP"].includes(operation)) return readyGate("N-024", operation);
return blockedGate("N-024", operation, [capabilityIssue(operation === "GIZMO" || operation === "TOUCH_DRAG" ? "EDITOR_GIZMO_UNAVAILABLE" : "EDITOR_WRITER_UNAVAILABLE", `${operation} requires editor-specific Main transaction and interaction verification`)]);
}