import type { GreasePencilDrawingScopeIR, GreasePencilStablePointRefIR } from "./grease-pencil-marquee"; export const GREASE_PENCIL_SELECTION_SCHEMA_VERSION = 1 as const; export const GREASE_PENCIL_SELECTION_BUDGET = { maxPoints: 1_000_000, maxIdBytes: 256 } as const; export type GreasePencilSelectionSource = "CANVAS_2D" | "VIEWPORT_3D"; export type GreasePencilSelectionOperation = "REPLACE" | "ADD" | "TOGGLE" | "CLEAR"; export interface GreasePencilSelectionStateIR { schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION; revision: number; drawing: GreasePencilDrawingScopeIR; selectedPoints: GreasePencilStablePointRefIR[]; lastSource: GreasePencilSelectionSource | null; } export interface GreasePencilSelectionEditIR { schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION; baseSelectionRevision: number; source: GreasePencilSelectionSource; operation: GreasePencilSelectionOperation; points: GreasePencilStablePointRefIR[]; } export type GreasePencilSelectionErrorCode = | "GREASE_PENCIL_SELECTION_INVALID" | "GREASE_PENCIL_SELECTION_SCOPE_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED" | "REVISION_CONFLICT"; export class GreasePencilSelectionValidationError extends Error { constructor(readonly code: GreasePencilSelectionErrorCode, message: string) { super(`${code}: ${message}`); this.name = "GreasePencilSelectionValidationError"; } } const encoder = new TextEncoder(); const SCOPE_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]); const POINT_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex", "pointIndex"]); const STATE_FIELDS = new Set(["schemaVersion", "revision", "drawing", "selectedPoints", "lastSource"]); const EDIT_FIELDS = new Set(["schemaVersion", "baseSelectionRevision", "source", "operation", "points"]); function fail(code: GreasePencilSelectionErrorCode, message: string): never { throw new GreasePencilSelectionValidationError(code, message); } function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`); } return value as Record; } function exact(value: Record, fields: ReadonlySet, path: string): void { if (Object.keys(value).some((field) => !fields.has(field))) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains undeclared fields`); } } function integer(value: unknown, path: string, allowNegative = false): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || (!allowNegative && value < 0) || value < -1_000_000 || value > 1_000_000) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is outside the supported integer range`); } return value; } function id(value: unknown, prefix: string, path: string): string { if (typeof value !== "string" || !value.startsWith(prefix) || encoder.encode(value).byteLength > GREASE_PENCIL_SELECTION_BUDGET.maxIdBytes) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`); } return value; } function scope(value: unknown, path: string): GreasePencilDrawingScopeIR { const candidate = record(value, path); exact(candidate, SCOPE_FIELDS, path); return { dataId: id(candidate.dataId, "grease-pencil:", `${path}.dataId`), layerId: id(candidate.layerId, "grease-pencil-layer:", `${path}.layerId`), frame: integer(candidate.frame, `${path}.frame`, true), drawingId: id(candidate.drawingId, "grease-pencil-drawing:", `${path}.drawingId`), }; } function sameDrawing(left: GreasePencilDrawingScopeIR, right: GreasePencilDrawingScopeIR): boolean { return left.dataId === right.dataId && left.layerId === right.layerId && left.frame === right.frame && left.drawingId === right.drawingId; } function point(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR { const candidate = record(value, path); exact(candidate, POINT_FIELDS, path); const pointScope = scope({ dataId: candidate.dataId, layerId: candidate.layerId, frame: candidate.frame, drawingId: candidate.drawingId, }, path); if (!sameDrawing(pointScope, drawing)) { fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`); } return { ...pointScope, strokeId: id(candidate.strokeId, "grease-pencil-stroke:", `${path}.strokeId`), pointId: id(candidate.pointId, "grease-pencil-point:", `${path}.pointId`), strokeIndex: integer(candidate.strokeIndex, `${path}.strokeIndex`), pointIndex: integer(candidate.pointIndex, `${path}.pointIndex`), }; } function points(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR[] { if (!Array.isArray(value)) fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an array`); if (value.length > GREASE_PENCIL_SELECTION_BUDGET.maxPoints) { fail("GREASE_PENCIL_BUDGET_EXCEEDED", `${path} exceeds the selection point budget`); } const parsed = value.map((item, index) => point(item, `${path}[${index}]`, drawing)); if (new Set(parsed.map((item) => item.pointId)).size !== parsed.length) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains duplicate point identities`); } const strokeIndices = new Map(); const pointIndices = new Map(); for (const item of parsed) { const priorStrokeIndex = strokeIndices.get(item.strokeId); if (priorStrokeIndex !== undefined && priorStrokeIndex !== item.strokeIndex) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one stroke identity to multiple indices`); } strokeIndices.set(item.strokeId, item.strokeIndex); const indexKey = `${item.strokeIndex}:${item.pointIndex}`; const priorPointId = pointIndices.get(indexKey); if (priorPointId !== undefined && priorPointId !== item.pointId) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one point index to multiple identities`); } pointIndices.set(indexKey, item.pointId); } return parsed.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId)); } export function parseGreasePencilSelectionState(value: unknown): GreasePencilSelectionStateIR { const state = record(value, "state"); exact(state, STATE_FIELDS, "state"); if (state.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION) { fail("GREASE_PENCIL_SELECTION_INVALID", "state.schemaVersion is unsupported"); } const drawing = scope(state.drawing, "state.drawing"); if (state.lastSource !== null && state.lastSource !== "CANVAS_2D" && state.lastSource !== "VIEWPORT_3D") { fail("GREASE_PENCIL_SELECTION_INVALID", "state.lastSource is invalid"); } return { schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION, revision: integer(state.revision, "state.revision"), drawing, selectedPoints: points(state.selectedPoints, "state.selectedPoints", drawing), lastSource: state.lastSource, }; } export function createGreasePencilSelectionState(drawing: GreasePencilDrawingScopeIR): GreasePencilSelectionStateIR { return parseGreasePencilSelectionState({ schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION, revision: 0, drawing, selectedPoints: [], lastSource: null, }); } export function applyGreasePencilSelectionEdit( stateValue: unknown, editValue: unknown, currentDrawing: GreasePencilDrawingScopeIR, ): GreasePencilSelectionStateIR { const state = parseGreasePencilSelectionState(stateValue); const expectedDrawing = scope(currentDrawing, "currentDrawing"); if (!sameDrawing(state.drawing, expectedDrawing)) { fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", "selection state is not bound to the current drawing"); } const edit = record(editValue, "edit"); exact(edit, EDIT_FIELDS, "edit"); if (edit.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION || (edit.source !== "CANVAS_2D" && edit.source !== "VIEWPORT_3D") || !["REPLACE", "ADD", "TOGGLE", "CLEAR"].includes(String(edit.operation))) { fail("GREASE_PENCIL_SELECTION_INVALID", "selection edit schema is invalid"); } const baseSelectionRevision = integer(edit.baseSelectionRevision, "edit.baseSelectionRevision"); if (baseSelectionRevision !== state.revision) { fail("REVISION_CONFLICT", "Grease Pencil selection edit is stale"); } const editedPoints = points(edit.points, "edit.points", expectedDrawing); if (edit.operation === "CLEAR" && editedPoints.length !== 0) { fail("GREASE_PENCIL_SELECTION_INVALID", "CLEAR cannot include points"); } const selected = new Map(state.selectedPoints.map((item) => [item.pointId, item])); if (edit.operation === "REPLACE" || edit.operation === "CLEAR") selected.clear(); if (edit.operation === "REPLACE" || edit.operation === "ADD") { for (const item of editedPoints) selected.set(item.pointId, item); } else if (edit.operation === "TOGGLE") { for (const item of editedPoints) { if (selected.has(item.pointId)) selected.delete(item.pointId); else selected.set(item.pointId, item); } } return parseGreasePencilSelectionState({ ...state, revision: state.revision + 1, selectedPoints: [...selected.values()], lastSource: edit.source, }); }