import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const EDITOR_WORKFLOW_SCHEMA = 1 as const; export const EDITOR_WORKFLOW_BUDGET = { maxWorkspaces: 64, maxAreas: 1_024, maxRegions: 4_096, maxSelection: 100_000, maxKeymaps: 4_096 } as const; export const EDITOR_TYPES = ["VIEW_3D", "OUTLINER", "PROPERTIES", "UV_IMAGE", "NODE", "GRAPH", "DOPE_SHEET", "NLA", "SPREADSHEET", "TIMELINE", "CLIP", "MASK", "SEQUENCER"] as const; export type EditorTypeIR = typeof EDITOR_TYPES[number]; export type EditorRegionKind = "HEADER" | "MAIN" | "TOOLBAR" | "SIDEBAR" | "FOOTER"; export type EditorMode = "OBJECT" | "EDIT" | "POSE"; export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: boolean } export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean } export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number } export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number } export interface KeymapBindingIR { id: string; key: string; modifiers: string[]; command: string; enabled: boolean } export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] } export type EditorWorkflowEditIR = | { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string } | { type: "SET_ACTIVE_AREA"; revision: number; areaId: string } | { type: "SET_SELECTION"; revision: number; selectedIds: string[]; activeObjectId: string | null } | { type: "TOGGLE_REGION"; revision: number; areaId: string; regionId: string; visible: boolean }; export class EditorWorkflowValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "EditorWorkflowValidationError"; this.code = code; } } const EDITOR_REGION_KINDS = new Set(["HEADER", "MAIN", "TOOLBAR", "SIDEBAR", "FOOTER"]); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); return value; } function finite(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; } function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; } function rect(value: unknown, name: string): EditorAreaIR["rect"] { if (!record(value)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); const next = { x: finite(value.x, `${name}.x`, 0, 1), y: finite(value.y, `${name}.y`, 0, 1), width: finite(value.width, `${name}.width`, 0.0001, 1), height: finite(value.height, `${name}.height`, 0.0001, 1) }; if (next.x + next.width > 1 || next.y + next.height > 1) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} exceeds workspace bounds`); return next; } function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; } export function parseEditorWorkflow(value: unknown): EditorWorkflowIR { if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema"); if (value.workspaces.length > EDITOR_WORKFLOW_BUDGET.maxWorkspaces || value.keymaps.length > EDITOR_WORKFLOW_BUDGET.maxKeymaps) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Editor workflow exceeds the budget"); let areaCount = 0; let regionCount = 0; const workspaceIds = new Set(); 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(); 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(); const regions = areaValue.regions.map((regionValue, regionIndex): EditorRegionIR => { const regionName = `${areaName}.regions[${regionIndex}]`; if (!record(regionValue) || !EDITOR_REGION_KINDS.has(regionValue.kind as EditorRegionKind)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${regionName} is invalid`); const regionId = text(regionValue.id, `${regionName}.id`); if (regionIds.has(regionId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate region ${regionId}`); regionIds.add(regionId); return { id: regionId, kind: regionValue.kind as EditorRegionKind, visible: regionValue.visible !== false }; }); return { id: areaId, editor: areaValue.editor as EditorTypeIR, regions, rect: rect(areaValue.rect, `${areaName}.rect`), maximized: areaValue.maximized === true }; }); const activeAreaId = text(workspaceValue.activeAreaId, `${name}.activeAreaId`); if (!areaIds.has(activeAreaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name}.activeAreaId is missing`); for (let index = 0; index < areas.length; index++) for (let other = index + 1; other < areas.length; other++) if (!areas[index].maximized && !areas[other].maximized && overlap(areas[index].rect, areas[other].rect)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} has overlapping areas`); return { id, name: text(workspaceValue.name, `${name}.name`), areas, activeAreaId, revision: integer(workspaceValue.revision, `${name}.revision`, 0, Number.MAX_SAFE_INTEGER) }; }); const contextValue = value.context; const workspaceId = text(contextValue.workspaceId, "context.workspaceId"); const workspace = workspaces.find((item) => item.id === workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context workspace is missing"); const activeAreaId = text(contextValue.activeAreaId, "context.activeAreaId"); const activeArea = workspace.areas.find((item) => item.id === activeAreaId); if (!activeArea || activeArea.editor !== contextValue.activeEditor) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context active area/editor is inconsistent"); if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid"); if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget"); if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid"); const keymapIds = new Set(); const keymaps = value.keymaps.map((bindingValue, index): KeymapBindingIR => { const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16)); if (new Set(modifiers).size !== modifiers.length) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers duplicate`); return { id, key: text(bindingValue.key, `${name}.key`, 32), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false }; }); return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps }; } export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR { const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow); if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; } else if (edit.type === "SET_ACTIVE_AREA") { const workspace = clone.workspaces.find((item) => item.id === clone.context.workspaceId); const area = workspace?.areas.find((item) => item.id === edit.areaId); if (!area) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown area ${edit.areaId}`); clone.context.activeAreaId = area.id; clone.context.activeEditor = area.editor; } else if (edit.type === "SET_SELECTION") { if (new Set(edit.selectedIds).size !== edit.selectedIds.length || edit.selectedIds.length > EDITOR_WORKFLOW_BUDGET.maxSelection || edit.selectedIds.some((id) => typeof id !== "string" || !id)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection is invalid"); if (edit.activeObjectId !== null && !edit.selectedIds.includes(edit.activeObjectId)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object must be selected"); clone.context.selection = [...edit.selectedIds]; clone.context.activeObjectId = edit.activeObjectId; } else { const area = clone.workspaces.find((item) => item.id === clone.context.workspaceId)?.areas.find((item) => item.id === edit.areaId); const region = area?.regions.find((item) => item.id === edit.regionId); if (!region) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown region ${edit.regionId}`); region.visible = edit.visible; } clone.context.revision += 1; return parseEditorWorkflow(clone); } export function gateEditorOperation(operation: "READ_ONLY_VIEW" | "SELECTION_SYNC" | "KEYMAP" | "WRITER" | "GIZMO" | "TOUCH_DRAG"): CapabilityGateResult { if (["READ_ONLY_VIEW", "SELECTION_SYNC", "KEYMAP"].includes(operation)) return readyGate("N-024", operation); return blockedGate("N-024", operation, [capabilityIssue(operation === "GIZMO" || operation === "TOUCH_DRAG" ? "EDITOR_GIZMO_UNAVAILABLE" : "EDITOR_WRITER_UNAVAILABLE", `${operation} requires editor-specific Main transaction and interaction verification`)]); }