213 lines
9.3 KiB
TypeScript
213 lines
9.3 KiB
TypeScript
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<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 exact(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 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<string, number>();
|
|
const pointIndices = new Map<string, string>();
|
|
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,
|
|
});
|
|
}
|