296 lines
14 KiB
TypeScript
296 lines
14 KiB
TypeScript
import { 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<string, unknown> {
|
|
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<SelectionElementTargetIR, "objectId" | "dataId" | "mode" | "nonMeshKind">): 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<string, unknown>, 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<string>();
|
|
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<number>(), ...(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 {
|
|
return readyGate("N-015", operation);
|
|
}
|