export type BudgetMetric = "triangles" | "vertices" | "indexBytes" | "materialSlots" | "textureBytes" | "gpuBytes"; export interface LightweightBudget { schemaVersion: 1; triangles?: number; vertices?: number; indexBytes?: number; materialSlots?: number; textureBytes?: number; gpuBytes?: number; } export interface LightweightUsage { triangles: number; vertices: number; indexBytes: number; materialSlots: number; textureBytes: number; gpuBytes: number; } export interface BudgetViolation { metric: BudgetMetric; limit: number; actual: number; excess: number; } export interface LightweightBudgetReport { withinBudget: boolean; budget: LightweightBudget; usage: LightweightUsage; violations: BudgetViolation[]; } export interface LightweightObjectUsage { objectId: string; collectionIds: string[]; usage: LightweightUsage; } export interface LightweightLODUsage { lodId: string; objectId: string; usage: LightweightUsage; } export interface LightweightUsageAggregation { project: LightweightUsage; collections: Record; objects: Record; lod: Record; } export interface LightweightBudgetScopes { project?: LightweightBudget; collections?: Record; objects?: Record; lod?: Record; } export interface LightweightBudgetAggregationReport { usage: LightweightUsageAggregation; project?: LightweightBudgetReport; collections: Record; objects: Record; lod: Record; } export class BudgetValidationError extends Error { constructor(message: string) { super(message); this.name = "BudgetValidationError"; } } const metrics: BudgetMetric[] = ["triangles", "vertices", "indexBytes", "materialSlots", "textureBytes", "gpuBytes"]; const emptyUsage = (): LightweightUsage => ({ triangles: 0, vertices: 0, indexBytes: 0, materialSlots: 0, textureBytes: 0, gpuBytes: 0 }); function validateUsage(value: LightweightUsage, field: string): LightweightUsage { for (const metric of metrics) nonNegativeInteger(value[metric], `${field}.${metric}`); return { ...value }; } function addUsage(target: LightweightUsage, source: LightweightUsage, field: string): void { for (const metric of metrics) { const value = target[metric] + source[metric]; if (!Number.isSafeInteger(value)) throw new BudgetValidationError(`${field}.${metric} exceeds safe integer range`); target[metric] = value; } } function requireId(value: string, field: string): string { if (typeof value !== "string" || value.length === 0 || value.length > 256) throw new BudgetValidationError(`${field} must be a non-empty id`); return value; } function nonNegativeInteger(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new BudgetValidationError(`${field} must be a non-negative safe integer`); return value; } export function parseLightweightBudget(value: unknown): LightweightBudget { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new BudgetValidationError("budget must be an object"); const input = value as Record; if (input.schemaVersion !== 1) throw new BudgetValidationError("Unsupported budget schema"); const budget: LightweightBudget = { schemaVersion: 1 }; for (const metric of metrics) { if (input[metric] !== undefined) budget[metric] = nonNegativeInteger(input[metric], metric); } if (metrics.every((metric) => budget[metric] === undefined)) throw new BudgetValidationError("budget must define at least one metric"); return budget; } export function evaluateLightweightBudget(budgetValue: unknown, usageValue: LightweightUsage): LightweightBudgetReport { const budget = parseLightweightBudget(budgetValue); validateUsage(usageValue, "usage"); const violations = metrics.flatMap((metric) => { const limit = budget[metric]; if (limit === undefined || usageValue[metric] <= limit) return []; return [{ metric, limit, actual: usageValue[metric], excess: usageValue[metric] - limit }]; }); return { withinBudget: violations.length === 0, budget, usage: { ...usageValue }, violations }; } /** Aggregates source object usage once, collection membership independently, and LOD usage separately. */ export function aggregateLightweightUsage(objects: readonly LightweightObjectUsage[], lod: readonly LightweightLODUsage[] = []): LightweightUsageAggregation { const project = emptyUsage(); const collections: Record = {}; const objectUsage: Record = {}; const lodUsage: Record = {}; for (const object of objects) { const objectId = requireId(object.objectId, "object.objectId"); if (objectUsage[objectId]) throw new BudgetValidationError(`duplicate object id: ${objectId}`); if (!Array.isArray(object.collectionIds)) throw new BudgetValidationError(`object.${objectId}.collectionIds must be an array`); const usage = validateUsage(object.usage, `object.${objectId}.usage`); objectUsage[objectId] = usage; addUsage(project, usage, "project"); const collectionIds = new Set(); for (const collectionIdValue of object.collectionIds) { const collectionId = requireId(collectionIdValue, "object.collectionIds[]"); if (collectionIds.has(collectionId)) throw new BudgetValidationError(`duplicate collection membership: ${objectId}:${collectionId}`); collectionIds.add(collectionId); const collection = collections[collectionId] ?? (collections[collectionId] = emptyUsage()); addUsage(collection, usage, `collection.${collectionId}`); } } for (const item of lod) { const lodId = requireId(item.lodId, "lod.lodId"); const objectId = requireId(item.objectId, "lod.objectId"); if (!objectUsage[objectId]) throw new BudgetValidationError(`LOD references unknown object: ${objectId}`); if (lodUsage[lodId]) throw new BudgetValidationError(`duplicate lod id: ${lodId}`); lodUsage[lodId] = validateUsage(item.usage, `lod.${lodId}.usage`); } return { project, collections, objects: objectUsage, lod: lodUsage }; } export function evaluateLightweightBudgets(scopes: LightweightBudgetScopes, aggregation: LightweightUsageAggregation): LightweightBudgetAggregationReport { const project = scopes.project ? evaluateLightweightBudget(scopes.project, aggregation.project) : undefined; const collections: Record = {}; for (const [id, budget] of Object.entries(scopes.collections ?? {})) { const usage = aggregation.collections[id] ?? emptyUsage(); collections[id] = evaluateLightweightBudget(budget, usage); } const objects: Record = {}; for (const [id, budget] of Object.entries(scopes.objects ?? {})) { const usage = aggregation.objects[id] ?? emptyUsage(); objects[id] = evaluateLightweightBudget(budget, usage); } const lod: Record = {}; for (const [id, budget] of Object.entries(scopes.lod ?? {})) { const usage = aggregation.lod[id] ?? emptyUsage(); lod[id] = evaluateLightweightBudget(budget, usage); } return { usage: aggregation, project, collections, objects, lod }; }