Add Chromium-only Blender WebEngine parity work
This commit is contained in:
63
web/protocol/selection-history.ts
Normal file
63
web/protocol/selection-history.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SELECTION_HISTORY_SCHEMA = 1 as const;
|
||||
export const SELECTION_HISTORY_BUDGET = { maxEntries: 256, maxObjects: 100_000, maxElements: 1_000_000 } as const;
|
||||
export type SelectionElementMode = "VERT" | "EDGE" | "FACE";
|
||||
export type NonMeshSelectionKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
|
||||
export interface SelectionStateIR { activeObjectId: string | null; objectIds: string[]; meshId: string | null; elementMode: SelectionElementMode; elementIndices: number[]; nonMeshKind?: NonMeshSelectionKind }
|
||||
export interface SelectionHistoryIR { schemaVersion: typeof SELECTION_HISTORY_SCHEMA; revision: number; cursor: number; entries: SelectionStateIR[] }
|
||||
export interface RaycastSelectionHitIR { sourceRevision: number; 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 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[]; }
|
||||
|
||||
export function parseSelectionState(value: unknown): SelectionStateIR {
|
||||
if (!record(value) || !["VERT", "EDGE", "FACE"].includes(value.elementMode as string)) 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 : typeof value.activeObjectId === "string" ? value.activeObjectId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "activeObjectId is invalid"); })();
|
||||
if (activeObjectId !== null && !objectIds.includes(activeObjectId)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected");
|
||||
if (!Array.isArray(value.elementIndices) || value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements || value.elementIndices.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value.elementIndices).size !== value.elementIndices.length) throw new SelectionHistoryValidationError(value.elementIndices instanceof Array && value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", "elementIndices are invalid");
|
||||
const meshId = value.meshId === null ? null : typeof value.meshId === "string" && value.meshId.length > 0 ? value.meshId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "meshId is invalid"); })();
|
||||
if (meshId === null && value.elementIndices.length > 0) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires meshId");
|
||||
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "nonMeshKind is invalid"); })();
|
||||
return { activeObjectId, objectIds, meshId, elementMode: value.elementMode as SelectionElementMode, elementIndices: [...value.elementIndices].sort((a, b) => a - b) as number[], ...(nonMeshKind ? { nonMeshKind } : {}) };
|
||||
}
|
||||
|
||||
export function parseSelectionHistory(value: unknown): SelectionHistoryIR {
|
||||
if (!record(value) || value.schemaVersion !== SELECTION_HISTORY_SCHEMA || !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(a: SelectionStateIR, b: SelectionStateIR): boolean { return a.activeObjectId === b.activeObjectId && a.meshId === b.meshId && a.elementMode === b.elementMode && a.nonMeshKind === b.nonMeshKind && a.objectIds.join("\0") === b.objectIds.join("\0") && a.elementIndices.join(",") === b.elementIndices.join(","); }
|
||||
|
||||
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 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 nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast non-mesh identity is invalid"); })();
|
||||
return { sourceRevision: value.sourceRevision as number, dataId: value.dataId, mode: value.mode as SelectionElementMode, index: value.index as number, distance: value.distance, point: value.point as [number, number, number], ...(nonMeshKind ? { nonMeshKind } : {}) };
|
||||
}
|
||||
|
||||
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/non-mesh gizmo interaction is not implemented")]);
|
||||
}
|
||||
Reference in New Issue
Block a user