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[] } | { type: "TRANSLATE_POINTS"; revision: number; translation: [number, number, number] }; export interface GreasePencilEditablePointIR { position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number]; } export interface GreasePencilEditableStrokeIR { cyclic?: boolean; materialIndex?: number; points: GreasePencilEditablePointIR[]; } export interface GreasePencilPointEditResultIR { editor: GreasePencilEditorIR; strokes: GreasePencilEditableStrokeIR[]; } function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); } function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object"); return value as Record; } 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 finite(value: unknown, path: string): number { if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 1_000_000) fail(path, "must be finite and bounded"); return value; } function tuple(value: unknown, length: number, path: string): number[] { if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`); return value.map((item, index) => finite(item, `${path}[${index}]`)); } 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(); 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") }); case "TRANSLATE_POINTS": tuple(edit.translation, 3, "edit.translation"); if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation"); return parseGreasePencilEditor({ ...editor, revision }); default: fail("edit.type", "is unsupported"); } } function parseEditablePoint(value: unknown, path: string): GreasePencilEditablePointIR { const point = record(value, path); const result: GreasePencilEditablePointIR = { position: tuple(point.position, 3, `${path}.position`) as [number, number, number] }; if (point.radius !== undefined) { result.radius = finite(point.radius, `${path}.radius`); if (result.radius < 0) fail(`${path}.radius`, "must be non-negative"); } if (point.opacity !== undefined) { result.opacity = finite(point.opacity, `${path}.opacity`); if (result.opacity < 0 || result.opacity > 1) fail(`${path}.opacity`, "must be in [0,1]"); } if (point.vertexColor !== undefined) { result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number]; if (result.vertexColor.some((component) => component < 0 || component > 1)) fail(`${path}.vertexColor`, "must be in [0,1]"); } return result; } function parseEditableStrokes(value: unknown): GreasePencilEditableStrokeIR[] { if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the stroke budget"); let pointCount = 0; return value.map((item, strokeIndex) => { const stroke = record(item, `strokes[${strokeIndex}]`); if (!Array.isArray(stroke.points) || stroke.points.length === 0) fail(`strokes[${strokeIndex}].points`, "must be a non-empty array"); pointCount += stroke.points.length; if (pointCount > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the point budget"); if (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") fail(`strokes[${strokeIndex}].cyclic`, "must be boolean"); if (stroke.materialIndex !== undefined) nonnegativeIndex(stroke.materialIndex, `strokes[${strokeIndex}].materialIndex`); return { ...(stroke.cyclic !== undefined ? { cyclic: stroke.cyclic } : {}), ...(stroke.materialIndex !== undefined ? { materialIndex: stroke.materialIndex as number } : {}), points: stroke.points.map((point, pointIndex) => parseEditablePoint(point, `strokes[${strokeIndex}].points[${pointIndex}]`)), }; }); } export function applyGreasePencilPointTranslation( editorValue: unknown, strokesValue: unknown, editValue: unknown, ): GreasePencilPointEditResultIR { const editor = parseGreasePencilEditor(editorValue); const strokes = parseEditableStrokes(strokesValue); const edit = record(editValue, "edit"); if (edit.type !== "TRANSLATE_POINTS") fail("edit.type", "must be TRANSLATE_POINTS"); if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale"); const translation = tuple(edit.translation, 3, "edit.translation") as [number, number, number]; if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation"); const selected = new Set(editor.selectedPoints.map((point) => `${point.strokeIndex}:${point.pointIndex}`)); for (const point of editor.selectedPoints) { if (!strokes[point.strokeIndex]?.points[point.pointIndex]) fail("editor.selectedPoints", `references missing point ${point.strokeIndex}:${point.pointIndex}`); } const translated = strokes.map((stroke, strokeIndex) => ({ ...stroke, points: stroke.points.map((point, pointIndex) => selected.has(`${strokeIndex}:${pointIndex}`) ? { ...point, position: point.position.map((component, axis) => finite(component + translation[axis], `translated[${strokeIndex}][${pointIndex}][${axis}]`)) as [number, number, number], } : { ...point, position: [...point.position] as [number, number, number] }), })); return { editor: parseGreasePencilEditor({ ...editor, revision: editor.revision + 1 }), strokes: translated, }; }