export type SimplifyMode = "COLLAPSE" | "UNSUBDIV" | "DISSOLVE_PLANAR"; export type SimplifyDelimit = "NORMAL" | "MATERIAL" | "SEAM" | "SHARP" | "UV" | "ALL_BOUNDARIES"; export type SimplifyAttributePolicy = "PRESERVE" | "RECOMPUTE_NORMALS" | "DROP"; export interface SkinSimplifyPolicy { maxInfluences: number; minWeight: number; maxPositionError: number; shapeKeys: "PRESERVE" | "REJECT"; } export type SimplifyErrorCode = | "INVALID_SCHEMA" | "INVALID_PARAMETER" | "MISSING_VERTEX_GROUP" | "UNSUPPORTED_PARAMETER" | "INVALID_LOD_MANIFEST"; export class SimplifyValidationError extends Error { readonly code: SimplifyErrorCode; constructor(code: SimplifyErrorCode, message: string) { super(message); this.name = "SimplifyValidationError"; this.code = code; } } interface SimplifyCommon { schemaVersion: 1; sourceMeshRevision: number; triangleBudget?: number; maxGeometricError?: number; screenSpaceError?: number; attributePolicy: SimplifyAttributePolicy; skinPolicy?: SkinSimplifyPolicy; } export type CollapseSimplifyProfile = SimplifyCommon & { mode: "COLLAPSE"; ratio: number; vertexGroup?: string | null; vertexGroupFactor?: number; vertexGroupInvert?: boolean; triangulate: boolean; useSymmetry: boolean; symmetryAxis?: 0 | 1 | 2; symmetryTolerance?: number; }; export type UnsubdivideSimplifyProfile = SimplifyCommon & { mode: "UNSUBDIV"; iterations: number; }; export type DissolvePlanarSimplifyProfile = SimplifyCommon & { mode: "DISSOLVE_PLANAR"; angleLimit: number; useDissolveBoundaries: boolean; delimit: SimplifyDelimit[]; }; export type SimplifyProfile = CollapseSimplifyProfile | UnsubdivideSimplifyProfile | DissolvePlanarSimplifyProfile; export interface SimplifyResult { schemaVersion: 1; status: "applied" | "rejected"; sourceMeshRevision: number; mode: SimplifyMode; originalFaceCount: number; originalTriangleCount: number; outputFaceCount: number; outputTriangleCount: number; ratio: number; triangleBudget?: number; maxGeometricError?: number; screenSpaceError?: number; evaluatedMeshId?: string; warnings?: string[]; error?: { code: SimplifyErrorCode; message: string }; } export interface LODLevel { level: number; sourceMeshRevision: number; triangleBudget: number; meshId?: string; maxGeometricError?: number; screenSpaceError?: number; } export interface LODManifest { schemaVersion: 1; meshId: string; sourceMeshRevision: number; levels: LODLevel[]; } function record(value: unknown, field: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new SimplifyValidationError("INVALID_SCHEMA", `${field} must be an object`); } return value as Record; } function finite(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be finite`); } return value; } function integer(value: unknown, field: string, minimum = 0): number { const result = finite(value, field); if (!Number.isInteger(result) || result < minimum) { throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be an integer >= ${minimum}`); } return result; } function optionalNonNegative(value: unknown, field: string): number | undefined { if (value === undefined) return undefined; const result = finite(value, field); if (result < 0) throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be >= 0`); return result; } function boolean(value: unknown, field: string, fallback: boolean): boolean { if (value === undefined) return fallback; if (typeof value !== "boolean") throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be boolean`); return value; } function optionalBudget(value: unknown): number | undefined { if (value === undefined) return undefined; return integer(value, "triangleBudget", 1); } function attributePolicy(value: unknown): SimplifyAttributePolicy { if (value === undefined) return "PRESERVE"; if (value !== "PRESERVE" && value !== "RECOMPUTE_NORMALS" && value !== "DROP") { throw new SimplifyValidationError("INVALID_PARAMETER", "attributePolicy must be PRESERVE, RECOMPUTE_NORMALS or DROP"); } return value; } function skinPolicy(value: unknown): SkinSimplifyPolicy | undefined { if (value === undefined) return undefined; const input = record(value, "skinPolicy"); const maxInfluences = integer(input.maxInfluences, "skinPolicy.maxInfluences", 1); if (maxInfluences > 8) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.maxInfluences must be <= 8"); const minWeight = finite(input.minWeight, "skinPolicy.minWeight"); if (minWeight < 0 || minWeight > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.minWeight must be between 0 and 1"); const maxPositionError = optionalNonNegative(input.maxPositionError, "skinPolicy.maxPositionError"); if (maxPositionError === undefined) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.maxPositionError is required"); const shapeKeys = input.shapeKeys; if (shapeKeys !== "PRESERVE" && shapeKeys !== "REJECT") { throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.shapeKeys must be PRESERVE or REJECT"); } return { maxInfluences, minWeight, maxPositionError, shapeKeys }; } function common(input: Record): SimplifyCommon { if (input.schemaVersion !== 1) throw new SimplifyValidationError("INVALID_SCHEMA", "Unsupported SimplifyProfile schema"); return { schemaVersion: 1, sourceMeshRevision: integer(input.sourceMeshRevision, "sourceMeshRevision"), triangleBudget: optionalBudget(input.triangleBudget), maxGeometricError: optionalNonNegative(input.maxGeometricError, "maxGeometricError"), screenSpaceError: optionalNonNegative(input.screenSpaceError, "screenSpaceError"), attributePolicy: attributePolicy(input.attributePolicy), skinPolicy: skinPolicy(input.skinPolicy), }; } function rejectFields(input: Record, fields: string[]): void { const present = fields.find((field) => input[field] !== undefined); if (present) throw new SimplifyValidationError("UNSUPPORTED_PARAMETER", `${present} is not valid for this simplify mode`); } const delimitValues: SimplifyDelimit[] = ["NORMAL", "MATERIAL", "SEAM", "SHARP", "UV", "ALL_BOUNDARIES"]; export function parseSimplifyProfile(value: unknown): SimplifyProfile { const input = record(value, "SimplifyProfile"); const base = common(input); if (input.mode === "COLLAPSE") { const ratio = finite(input.ratio, "ratio"); if (ratio <= 0 || ratio > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "ratio must be > 0 and <= 1"); const vertexGroup = input.vertexGroup === undefined || input.vertexGroup === null ? null : input.vertexGroup; if (vertexGroup !== null && typeof vertexGroup !== "string") { throw new SimplifyValidationError("INVALID_PARAMETER", "vertexGroup must be a string or null"); } const vertexGroupFactor = finite(input.vertexGroupFactor ?? 1, "vertexGroupFactor"); if (vertexGroupFactor < 0 || vertexGroupFactor > 1) { throw new SimplifyValidationError("INVALID_PARAMETER", "vertexGroupFactor must be between 0 and 1"); } const vertexGroupInvert = boolean(input.vertexGroupInvert, "vertexGroupInvert", false); if (vertexGroup === null && (input.vertexGroupFactor !== undefined || vertexGroupInvert)) { throw new SimplifyValidationError("MISSING_VERTEX_GROUP", "vertexGroupFactor/invert requires vertexGroup"); } const useSymmetry = boolean(input.useSymmetry, "useSymmetry", false); const symmetryAxis = integer(input.symmetryAxis ?? 0, "symmetryAxis"); if (symmetryAxis > 2) throw new SimplifyValidationError("INVALID_PARAMETER", "symmetryAxis must be 0, 1 or 2"); const symmetryTolerance = finite(input.symmetryTolerance ?? 1e-4, "symmetryTolerance"); if (symmetryTolerance <= 0) throw new SimplifyValidationError("INVALID_PARAMETER", "symmetryTolerance must be > 0"); if (!useSymmetry && (input.symmetryAxis !== undefined || input.symmetryTolerance !== undefined)) { throw new SimplifyValidationError("UNSUPPORTED_PARAMETER", "symmetryAxis/tolerance requires useSymmetry"); } return { ...base, mode: "COLLAPSE", ratio, vertexGroup, vertexGroupFactor, vertexGroupInvert, triangulate: boolean(input.triangulate, "triangulate", false), useSymmetry, symmetryAxis: symmetryAxis as 0 | 1 | 2, symmetryTolerance, }; } if (input.mode === "UNSUBDIV") { rejectFields(input, ["ratio", "vertexGroup", "vertexGroupFactor", "vertexGroupInvert", "triangulate", "useSymmetry", "symmetryAxis", "symmetryTolerance", "angleLimit", "delimit"]); return { ...base, mode: "UNSUBDIV", iterations: integer(input.iterations, "iterations", 1) }; } if (input.mode === "DISSOLVE_PLANAR") { rejectFields(input, ["ratio", "vertexGroup", "vertexGroupFactor", "vertexGroupInvert", "triangulate", "useSymmetry", "symmetryAxis", "symmetryTolerance", "iterations"]); const angleLimit = finite(input.angleLimit, "angleLimit"); if (angleLimit < 0 || angleLimit > Math.PI) throw new SimplifyValidationError("INVALID_PARAMETER", "angleLimit must be between 0 and PI"); const delimitInput = input.delimit ?? []; if (!Array.isArray(delimitInput) || delimitInput.some((item) => typeof item !== "string" || !delimitValues.includes(item as SimplifyDelimit))) { throw new SimplifyValidationError("INVALID_PARAMETER", "delimit contains an unsupported boundary type"); } const delimit = [...new Set(delimitInput as SimplifyDelimit[])]; return { ...base, mode: "DISSOLVE_PLANAR", angleLimit, useDissolveBoundaries: boolean(input.useDissolveBoundaries, "useDissolveBoundaries", false), delimit, }; } throw new SimplifyValidationError("INVALID_PARAMETER", "mode must be COLLAPSE, UNSUBDIV or DISSOLVE_PLANAR"); } export function parseSimplifyResult(value: unknown): SimplifyResult { const input = record(value, "SimplifyResult"); if (input.schemaVersion !== 1 || (input.status !== "applied" && input.status !== "rejected")) { throw new SimplifyValidationError("INVALID_SCHEMA", "SimplifyResult envelope is invalid"); } const mode = input.mode; if (mode !== "COLLAPSE" && mode !== "UNSUBDIV" && mode !== "DISSOLVE_PLANAR") { throw new SimplifyValidationError("INVALID_SCHEMA", "SimplifyResult.mode is invalid"); } for (const field of ["originalFaceCount", "originalTriangleCount", "outputFaceCount", "outputTriangleCount"]) { integer(input[field], field); } const ratio = finite(input.ratio, "ratio"); if (ratio < 0 || ratio > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "result ratio must be between 0 and 1"); const result: SimplifyResult = { schemaVersion: 1, status: input.status, sourceMeshRevision: integer(input.sourceMeshRevision, "sourceMeshRevision"), mode, originalFaceCount: input.originalFaceCount as number, originalTriangleCount: input.originalTriangleCount as number, outputFaceCount: input.outputFaceCount as number, outputTriangleCount: input.outputTriangleCount as number, ratio, triangleBudget: optionalBudget(input.triangleBudget), maxGeometricError: optionalNonNegative(input.maxGeometricError, "maxGeometricError"), screenSpaceError: optionalNonNegative(input.screenSpaceError, "screenSpaceError"), evaluatedMeshId: input.evaluatedMeshId === undefined ? undefined : String(input.evaluatedMeshId), warnings: input.warnings === undefined ? undefined : input.warnings as string[], error: input.error === undefined ? undefined : record(input.error, "error") as SimplifyResult["error"], }; if (result.status === "rejected" && !result.error) throw new SimplifyValidationError("INVALID_SCHEMA", "rejected result must include error"); return result; } export function parseLODManifest(value: unknown): LODManifest { const input = record(value, "LODManifest"); if (input.schemaVersion !== 1 || typeof input.meshId !== "string" || !input.meshId) { throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LODManifest envelope is invalid"); } if (!Array.isArray(input.levels) || input.levels.length === 0) { throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LODManifest.levels must not be empty"); } const sourceMeshRevision = integer(input.sourceMeshRevision, "sourceMeshRevision"); const levels = input.levels.map((raw, index) => { const level = record(raw, `levels[${index}]`); const parsed: LODLevel = { level: integer(level.level, `levels[${index}].level`), sourceMeshRevision: integer(level.sourceMeshRevision, `levels[${index}].sourceMeshRevision`), triangleBudget: integer(level.triangleBudget, `levels[${index}].triangleBudget`, 1), meshId: level.meshId === undefined ? undefined : String(level.meshId), maxGeometricError: optionalNonNegative(level.maxGeometricError, `levels[${index}].maxGeometricError`), screenSpaceError: optionalNonNegative(level.screenSpaceError, `levels[${index}].screenSpaceError`), }; if (parsed.sourceMeshRevision !== sourceMeshRevision) throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "all LOD levels must share sourceMeshRevision"); return parsed; }); for (let index = 0; index < levels.length; index++) { if (levels[index].level !== index) throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD levels must be contiguous from level 0"); if (index > 0 && levels[index].triangleBudget >= levels[index - 1].triangleBudget) { throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD triangle budgets must strictly decrease"); } } return { schemaVersion: 1, meshId: input.meshId, sourceMeshRevision, levels }; }