import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const SELECTION_HISTORY_SCHEMA = 2 as const; export const SELECTION_HISTORY_BUDGET = { maxEntries: 256, maxObjects: 100_000, maxTargets: 100_000, maxElements: 1_000_000, maxRangePatches: 4096, } as const; export type SelectionElementMode = "VERT" | "EDGE" | "FACE"; export type NonMeshSelectionKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT"; export interface SelectionElementTargetIR { objectId: string; dataId: string; mode: SelectionElementMode; indices: number[]; nonMeshKind?: NonMeshSelectionKind; } export interface SelectionStateIR { activeObjectId: string | null; objectIds: string[]; targets: SelectionElementTargetIR[]; } export interface SelectionHistoryIR { schemaVersion: typeof SELECTION_HISTORY_SCHEMA; revision: number; cursor: number; entries: SelectionStateIR[]; } export interface SelectionRangePatchIR { objectId: string; dataId: string; mode: SelectionElementMode; start: number; end: number; selected: boolean; nonMeshKind?: NonMeshSelectionKind; } export interface RaycastSelectionHitIR { sourceRevision: number; objectId?: string; dataId: string; mode: SelectionElementMode; index: number; distance: number; point: [number, number, number]; nonMeshKind?: NonMeshSelectionKind; } export class SelectionHistoryValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "SelectionHistoryValidationError"; this.code = code; } } function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(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 SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is outside the bounded range`); } return value; } function id(value: unknown, name: string): string { if (typeof value !== "string" || value.length === 0 || value.length > 256) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is invalid`); } return value; } function ids(value: unknown, name: string, maximum: number): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256) || new Set(value).size !== value.length) { throw new SelectionHistoryValidationError( value instanceof Array && value.length > maximum ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", `${name} is invalid`, ); } return [...value] as string[]; } function mode(value: unknown, name: string): SelectionElementMode { if (!["VERT", "EDGE", "FACE"].includes(value as string)) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is invalid`); } return value as SelectionElementMode; } function kind(value: unknown, code: "SELECTION_HISTORY_INVALID" | "RAYCAST_HIT_INVALID"): NonMeshSelectionKind | undefined { if (value === undefined) return undefined; if (!["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value as string)) { throw new SelectionHistoryValidationError(code, "Non-mesh selection identity is invalid"); } return value as NonMeshSelectionKind; } function indices(value: unknown, name: string): number[] { if (!Array.isArray(value) || value.length > SELECTION_HISTORY_BUDGET.maxElements || value.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value).size !== value.length) { throw new SelectionHistoryValidationError( value instanceof Array && value.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", `${name} is invalid`, ); } return [...value].sort((left, right) => left - right) as number[]; } function targetKey(target: Pick): string { return `${target.objectId}\0${target.dataId}\0${target.mode}\0${target.nonMeshKind ?? "MESH"}`; } function parseTarget(value: unknown, path: string): SelectionElementTargetIR { if (!record(value)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${path} is invalid`); const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID"); return { objectId: id(value.objectId, `${path}.objectId`), dataId: id(value.dataId, `${path}.dataId`), mode: mode(value.mode, `${path}.mode`), indices: indices(value.indices, `${path}.indices`), ...(parsedKind ? { nonMeshKind: parsedKind } : {}), }; } function migrateLegacyTarget(value: Record, objectIds: string[], activeObjectId: string | null): SelectionElementTargetIR[] { const legacyIndices = value.elementIndices === undefined ? [] : indices(value.elementIndices, "elementIndices"); if (legacyIndices.length === 0) return []; const dataId = value.meshId === null ? null : id(value.meshId, "meshId"); if (!dataId) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires dataId"); const objectId = activeObjectId ?? objectIds[0]; if (!objectId) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires an owning object"); const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID"); return [{ objectId, dataId, mode: mode(value.elementMode, "elementMode"), indices: legacyIndices, ...(parsedKind ? { nonMeshKind: parsedKind } : {}), }]; } export function parseSelectionState(value: unknown): SelectionStateIR { if (!record(value)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection state is invalid"); const objectIds = ids(value.objectIds, "objectIds", SELECTION_HISTORY_BUDGET.maxObjects); const activeObjectId = value.activeObjectId === null ? null : id(value.activeObjectId, "activeObjectId"); if (activeObjectId !== null && !objectIds.includes(activeObjectId)) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected"); } const targets = Array.isArray(value.targets) ? value.targets.map((target, index) => parseTarget(target, `targets[${index}]`)) : migrateLegacyTarget(value, objectIds, activeObjectId); if (targets.length > SELECTION_HISTORY_BUDGET.maxTargets) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection target count exceeds the budget"); } let elementCount = 0; const keys = new Set(); for (const target of targets) { if (!objectIds.includes(target.objectId)) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element target owner must be selected"); } const key = targetKey(target); if (keys.has(key)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection targets must be unique"); keys.add(key); elementCount += target.indices.length; if (!Number.isSafeInteger(elementCount) || elementCount > SELECTION_HISTORY_BUDGET.maxElements) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selected element count exceeds the budget"); } } targets.sort((left, right) => targetKey(left).localeCompare(targetKey(right))); return { activeObjectId, objectIds: [...objectIds].sort(), targets }; } export function parseSelectionHistory(value: unknown): SelectionHistoryIR { if (!record(value) || ![1, SELECTION_HISTORY_SCHEMA].includes(value.schemaVersion as number) || !Array.isArray(value.entries)) { throw new SelectionHistoryValidationError("PROTOCOL_MISMATCH", "Unsupported selection history schema"); } if (value.entries.length === 0 || value.entries.length > SELECTION_HISTORY_BUDGET.maxEntries) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection history entry count exceeds the budget"); } return { schemaVersion: SELECTION_HISTORY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), cursor: integer(value.cursor, "cursor", 0, value.entries.length - 1), entries: value.entries.map(parseSelectionState), }; } function equalState(left: SelectionStateIR, right: SelectionStateIR): boolean { return JSON.stringify(left) === JSON.stringify(right); } export function recordSelection(value: unknown, revision: number, stateValue: unknown): SelectionHistoryIR { const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const state = parseSelectionState(stateValue); if (equalState(history.entries[history.cursor], state)) return history; const entries = history.entries.slice(0, history.cursor + 1); entries.push(state); if (entries.length > SELECTION_HISTORY_BUDGET.maxEntries) entries.shift(); return parseSelectionHistory({ schemaVersion: SELECTION_HISTORY_SCHEMA, revision: history.revision + 1, cursor: entries.length - 1, entries }); } export function stepSelectionHistory(value: unknown, revision: number, direction: "UNDO" | "REDO"): SelectionHistoryIR { const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const cursor = history.cursor + (direction === "UNDO" ? -1 : 1); if (cursor < 0 || cursor >= history.entries.length) { throw new SelectionHistoryValidationError("SELECTION_UNDO_UNAVAILABLE", `${direction} has no selection entry`); } return { ...history, revision: history.revision + 1, cursor }; } export function patchSelectionRanges(stateValue: unknown, patchesValue: unknown): SelectionStateIR { const state = parseSelectionState(stateValue); if (!Array.isArray(patchesValue) || patchesValue.length === 0 || patchesValue.length > SELECTION_HISTORY_BUDGET.maxRangePatches) { throw new SelectionHistoryValidationError( Array.isArray(patchesValue) && patchesValue.length > SELECTION_HISTORY_BUDGET.maxRangePatches ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", "Selection range patches are invalid", ); } const targets = new Map(state.targets.map((target) => [targetKey(target), { ...target, indices: new Set(target.indices) }])); let touched = 0; for (let patchIndex = 0; patchIndex < patchesValue.length; patchIndex++) { const value = patchesValue[patchIndex]; if (!record(value) || typeof value.selected !== "boolean") { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `patches[${patchIndex}] is invalid`); } const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID"); const patch: SelectionRangePatchIR = { objectId: id(value.objectId, `patches[${patchIndex}].objectId`), dataId: id(value.dataId, `patches[${patchIndex}].dataId`), mode: mode(value.mode, `patches[${patchIndex}].mode`), start: integer(value.start, `patches[${patchIndex}].start`, 0, Number.MAX_SAFE_INTEGER), end: integer(value.end, `patches[${patchIndex}].end`, 0, Number.MAX_SAFE_INTEGER), selected: value.selected, ...(parsedKind ? { nonMeshKind: parsedKind } : {}), }; if (patch.end < patch.start) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `patches[${patchIndex}] range is reversed`); const length = patch.end - patch.start + 1; touched += length; if (!Number.isSafeInteger(touched) || touched > SELECTION_HISTORY_BUDGET.maxElements) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection range patch span exceeds the budget"); } if (!state.objectIds.includes(patch.objectId)) { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection range patch owner must be selected"); } const key = targetKey(patch); const target = targets.get(key) ?? { objectId: patch.objectId, dataId: patch.dataId, mode: patch.mode, indices: new Set(), ...(patch.nonMeshKind ? { nonMeshKind: patch.nonMeshKind } : {}) }; for (let index = patch.start; index <= patch.end; index++) { if (patch.selected) target.indices.add(index); else target.indices.delete(index); } if (target.indices.size === 0) targets.delete(key); else targets.set(key, target); } return parseSelectionState({ activeObjectId: state.activeObjectId, objectIds: state.objectIds, targets: [...targets.values()].map((target) => ({ ...target, indices: [...target.indices] })), }); } export function parseRaycastSelectionHit(value: unknown, expectedRevision: number): RaycastSelectionHitIR { if (!record(value) || value.sourceRevision !== expectedRevision || typeof value.dataId !== "string" || !value.dataId || !["VERT", "EDGE", "FACE"].includes(value.mode as string) || !Number.isSafeInteger(value.index) || (value.index as number) < 0 || typeof value.distance !== "number" || !Number.isFinite(value.distance) || value.distance < 0 || !Array.isArray(value.point) || value.point.length !== 3 || value.point.some((item) => typeof item !== "number" || !Number.isFinite(item))) { throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast hit is stale or invalid"); } const parsedKind = kind(value.nonMeshKind, "RAYCAST_HIT_INVALID"); const objectId = value.objectId === undefined ? undefined : id(value.objectId, "objectId"); return { sourceRevision: value.sourceRevision as number, ...(objectId ? { objectId } : {}), dataId: value.dataId, mode: value.mode as SelectionElementMode, index: value.index as number, distance: value.distance, point: value.point as [number, number, number], ...(parsedKind ? { nonMeshKind: parsedKind } : {}), }; } export function gateSelectionInteraction(operation: "RAYCAST" | "HISTORY" | "GIZMO"): CapabilityGateResult { if (operation !== "GIZMO") return readyGate("N-015", operation); return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve gizmo preview remains unavailable; bounded multi-handle commit is supported")]); }