178 lines
7.3 KiB
TypeScript
178 lines
7.3 KiB
TypeScript
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<string, LightweightUsage>;
|
|
objects: Record<string, LightweightUsage>;
|
|
lod: Record<string, LightweightUsage>;
|
|
}
|
|
|
|
export interface LightweightBudgetScopes {
|
|
project?: LightweightBudget;
|
|
collections?: Record<string, LightweightBudget>;
|
|
objects?: Record<string, LightweightBudget>;
|
|
lod?: Record<string, LightweightBudget>;
|
|
}
|
|
|
|
export interface LightweightBudgetAggregationReport {
|
|
usage: LightweightUsageAggregation;
|
|
project?: LightweightBudgetReport;
|
|
collections: Record<string, LightweightBudgetReport>;
|
|
objects: Record<string, LightweightBudgetReport>;
|
|
lod: Record<string, LightweightBudgetReport>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
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<string, LightweightUsage> = {};
|
|
const objectUsage: Record<string, LightweightUsage> = {};
|
|
const lodUsage: Record<string, LightweightUsage> = {};
|
|
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<string>();
|
|
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<string, LightweightBudgetReport> = {};
|
|
for (const [id, budget] of Object.entries(scopes.collections ?? {})) {
|
|
const usage = aggregation.collections[id] ?? emptyUsage();
|
|
collections[id] = evaluateLightweightBudget(budget, usage);
|
|
}
|
|
const objects: Record<string, LightweightBudgetReport> = {};
|
|
for (const [id, budget] of Object.entries(scopes.objects ?? {})) {
|
|
const usage = aggregation.objects[id] ?? emptyUsage();
|
|
objects[id] = evaluateLightweightBudget(budget, usage);
|
|
}
|
|
const lod: Record<string, LightweightBudgetReport> = {};
|
|
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 };
|
|
}
|