93 lines
5.1 KiB
TypeScript
93 lines
5.1 KiB
TypeScript
export const GREASE_PENCIL_EDITOR_SCHEMA = 1 as const;
|
|
export const GREASE_PENCIL_EDITOR_BUDGET = { maxSelection: 1_000_000, maxIdLength: 256 } as const;
|
|
|
|
export interface GreasePencilPointSelectionIR { strokeIndex: number; pointIndex: number }
|
|
export interface GreasePencilEditorIR {
|
|
schemaVersion: typeof GREASE_PENCIL_EDITOR_SCHEMA;
|
|
revision: number;
|
|
dataId: string;
|
|
layerId: string;
|
|
frame: number;
|
|
onionSkinning: boolean;
|
|
selectedStrokeIndices: number[];
|
|
selectedPoints: GreasePencilPointSelectionIR[];
|
|
}
|
|
|
|
export type GreasePencilEditorEditIR =
|
|
| { type: "SET_FRAME"; revision: number; frame: number }
|
|
| { type: "SET_LAYER"; revision: number; layerId: string }
|
|
| { type: "SET_ONION"; revision: number; enabled: boolean }
|
|
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] };
|
|
|
|
function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); }
|
|
function record(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
|
|
return value as Record<string, unknown>;
|
|
}
|
|
function id(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxIdLength) fail(path, "is invalid");
|
|
return value;
|
|
}
|
|
function integer(value: unknown, path: string): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) fail(path, "is outside the supported frame/index range");
|
|
return value;
|
|
}
|
|
function nonnegativeIndex(value: unknown, path: string): number {
|
|
const result = integer(value, path);
|
|
if (result < 0) fail(path, "must be non-negative");
|
|
return result;
|
|
}
|
|
function uniqueIndices(value: unknown, path: string): number[] {
|
|
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
|
|
const result = value.map((item, itemIndex) => nonnegativeIndex(item, `${path}[${itemIndex}]`));
|
|
if (new Set(result).size !== result.length) fail(path, "contains duplicates");
|
|
return result.sort((left, right) => left - right);
|
|
}
|
|
function points(value: unknown, path: string): GreasePencilPointSelectionIR[] {
|
|
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
|
|
const seen = new Set<string>();
|
|
const result = value.map((item, index) => {
|
|
const point = record(item, `${path}[${index}]`);
|
|
const parsed = { strokeIndex: nonnegativeIndex(point.strokeIndex, `${path}[${index}].strokeIndex`), pointIndex: nonnegativeIndex(point.pointIndex, `${path}[${index}].pointIndex`) };
|
|
const key = `${parsed.strokeIndex}:${parsed.pointIndex}`;
|
|
if (seen.has(key)) fail(`${path}[${index}]`, "contains duplicates");
|
|
seen.add(key);
|
|
return parsed;
|
|
});
|
|
return result.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex);
|
|
}
|
|
|
|
export function parseGreasePencilEditor(value: unknown): GreasePencilEditorIR {
|
|
const editor = record(value, "editor");
|
|
if (editor.schemaVersion !== GREASE_PENCIL_EDITOR_SCHEMA) fail("schemaVersion", "is unsupported");
|
|
if (typeof editor.revision !== "number" || !Number.isSafeInteger(editor.revision) || editor.revision < 0) fail("revision", "is invalid");
|
|
if (typeof editor.frame !== "number" || !Number.isSafeInteger(editor.frame) || editor.frame < -1_000_000 || editor.frame > 1_000_000) fail("frame", "is invalid");
|
|
if (typeof editor.onionSkinning !== "boolean") fail("onionSkinning", "must be boolean");
|
|
return {
|
|
schemaVersion: GREASE_PENCIL_EDITOR_SCHEMA,
|
|
revision: editor.revision,
|
|
dataId: id(editor.dataId, "dataId"),
|
|
layerId: id(editor.layerId, "layerId"),
|
|
frame: editor.frame,
|
|
onionSkinning: editor.onionSkinning,
|
|
selectedStrokeIndices: uniqueIndices(editor.selectedStrokeIndices, "selectedStrokeIndices"),
|
|
selectedPoints: points(editor.selectedPoints, "selectedPoints"),
|
|
};
|
|
}
|
|
|
|
export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown): GreasePencilEditorIR {
|
|
const editor = parseGreasePencilEditor(value);
|
|
const edit = record(editValue, "edit");
|
|
if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale");
|
|
const revision = editor.revision + 1;
|
|
switch (edit.type) {
|
|
case "SET_FRAME": return parseGreasePencilEditor({ ...editor, revision, frame: integer(edit.frame, "edit.frame") });
|
|
case "SET_LAYER": return parseGreasePencilEditor({ ...editor, revision, layerId: id(edit.layerId, "edit.layerId") });
|
|
case "SET_ONION":
|
|
if (typeof edit.enabled !== "boolean") fail("edit.enabled", "must be boolean");
|
|
return parseGreasePencilEditor({ ...editor, revision, onionSkinning: edit.enabled });
|
|
case "SET_SELECTION": return parseGreasePencilEditor({ ...editor, revision, selectedStrokeIndices: uniqueIndices(edit.strokeIndices, "edit.strokeIndices"), selectedPoints: points(edit.points, "edit.points") });
|
|
default: fail("edit.type", "is unsupported");
|
|
}
|
|
}
|