export const GREASE_PENCIL_BUDGET = { maxLayers: 1_024, maxFrames: 100_000, maxStrokes: 1_000_000, maxPoints: 1_000_000, maxAttributes: 65_536, } as const; export type GreasePencilGeometryStatus = "summary-only" | "available" | "blocked"; export type GreasePencilAttributeDomain = "POINT" | "STROKE" | "CURVE" | "INSTANCE"; export type GreasePencilAttributeDataType = "BOOL" | "INT" | "FLOAT" | "FLOAT2" | "FLOAT3" | "FLOAT4" | "BYTE_COLOR" | "FLOAT_COLOR"; export interface GreasePencilPointIR { position: [number, number, number]; radius: number; opacity: number; vertexColor?: [number, number, number, number]; } export interface GreasePencilAttributeIR { name: string; domain: GreasePencilAttributeDomain; dataType: GreasePencilAttributeDataType; components: 1 | 2 | 3 | 4; values?: Array; } export interface GreasePencilStrokeIR { id?: string; cyclic: boolean; pointCount: number; points?: GreasePencilPointIR[]; materialIndex?: number; attributes?: GreasePencilAttributeIR[]; } export interface GreasePencilDrawingIR { id: string; strokeCount: number; pointCount: number; strokes: GreasePencilStrokeIR[]; attributes?: GreasePencilAttributeIR[]; } export interface GreasePencilFrameIR { frame: number; drawing: GreasePencilDrawingIR; duration?: number; } export interface GreasePencilLayerIR { id: string; name: string; visible: boolean; locked: boolean; opacity: number; onionSkinning?: boolean; frames: GreasePencilFrameIR[]; } export interface GreasePencilDataIR { id: string; name: string; geometryStatus: GreasePencilGeometryStatus; layerCount: number; frameCount: number; strokeCount: number; pointCount: number; layers: GreasePencilLayerIR[]; attributes?: GreasePencilAttributeIR[]; errorCode?: "GREASE_PENCIL_SCHEMA_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED"; } function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_SCHEMA_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 string(value: unknown, path: string): string { if (typeof value !== "string" || value.length === 0 || value.length > 255) fail(path, "must be a non-empty string up to 255 characters"); return value; } function number(value: unknown, path: string): number { if (typeof value !== "number" || !Number.isFinite(value)) fail(path, "must be finite"); return value; } function count(value: unknown, path: string): number { const result = number(value, path); if (!Number.isSafeInteger(result) || result < 0) fail(path, "must be a non-negative safe integer"); return result; } function tuple(value: unknown, length: number, path: string): [number, number, number] | [number, number, number, number] { if (!Array.isArray(value) || value.length !== length || value.some((item) => typeof item !== "number" || !Number.isFinite(item))) fail(path, `must contain ${length} finite numbers`); return value as [number, number, number] | [number, number, number, number]; } function parseAttribute(value: unknown, path: string, domainCount: number): GreasePencilAttributeIR { const attribute = record(value, path); const name = string(attribute.name, `${path}.name`); const domain = attribute.domain; if (domain !== "POINT" && domain !== "STROKE" && domain !== "CURVE" && domain !== "INSTANCE") fail(`${path}.domain`, "is invalid"); const dataType = attribute.dataType; if (dataType !== "BOOL" && dataType !== "INT" && dataType !== "FLOAT" && dataType !== "FLOAT2" && dataType !== "FLOAT3" && dataType !== "FLOAT4" && dataType !== "BYTE_COLOR" && dataType !== "FLOAT_COLOR") fail(`${path}.dataType`, "is invalid"); const components = count(attribute.components, `${path}.components`); if (components < 1 || components > 4) fail(`${path}.components`, "must be between 1 and 4"); const result: GreasePencilAttributeIR = { name, domain, dataType, components: components as 1 | 2 | 3 | 4 }; if (attribute.values !== undefined) { if (!Array.isArray(attribute.values)) fail(`${path}.values`, "must be an array"); const expected = (domain === "POINT" || domain === "STROKE" || domain === "CURVE" ? domainCount : 1) * components; if (attribute.values.length !== expected) fail(`${path}.values`, `must contain ${expected} entries`); if (attribute.values.some((item) => (dataType === "BOOL" ? typeof item !== "boolean" : typeof item !== "number" || !Number.isFinite(item)))) fail(`${path}.values`, "contains an invalid value"); result.values = attribute.values as Array; } return result; } function parsePoint(value: unknown, path: string): GreasePencilPointIR { const point = record(value, path); const position = tuple(point.position, 3, `${path}.position`) as [number, number, number]; const radius = number(point.radius, `${path}.radius`); const opacity = number(point.opacity, `${path}.opacity`); if (radius < 0 || radius > 1_000_000) fail(`${path}.radius`, "is outside the bounded range"); if (opacity < 0 || opacity > 1) fail(`${path}.opacity`, "must be in [0,1]"); const result: GreasePencilPointIR = { position, radius, opacity }; if (point.vertexColor !== undefined) result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number]; return result; } function parseStroke(value: unknown, path: string): GreasePencilStrokeIR { const stroke = record(value, path); const result: GreasePencilStrokeIR = { cyclic: stroke.cyclic === true, pointCount: count(stroke.pointCount, `${path}.pointCount`), }; if (typeof stroke.cyclic !== "boolean") fail(`${path}.cyclic`, "must be a boolean"); if (stroke.id !== undefined) result.id = string(stroke.id, `${path}.id`); if (stroke.materialIndex !== undefined) result.materialIndex = count(stroke.materialIndex, `${path}.materialIndex`); if (stroke.points !== undefined) { if (!Array.isArray(stroke.points)) fail(`${path}.points`, "must be an array"); if (stroke.points.length !== result.pointCount) fail(`${path}.points`, "length must match pointCount"); result.points = stroke.points.map((point, index) => parsePoint(point, `${path}.points[${index}]`)); } if (stroke.attributes !== undefined) { if (!Array.isArray(stroke.attributes)) fail(`${path}.attributes`, "must be an array"); result.attributes = stroke.attributes.map((attribute, index) => parseAttribute(attribute, `${path}.attributes[${index}]`, result.pointCount)); } return result; } function parseDrawing(value: unknown, path: string): GreasePencilDrawingIR { const drawing = record(value, path); const id = string(drawing.id, `${path}.id`); const strokeCount = count(drawing.strokeCount, `${path}.strokeCount`); const pointCount = count(drawing.pointCount, `${path}.pointCount`); if (!Array.isArray(drawing.strokes)) fail(`${path}.strokes`, "must be an array"); if (drawing.strokes.length !== strokeCount) fail(`${path}.strokes`, "length must match strokeCount"); const strokes = drawing.strokes.map((stroke, index) => parseStroke(stroke, `${path}.strokes[${index}]`)); if (strokes.reduce((sum, stroke) => sum + stroke.pointCount, 0) !== pointCount) fail(`${path}.pointCount`, "must equal the sum of stroke point counts"); const result: GreasePencilDrawingIR = { id, strokeCount, pointCount, strokes }; if (drawing.attributes !== undefined) { if (!Array.isArray(drawing.attributes)) fail(`${path}.attributes`, "must be an array"); result.attributes = drawing.attributes.map((attribute, index) => parseAttribute(attribute, `${path}.attributes[${index}]`, strokeCount)); } return result; } function parseFrame(value: unknown, path: string): GreasePencilFrameIR { const frame = record(value, path); const frameNumber = number(frame.frame, `${path}.frame`); if (!Number.isSafeInteger(frameNumber) || frameNumber < -1_000_000 || frameNumber > 1_000_000) fail(`${path}.frame`, "is outside the supported range"); const result: GreasePencilFrameIR = { frame: frameNumber, drawing: parseDrawing(frame.drawing, `${path}.drawing`) }; if (frame.duration !== undefined) { const duration = count(frame.duration, `${path}.duration`); if (duration > 1_000_000) fail(`${path}.duration`, "is outside the supported range"); result.duration = duration; } return result; } function parseLayer(value: unknown, path: string): GreasePencilLayerIR { const layer = record(value, path); const name = string(layer.name, `${path}.name`); const id = string(layer.id, `${path}.id`); if (typeof layer.visible !== "boolean") fail(`${path}.visible`, "must be a boolean"); if (typeof layer.locked !== "boolean") fail(`${path}.locked`, "must be a boolean"); const opacity = number(layer.opacity, `${path}.opacity`); if (opacity < 0 || opacity > 1) fail(`${path}.opacity`, "must be in [0,1]"); if (!Array.isArray(layer.frames)) fail(`${path}.frames`, "must be an array"); const frames = layer.frames.map((frame, index) => parseFrame(frame, `${path}.frames[${index}]`)); const result: GreasePencilLayerIR = { id, name, visible: layer.visible, locked: layer.locked, opacity, frames }; if (layer.onionSkinning !== undefined) { if (typeof layer.onionSkinning !== "boolean") fail(`${path}.onionSkinning`, "must be a boolean"); result.onionSkinning = layer.onionSkinning; } return result; } export function parseGreasePencilData(value: unknown, path = "greasePencils"): GreasePencilDataIR { const data = record(value, path); const id = string(data.id, `${path}.id`); const name = string(data.name, `${path}.name`); const geometryStatus = data.geometryStatus; if (geometryStatus !== "summary-only" && geometryStatus !== "available" && geometryStatus !== "blocked") fail(`${path}.geometryStatus`, "is invalid"); const layerCount = count(data.layerCount, `${path}.layerCount`); const frameCount = count(data.frameCount, `${path}.frameCount`); const strokeCount = count(data.strokeCount, `${path}.strokeCount`); const pointCount = count(data.pointCount, `${path}.pointCount`); if (!Array.isArray(data.layers)) fail(`${path}.layers`, "must be an array"); const budgetBlocked = geometryStatus === "blocked" && data.errorCode === "GREASE_PENCIL_BUDGET_EXCEEDED"; if (!budgetBlocked && data.layers.length !== layerCount) fail(`${path}.layers`, "length must match layerCount"); if (layerCount > GREASE_PENCIL_BUDGET.maxLayers || frameCount > GREASE_PENCIL_BUDGET.maxFrames || strokeCount > GREASE_PENCIL_BUDGET.maxStrokes || pointCount > GREASE_PENCIL_BUDGET.maxPoints) { if (!budgetBlocked) fail(path, "exceeds the bounded layer/frame/stroke/point budget"); } const layers = data.layers.map((layer, index) => parseLayer(layer, `${path}.layers[${index}]`)); const actualFrames = layers.reduce((sum, layer) => sum + layer.frames.length, 0); const actualStrokes = layers.reduce((sum, layer) => sum + layer.frames.reduce((frameSum, frame) => frameSum + frame.drawing.strokeCount, 0), 0); const actualPoints = layers.reduce((sum, layer) => sum + layer.frames.reduce((frameSum, frame) => frameSum + frame.drawing.pointCount, 0), 0); if (!budgetBlocked && (actualFrames !== frameCount || actualStrokes !== strokeCount || actualPoints !== pointCount)) fail(path, "declared counts do not match layer/frame/drawing contents"); const result: GreasePencilDataIR = { id, name, geometryStatus, layerCount, frameCount, strokeCount, pointCount, layers }; if (data.errorCode !== undefined) { if (data.errorCode !== "GREASE_PENCIL_SCHEMA_INVALID" && data.errorCode !== "GREASE_PENCIL_BUDGET_EXCEEDED") fail(`${path}.errorCode`, "is invalid"); result.errorCode = data.errorCode; } if (data.attributes !== undefined) { if (!Array.isArray(data.attributes)) fail(`${path}.attributes`, "must be an array"); if (data.attributes.length > GREASE_PENCIL_BUDGET.maxAttributes) fail(`${path}.attributes`, "exceeds the attribute budget"); result.attributes = data.attributes.map((attribute, index) => parseAttribute(attribute, `${path}.attributes[${index}]`, pointCount)); } return result; }