Files
workinf_Blender_Wasm/web/protocol/selection-history.ts
2026-08-14 18:08:29 -04:00

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);
}