252 lines
13 KiB
TypeScript
252 lines
13 KiB
TypeScript
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 {
|
|
id: string;
|
|
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<number | boolean>;
|
|
}
|
|
|
|
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[];
|
|
activeLayerId?: string;
|
|
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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<number | boolean>;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parsePoint(value: unknown, path: string): GreasePencilPointIR {
|
|
const point = record(value, path);
|
|
const id = string(point.id, `${path}.id`);
|
|
if (!id.startsWith("grease-pencil-point:")) fail(`${path}.id`, "must be a stable Grease Pencil point identity");
|
|
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 = { id, 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 id = string(stroke.id, `${path}.id`);
|
|
if (!id.startsWith("grease-pencil-stroke:")) fail(`${path}.id`, "must be a stable Grease Pencil stroke identity");
|
|
const result: GreasePencilStrokeIR = {
|
|
id,
|
|
cyclic: stroke.cyclic === true,
|
|
pointCount: count(stroke.pointCount, `${path}.pointCount`),
|
|
};
|
|
if (typeof stroke.cyclic !== "boolean") fail(`${path}.cyclic`, "must be a boolean");
|
|
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 (new Set(strokes.map((stroke) => stroke.id)).size !== strokes.length) fail(`${path}.strokes`, "contains duplicate stable stroke identities");
|
|
const pointIds = strokes.flatMap((stroke) => (stroke.points ?? []).map((point) => point.id));
|
|
if (new Set(pointIds).size !== pointIds.length) fail(`${path}.strokes`, "contains duplicate stable point identities");
|
|
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.activeLayerId !== undefined) {
|
|
const activeLayerId = string(data.activeLayerId, `${path}.activeLayerId`);
|
|
if (!layers.some((layer) => layer.id === activeLayerId)) fail(`${path}.activeLayerId`, "must reference a declared layer");
|
|
result.activeLayerId = activeLayerId;
|
|
}
|
|
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;
|
|
}
|