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>
1704 lines
140 KiB
TypeScript
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`)]);
|
|
}
|