226 lines
8.8 KiB
TypeScript
226 lines
8.8 KiB
TypeScript
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactFields(value: Record<string, unknown>, fields: ReadonlySet<string>, 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<string>();
|
|
const strokeIndices = new Map<string, number>();
|
|
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,
|
|
};
|
|
}
|