Add configurable area asset budgets

This commit is contained in:
2026-08-04 12:28:43 +08:00
parent f78080bbfa
commit 463cb06be4
18 changed files with 443 additions and 42 deletions

View File

@@ -2,6 +2,7 @@
const fs = require("fs");
const path = require("path");
const { BUDGETS } = require("./stage-manifest");
function readAreaConfig(file, options = {}) {
if (!fs.existsSync(file)) {
@@ -23,6 +24,7 @@ function normalizeAreaConfig(raw, options = {}) {
const areaDir = path.resolve(outputOverrides.areaDir || path.join(outputRoot, id));
const fileStem = outputOverrides.fileStem || id;
const compress = normalizeCompressConfig(raw.compress);
const budget = normalizeBudgetConfig(raw.budget);
const compressedFileStem = outputOverrides.compressedFileStem ||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
@@ -92,10 +94,34 @@ function normalizeAreaConfig(raw, options = {}) {
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
},
compress,
budget,
outputs,
};
}
function normalizeBudgetConfig(raw) {
if (raw !== undefined && raw !== null && (typeof raw !== "object" || Array.isArray(raw))) {
throw new Error("budget must be an object");
}
const value = raw || {};
const budget = {
glbBytes: megabytesOption(value.glbSizeMb, BUDGETS.glbBytes, "budget.glbSizeMb"),
glbNodes: integerOption(value.nodes, BUDGETS.glbNodes, "budget.nodes"),
glbImages: integerOption(value.images, BUDGETS.glbImages, "budget.images"),
glbTriangles: integerOption(value.triangles, BUDGETS.glbTriangles, "budget.triangles"),
glbImageBytes: megabytesOption(value.embeddedImageBytesMb, BUDGETS.glbImageBytes, "budget.embeddedImageBytesMb"),
reason: value.reason ?? "",
};
if (typeof budget.reason !== "string") {
throw new Error("budget.reason must be a string");
}
const loosened = Object.keys(BUDGETS).some((key) => budget[key] > BUDGETS[key]);
if (loosened && budget.reason.trim() === "") {
throw new Error("budget.reason is required when a budget exceeds the default");
}
return budget;
}
function requireText(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing config key: ${key}`);
@@ -121,6 +147,22 @@ function numberOption(value, fallback, label, min, max) {
return number;
}
function integerOption(value, fallback, label) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isInteger(number) || number < 1) {
throw new Error(`${label} must be a positive integer`);
}
return number;
}
function megabytesOption(value, fallbackBytes, label) {
const megabytes = value === undefined ? fallbackBytes / 1024 / 1024 : Number(value);
if (!Number.isFinite(megabytes) || megabytes <= 0) {
throw new Error(`${label} must be a positive finite number`);
}
return Math.round(megabytes * 1024 * 1024);
}
function booleanOption(value, fallback, label) {
if (value === undefined) return fallback;
if (typeof value === "boolean") return value;

View File

@@ -10,7 +10,14 @@ const {
layerFile,
} = require("./scene-layers");
const { digest: glbDigest } = require("../glb-digest");
const { BUDGETS, fileRecord, readStageManifest, stageManifestPath } = require("./stage-manifest");
const {
BUDGETS,
evaluateGlbBudget,
fileRecord,
glbBudgetWarnings,
readStageManifest,
stageManifestPath,
} = require("./stage-manifest");
function defaultConfigPath(repoRoot) {
return path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json");
@@ -604,15 +611,7 @@ function collectWarnings(area, osm, artifacts, manifests, glb, metadata) {
}
}
if (glb) {
if (glb.fileBytes > BUDGETS.glbBytes) {
warnings.push(`GLB size ${mb(glb.fileBytes)} MB exceeds budget ${mb(BUDGETS.glbBytes)} MB.`);
}
if (glb.counts.nodes > BUDGETS.glbNodes) {
warnings.push(`GLB nodes ${glb.counts.nodes} exceed budget ${BUDGETS.glbNodes}.`);
}
if (glb.counts.images > BUDGETS.glbImages) {
warnings.push(`GLB images ${glb.counts.images} exceed budget ${BUDGETS.glbImages}.`);
}
warnings.push(...glbBudgetWarnings("GLB", glb, area.budget).map((warning) => `${warning}.`));
}
if (metadata && metadata.assets < 1) {
warnings.push("Cesium metadata has no assets entries.");
@@ -707,6 +706,9 @@ function printDiagnosticsReport(result) {
`${glb.counts.materials} materials, ${glb.counts.images} images, ${glb.counts.accessors} accessors`,
);
console.log(` Extensions: ${glb.extensionsUsed.length ? glb.extensionsUsed.join(", ") : "none"}`);
printGlbBudget(glb, area.budget);
printGlbSources(glb);
printGlbImages(glb);
}
console.log("");
@@ -789,15 +791,7 @@ function classifyAreaQuality(result) {
}
if (glb) {
if (glb.fileBytes > BUDGETS.glbBytes) {
failures.push(`GLB size ${mb(glb.fileBytes)} MB exceeds budget ${mb(BUDGETS.glbBytes)} MB.`);
}
if (glb.counts.nodes > BUDGETS.glbNodes) {
failures.push(`GLB nodes ${glb.counts.nodes} exceed budget ${BUDGETS.glbNodes}.`);
}
if (glb.counts.images > BUDGETS.glbImages) {
failures.push(`GLB images ${glb.counts.images} exceed budget ${BUDGETS.glbImages}.`);
}
failures.push(...glbBudgetWarnings("GLB", glb, result.area.budget).map((warning) => `${warning}.`));
}
if (!result.area.blender.treeStyle) {
@@ -832,6 +826,35 @@ function formatBytes(bytes) {
return `${bytes} B`;
}
function printGlbBudget(glb, budget) {
const result = evaluateGlbBudget(glb, budget);
console.log(" Budget:");
console.log(` Size: ${formatBytes(result.usage.glbBytes)} / ${formatBytes(result.limits.glbBytes)}`);
console.log(` Nodes: ${result.usage.glbNodes} / ${result.limits.glbNodes}`);
console.log(` Images: ${result.usage.glbImages} / ${result.limits.glbImages}`);
console.log(` Render triangles: ${result.usage.glbTriangles} / ${result.limits.glbTriangles}`);
console.log(` Embedded images: ${formatBytes(result.usage.glbImageBytes)} / ${formatBytes(result.limits.glbImageBytes)}`);
if (budget.reason) console.log(` Exception: ${budget.reason}`);
}
function printGlbSources(glb) {
const sources = (glb.sourceSummary || []).slice(0, 5);
if (!sources.length) return;
console.log(" Top sources:");
for (const source of sources) {
console.log(` ${source.source}: ${source.nodes} nodes, ${source.triangles} triangles`);
}
}
function printGlbImages(glb) {
const images = (glb.images || []).filter((image) => image.bytes > 0).slice(0, 5);
if (!images.length) return;
console.log(" Top embedded images:");
for (const image of images) {
console.log(` ${image.name || "unnamed"}: ${formatBytes(image.bytes)}`);
}
}
module.exports = {
BUDGETS,
analyzeOsmPreflight,

View File

@@ -9,6 +9,8 @@ const BUDGETS = {
glbBytes: 25 * 1024 * 1024,
glbNodes: 1000,
glbImages: 24,
glbTriangles: 250000,
glbImageBytes: 20 * 1024 * 1024,
};
function stageManifestPath(area, stage) {
@@ -64,18 +66,36 @@ function glbSummary(digest) {
}
function glbBudgetWarnings(label, digest, budgets = BUDGETS) {
if (!digest) return [];
const warnings = [];
if (digest.fileBytes > budgets.glbBytes) {
warnings.push(`${label} GLB size ${mb(digest.fileBytes)} MB exceeds budget ${mb(budgets.glbBytes)} MB`);
}
if (digest.counts.nodes > budgets.glbNodes) {
warnings.push(`${label} GLB nodes ${digest.counts.nodes} exceed budget ${budgets.glbNodes}`);
}
if (digest.counts.images > budgets.glbImages) {
warnings.push(`${label} GLB images ${digest.counts.images} exceed budget ${budgets.glbImages}`);
}
return warnings;
return evaluateGlbBudget(digest, budgets).violations.map(
(violation) => `${label} ${violation.label} ${formatBudgetValue(violation.actual, violation.unit)} ` +
`exceeds budget ${formatBudgetValue(violation.limit, violation.unit)}`,
);
}
function evaluateGlbBudget(digest, budgets = BUDGETS) {
const usage = {
glbBytes: digest?.fileBytes ?? 0,
glbNodes: digest?.counts?.nodes ?? 0,
glbImages: digest?.counts?.images ?? 0,
glbTriangles: digest?.counts?.renderTriangles ?? 0,
glbImageBytes: digest?.embeddedImageBytes ?? 0,
};
const definitions = [
["glbBytes", "GLB size", "bytes"],
["glbNodes", "GLB nodes", "count"],
["glbImages", "GLB images", "count"],
["glbTriangles", "GLB rendered triangles", "count"],
["glbImageBytes", "GLB embedded image bytes", "bytes"],
];
const limits = { ...BUDGETS, ...budgets };
const violations = definitions
.filter(([key]) => usage[key] > limits[key])
.map(([key, label, unit]) => ({ key, label, unit, actual: usage[key], limit: limits[key] }));
return { usage, limits, violations };
}
function formatBudgetValue(value, unit) {
return unit === "bytes" ? `${mb(value)} MB` : String(value);
}
function sha256(file) {
@@ -90,6 +110,7 @@ function mb(bytes) {
module.exports = {
BUDGETS,
evaluateGlbBudget,
fileRecord,
glbBudgetWarnings,
glbSummary,