export const GREASE_PENCIL_MARQUEE_SCHEMA_VERSION = 1 as const; export const GREASE_PENCIL_MARQUEE_BUDGET = { maxCandidates: 1_000_000, maxIdBytes: 256, } as const; export interface GreasePencilDrawingScopeIR { dataId: string; layerId: string; frame: number; drawingId: string; } export interface GreasePencilStablePointRefIR extends GreasePencilDrawingScopeIR { strokeId: string; pointId: string; strokeIndex: number; pointIndex: number; } export interface GreasePencilMarqueeCandidateIR extends GreasePencilStablePointRefIR { viewportPosition: [number, number]; } export interface GreasePencilMarqueeBoxIR { left: number; top: number; right: number; bottom: number; } export interface GreasePencilMarqueeRequestIR { schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION; baseRevision: number; baseSelectionRevision: number; drawing: GreasePencilDrawingScopeIR; box: GreasePencilMarqueeBoxIR; candidates: GreasePencilMarqueeCandidateIR[]; } export interface GreasePencilMarqueeResultIR { schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION; baseRevision: number; baseSelectionRevision: number; drawing: GreasePencilDrawingScopeIR; selectedStrokeIds: string[]; selectedPoints: GreasePencilStablePointRefIR[]; } export type GreasePencilMarqueeErrorCode = | "GREASE_PENCIL_SELECTION_INVALID" | "GREASE_PENCIL_SELECTION_SCOPE_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED" | "REVISION_CONFLICT"; export class GreasePencilMarqueeValidationError extends Error { constructor(readonly code: GreasePencilMarqueeErrorCode, message: string) { super(`${code}: ${message}`); this.name = "GreasePencilMarqueeValidationError"; } } const DRAWING_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]); const BOX_FIELDS = new Set(["left", "top", "right", "bottom"]); const CANDIDATE_FIELDS = new Set([ "dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex", "pointIndex", "viewportPosition", ]); const REQUEST_FIELDS = new Set(["schemaVersion", "baseRevision", "baseSelectionRevision", "drawing", "box", "candidates"]); const encoder = new TextEncoder(); function fail(code: GreasePencilMarqueeErrorCode, message: string): never { throw new GreasePencilMarqueeValidationError(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 exactFields(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 stableId(value: unknown, prefix: string, path: string): string { if (typeof value !== "string" || !value.startsWith(prefix) || encoder.encode(value).byteLength > GREASE_PENCIL_MARQUEE_BUDGET.maxIdBytes) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`); } return value; } function safeInteger(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 finite(value: unknown, path: string): number { if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 8) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be finite and bounded`); } return value; } function drawingScope(value: unknown, path: string): GreasePencilDrawingScopeIR { const drawing = record(value, path); exactFields(drawing, DRAWING_FIELDS, path); return { dataId: stableId(drawing.dataId, "grease-pencil:", `${path}.dataId`), layerId: stableId(drawing.layerId, "grease-pencil-layer:", `${path}.layerId`), frame: safeInteger(drawing.frame, `${path}.frame`, true), drawingId: stableId(drawing.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 box(value: unknown): GreasePencilMarqueeBoxIR { const candidate = record(value, "request.box"); exactFields(candidate, BOX_FIELDS, "request.box"); const result = { left: finite(candidate.left, "request.box.left"), top: finite(candidate.top, "request.box.top"), right: finite(candidate.right, "request.box.right"), bottom: finite(candidate.bottom, "request.box.bottom"), }; if (result.left < 0 || result.top < 0 || result.right > 1 || result.bottom > 1 || result.left >= result.right || result.top >= result.bottom) { fail("GREASE_PENCIL_SELECTION_INVALID", "request.box must be a non-empty normalized viewport rectangle"); } return result; } function candidate(value: unknown, index: number, drawing: GreasePencilDrawingScopeIR): GreasePencilMarqueeCandidateIR { const path = `request.candidates[${index}]`; const point = record(value, path); exactFields(point, CANDIDATE_FIELDS, path); const scope = drawingScope({ dataId: point.dataId, layerId: point.layerId, frame: point.frame, drawingId: point.drawingId, }, path); if (!sameDrawing(scope, drawing)) { fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`); } if (!Array.isArray(point.viewportPosition) || point.viewportPosition.length !== 2) { fail("GREASE_PENCIL_SELECTION_INVALID", `${path}.viewportPosition must contain two numbers`); } return { ...scope, strokeId: stableId(point.strokeId, "grease-pencil-stroke:", `${path}.strokeId`), pointId: stableId(point.pointId, "grease-pencil-point:", `${path}.pointId`), strokeIndex: safeInteger(point.strokeIndex, `${path}.strokeIndex`), pointIndex: safeInteger(point.pointIndex, `${path}.pointIndex`), viewportPosition: [ finite(point.viewportPosition[0], `${path}.viewportPosition[0]`), finite(point.viewportPosition[1], `${path}.viewportPosition[1]`), ], }; } export function selectGreasePencilMarquee( requestValue: unknown, currentRevision: number, ): GreasePencilMarqueeResultIR { const request = record(requestValue, "request"); exactFields(request, REQUEST_FIELDS, "request"); if (request.schemaVersion !== GREASE_PENCIL_MARQUEE_SCHEMA_VERSION) { fail("GREASE_PENCIL_SELECTION_INVALID", "request.schemaVersion is unsupported"); } const baseRevision = safeInteger(request.baseRevision, "request.baseRevision"); const baseSelectionRevision = safeInteger(request.baseSelectionRevision, "request.baseSelectionRevision"); if (baseRevision !== currentRevision) { fail("REVISION_CONFLICT", "Grease Pencil marquee request is stale"); } const drawing = drawingScope(request.drawing, "request.drawing"); const selectionBox = box(request.box); if (!Array.isArray(request.candidates)) { fail("GREASE_PENCIL_SELECTION_INVALID", "request.candidates must be an array"); } if (request.candidates.length > GREASE_PENCIL_MARQUEE_BUDGET.maxCandidates) { fail("GREASE_PENCIL_BUDGET_EXCEEDED", "Grease Pencil marquee candidate budget exceeded"); } const candidates = request.candidates.map((value, index) => candidate(value, index, drawing)); const pointIds = new Set(); const strokeIndices = new Map(); for (const point of candidates) { if (pointIds.has(point.pointId)) { fail("GREASE_PENCIL_SELECTION_INVALID", `duplicate point identity ${point.pointId}`); } pointIds.add(point.pointId); const priorStrokeIndex = strokeIndices.get(point.strokeId); if (priorStrokeIndex !== undefined && priorStrokeIndex !== point.strokeIndex) { fail("GREASE_PENCIL_SELECTION_INVALID", `stroke identity ${point.strokeId} maps to multiple indices`); } strokeIndices.set(point.strokeId, point.strokeIndex); } const selectedPoints = candidates .filter((point) => point.viewportPosition[0] >= selectionBox.left && point.viewportPosition[0] <= selectionBox.right && point.viewportPosition[1] >= selectionBox.top && point.viewportPosition[1] <= selectionBox.bottom) .map(({ viewportPosition: _viewportPosition, ...point }) => point) .sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId)); return { schemaVersion: GREASE_PENCIL_MARQUEE_SCHEMA_VERSION, baseRevision, baseSelectionRevision, drawing, selectedStrokeIds: [...new Set(selectedPoints.map((point) => point.strokeId))].sort(), selectedPoints, }; }