export const PAINT_BUDGET = { maxSamples: 100_000, maxWeightEntries: 1_000_000, maxTextureTileBytes: 256 * 1024 * 1024, maxStrokeBytes: 64 * 1024 * 1024, } as const; export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE"; export interface PaintHitIR { position: [number, number, number]; normal?: [number, number, number]; faceIndex?: number; barycentric?: [number, number, number]; uv?: [number, number]; pressure?: number; } export interface PaintStrokeIR { schemaVersion: 1; mode: PaintMode; objectId: string; revision: number; radius: number; strength: number; samples: PaintHitIR[]; color?: [number, number, number, number]; vertexGroup?: string; textureAssetId?: string; textureTile?: number; spacing?: number; } export interface WeightPatchIR { schemaVersion: 1; objectId: string; revision: number; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean; } function fail(path: string, message: string, budget = false): never { throw new Error(`${budget ? "PAINT_BUDGET_EXCEEDED" : "PAINT_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 finite(value: unknown, path: string): number { if (typeof value !== "number" || !Number.isFinite(value)) fail(path, "must be finite"); return value; } function integer(value: unknown, path: string): number { const result = finite(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[] { if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`); if (value.some((item) => typeof item !== "number" || !Number.isFinite(item))) fail(path, "must contain finite numbers"); return value as number[]; } function string(value: unknown, path: string, allowEmpty = false): string { if (typeof value !== "string" || (!allowEmpty && value.length === 0) || value.length > 255) fail(path, "is outside the bounded string range"); return value; } function parseHit(value: unknown, path: string): PaintHitIR { const hit = record(value, path); const position = tuple(hit.position, 3, `${path}.position`) as [number, number, number]; const result: PaintHitIR = { position }; if (hit.normal !== undefined) result.normal = tuple(hit.normal, 3, `${path}.normal`) as [number, number, number]; if (hit.faceIndex !== undefined) result.faceIndex = integer(hit.faceIndex, `${path}.faceIndex`); if (hit.barycentric !== undefined) { result.barycentric = tuple(hit.barycentric, 3, `${path}.barycentric`) as [number, number, number]; if (result.barycentric.some((item) => item < 0 || item > 1) || Math.abs(result.barycentric.reduce((sum, item) => sum + item, 0) - 1) > 1e-4) fail(`${path}.barycentric`, "must be non-negative and sum to one"); } if (hit.uv !== undefined) result.uv = tuple(hit.uv, 2, `${path}.uv`) as [number, number]; if (hit.pressure !== undefined) { const pressure = finite(hit.pressure, `${path}.pressure`); if (pressure < 0 || pressure > 1) fail(`${path}.pressure`, "must be in [0,1]"); result.pressure = pressure; } return result; } export function parsePaintStroke(value: unknown): PaintStrokeIR { const stroke = record(value, "paintStroke"); if (stroke.schemaVersion !== 1) fail("paintStroke.schemaVersion", "is unsupported"); const mode = stroke.mode; if (mode !== "VERTEX_COLOR" && mode !== "WEIGHT" && mode !== "TEXTURE") fail("paintStroke.mode", "is invalid"); const samples = Array.isArray(stroke.samples) ? stroke.samples : fail("paintStroke.samples", "must be an array"); if (samples.length === 0 || samples.length > PAINT_BUDGET.maxSamples) fail("paintStroke.samples", "exceeds the sample budget", samples.length > PAINT_BUDGET.maxSamples); const radius = finite(stroke.radius, "paintStroke.radius"); const strength = finite(stroke.strength, "paintStroke.strength"); if (radius <= 0 || radius > 100_000) fail("paintStroke.radius", "is outside the bounded range"); if (strength < -1 || strength > 1) fail("paintStroke.strength", "must be in [-1,1]"); const revision = integer(stroke.revision, "paintStroke.revision"); const result: PaintStrokeIR = { schemaVersion: 1, mode, objectId: string(stroke.objectId, "paintStroke.objectId"), revision, radius, strength, samples: samples.map((sample, index) => parseHit(sample, `paintStroke.samples[${index}]`)) }; if (stroke.color !== undefined) { result.color = tuple(stroke.color, 4, "paintStroke.color") as [number, number, number, number]; if (result.color.some((item) => item < 0 || item > 1)) fail("paintStroke.color", "must be in [0,1]"); } if (stroke.vertexGroup !== undefined) result.vertexGroup = string(stroke.vertexGroup, "paintStroke.vertexGroup"); if (stroke.textureAssetId !== undefined) result.textureAssetId = string(stroke.textureAssetId, "paintStroke.textureAssetId"); if (stroke.textureTile !== undefined) result.textureTile = integer(stroke.textureTile, "paintStroke.textureTile"); if (stroke.spacing !== undefined) { result.spacing = finite(stroke.spacing, "paintStroke.spacing"); if (result.spacing < 0 || result.spacing > 100_000) fail("paintStroke.spacing", "is outside the bounded range"); } if (mode === "WEIGHT" && !result.vertexGroup) fail("paintStroke.vertexGroup", "is required for weight paint"); if (mode === "TEXTURE" && !result.textureAssetId) fail("paintStroke.textureAssetId", "is required for texture paint"); const estimatedBytes = samples.length * 64; if (estimatedBytes > PAINT_BUDGET.maxStrokeBytes) fail("paintStroke", "exceeds the byte budget", true); return result; } export function parseWeightPatch(value: unknown): WeightPatchIR { const patch = record(value, "weightPatch"); if (patch.schemaVersion !== 1) fail("weightPatch.schemaVersion", "is unsupported"); if (!Array.isArray(patch.indices) || !Array.isArray(patch.values) || patch.indices.length !== patch.values.length) fail("weightPatch", "indices and values must have equal lengths"); if (patch.indices.length > PAINT_BUDGET.maxWeightEntries) fail("weightPatch", "exceeds the weight entry budget", true); const indices = patch.indices.map((item, index) => integer(item, `weightPatch.indices[${index}]`)); const values = patch.values.map((item, index) => { const result = finite(item, `weightPatch.values[${index}]`); if (result < 0 || result > 1) fail(`weightPatch.values[${index}]`, "must be in [0,1]"); return result; }); const result: WeightPatchIR = { schemaVersion: 1, objectId: string(patch.objectId, "weightPatch.objectId"), revision: integer(patch.revision, "weightPatch.revision"), vertexGroup: string(patch.vertexGroup, "weightPatch.vertexGroup"), indices, values }; if (patch.normalize !== undefined) { if (typeof patch.normalize !== "boolean") fail("weightPatch.normalize", "must be boolean"); result.normalize = patch.normalize; } if (patch.mirror !== undefined) { if (typeof patch.mirror !== "boolean") fail("weightPatch.mirror", "must be boolean"); result.mirror = patch.mirror; } return result; }