Add configurable area asset budgets
This commit is contained in:
@@ -13,6 +13,7 @@ const {
|
||||
const { digest: glbDigest } = require("./glb-digest");
|
||||
const {
|
||||
fileRecord,
|
||||
evaluateGlbBudget,
|
||||
glbBudgetWarnings,
|
||||
glbSummary,
|
||||
optionalFileRecord,
|
||||
@@ -334,8 +335,9 @@ function exportCesium(area) {
|
||||
},
|
||||
summary: {
|
||||
glb: glbSummary(digest),
|
||||
budget: evaluateGlbBudget(digest, area.budget),
|
||||
},
|
||||
warnings: glbBudgetWarnings("Cesium", digest),
|
||||
warnings: glbBudgetWarnings("Cesium", digest, area.budget),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -400,6 +402,7 @@ function compressCesiumGlb(area) {
|
||||
summary: {
|
||||
sourceGlb: glbSummary(sourceDigest),
|
||||
compressedGlb: glbSummary(compressedDigest),
|
||||
budget: evaluateGlbBudget(compressedDigest, area.budget),
|
||||
options: {
|
||||
textureSize: area.compress.textureSize,
|
||||
quality: area.compress.quality,
|
||||
@@ -409,7 +412,7 @@ function compressCesiumGlb(area) {
|
||||
compressionRatio: Number((compressedDigest.fileBytes / sourceDigest.fileBytes).toFixed(4)),
|
||||
savedBytes: sourceDigest.fileBytes - compressedDigest.fileBytes,
|
||||
},
|
||||
warnings: glbBudgetWarnings("Compressed", compressedDigest),
|
||||
warnings: glbBudgetWarnings("Compressed", compressedDigest, area.budget),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ function materialDigest(material) {
|
||||
|
||||
function digest(file) {
|
||||
const gltf = readGlbJson(file);
|
||||
return digestGltf(gltf, { file: path.basename(file), fileBytes: fs.statSync(file).size });
|
||||
}
|
||||
|
||||
function digestGltf(gltf, fileInfo = {}) {
|
||||
const meshes = (gltf.meshes || []).map((mesh) => ({
|
||||
name: mesh.name || null,
|
||||
primitives: (mesh.primitives || []).map((primitive) => ({
|
||||
@@ -68,18 +72,37 @@ function digest(file) {
|
||||
// fingerprint and stay stable regardless of buffer layout.
|
||||
count: gltf.accessors?.[primitive.attributes?.POSITION]?.count ?? null,
|
||||
indices: gltf.accessors?.[primitive.indices]?.count ?? null,
|
||||
triangles: primitiveTriangleCount(primitive, gltf.accessors || []),
|
||||
})),
|
||||
}));
|
||||
const sourceSummary = summarizeNodeSources(gltf.nodes || [], meshes);
|
||||
const images = (gltf.images || [])
|
||||
.map((image) => ({
|
||||
name: image.name || null,
|
||||
mimeType: image.mimeType || null,
|
||||
bytes: gltf.bufferViews?.[image.bufferView]?.byteLength ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes || String(a.name).localeCompare(String(b.name)));
|
||||
const triangles = meshes.reduce(
|
||||
(total, mesh) => total + mesh.primitives.reduce((sum, primitive) => sum + primitive.triangles, 0),
|
||||
0,
|
||||
);
|
||||
const renderTriangles = sourceSummary.reduce((total, source) => total + source.triangles, 0);
|
||||
const embeddedImageBytes = images.reduce((total, image) => total + image.bytes, 0);
|
||||
return {
|
||||
file: path.basename(file),
|
||||
fileBytes: fs.statSync(file).size,
|
||||
file: fileInfo.file || null,
|
||||
fileBytes: fileInfo.fileBytes ?? 0,
|
||||
counts: {
|
||||
nodes: (gltf.nodes || []).length,
|
||||
meshes: meshes.length,
|
||||
materials: (gltf.materials || []).length,
|
||||
images: (gltf.images || []).length,
|
||||
accessors: (gltf.accessors || []).length,
|
||||
triangles,
|
||||
renderTriangles,
|
||||
},
|
||||
embeddedImageBytes,
|
||||
sourceSummary,
|
||||
extensionsUsed: (gltf.extensionsUsed || []).slice().sort(),
|
||||
buffers: (gltf.buffers || []).map((buffer) => buffer.byteLength),
|
||||
nodes: (gltf.nodes || [])
|
||||
@@ -96,12 +119,44 @@ function digest(file) {
|
||||
materials: (gltf.materials || [])
|
||||
.map(materialDigest)
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||
images: (gltf.images || [])
|
||||
.map((image) => ({ name: image.name || null, mimeType: image.mimeType || null }))
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
function primitiveTriangleCount(primitive, accessors) {
|
||||
if (primitive.mode !== undefined && primitive.mode !== 4) return 0;
|
||||
const count = primitive.indices === undefined
|
||||
? accessors[primitive.attributes?.POSITION]?.count
|
||||
: accessors[primitive.indices]?.count;
|
||||
return Number.isFinite(count) ? Math.floor(count / 3) : 0;
|
||||
}
|
||||
|
||||
function summarizeNodeSources(nodes, meshes) {
|
||||
const summaries = new Map();
|
||||
for (const node of nodes) {
|
||||
if (node.mesh === undefined || !meshes[node.mesh]) continue;
|
||||
const source = nodeSource(node.name || meshes[node.mesh].name || "");
|
||||
const summary = summaries.get(source) || { source, nodes: 0, meshInstances: 0, triangles: 0 };
|
||||
const triangles = meshes[node.mesh].primitives.reduce((sum, primitive) => sum + primitive.triangles, 0);
|
||||
summary.nodes += 1;
|
||||
summary.meshInstances += 1;
|
||||
summary.triangles += triangles;
|
||||
summaries.set(source, summary);
|
||||
}
|
||||
return [...summaries.values()].sort((a, b) => b.triangles - a.triangles || a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
function nodeSource(name) {
|
||||
const value = String(name).toLowerCase();
|
||||
if (value.includes("building")) return "buildings";
|
||||
if (/(shapespark|tree|bush|shrub|grass|branch|trunk|leaf)/.test(value)) return "vegetation";
|
||||
if (/(road|sidewalk|lane|crosswalk|stop|intersection)/.test(value)) return "roads";
|
||||
if (/(lake|water)/.test(value)) return "water";
|
||||
if (value.includes("fountain")) return "fountain";
|
||||
if (/(vehicle|car)/.test(value)) return "vehicles";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const file = argv.find((arg) => !arg.startsWith("--"));
|
||||
@@ -128,5 +183,6 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
digest,
|
||||
digestGltf,
|
||||
readGlbJson,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
scripts/test-asset-budgets.js
Normal file
58
scripts/test-asset-budgets.js
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { normalizeAreaConfig } = require("./lib/area-config");
|
||||
const { digestGltf } = require("./glb-digest");
|
||||
const { evaluateGlbBudget, BUDGETS } = require("./lib/stage-manifest");
|
||||
|
||||
const gltf = {
|
||||
nodes: [
|
||||
{ name: "Building_1", mesh: 0 },
|
||||
{ name: "Shapespark_tree-01", mesh: 1 },
|
||||
],
|
||||
meshes: [
|
||||
{ name: "Building", primitives: [{ indices: 0, attributes: { POSITION: 1 } }] },
|
||||
{ name: "Tree", primitives: [{ indices: 2, attributes: { POSITION: 3 } }] },
|
||||
],
|
||||
accessors: [{ count: 12 }, { count: 4 }, { count: 9 }, { count: 3 }],
|
||||
images: [{ name: "large", bufferView: 0 }, { name: "small", bufferView: 1 }],
|
||||
bufferViews: [{ byteLength: 800 }, { byteLength: 200 }],
|
||||
};
|
||||
const digest = digestGltf(gltf, { file: "fixture.glb", fileBytes: 1500 });
|
||||
assert.equal(digest.counts.triangles, 7);
|
||||
assert.equal(digest.counts.renderTriangles, 7);
|
||||
assert.equal(digest.embeddedImageBytes, 1000);
|
||||
assert.deepEqual(digest.sourceSummary.map((entry) => [entry.source, entry.triangles]), [["buildings", 4], ["vegetation", 3]]);
|
||||
assert.deepEqual(digest.images.map((image) => image.bytes), [800, 200]);
|
||||
|
||||
const budget = { ...BUDGETS, glbBytes: 1000 };
|
||||
assert.equal(evaluateGlbBudget(digest, budget).violations[0].key, "glbBytes");
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-budget-"));
|
||||
const input = path.join(tempDir, "input.osm");
|
||||
fs.writeFileSync(input, "<osm/>");
|
||||
const base = { id: "test-area", input, outputRoot: tempDir };
|
||||
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
|
||||
/budget.reason is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: { triangles: 0 } }),
|
||||
/budget.triangles must be a positive integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: "large" }),
|
||||
/budget must be an object/,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeAreaConfig({ ...base, budget: { nodes: 1200, reason: "Dense campus vegetation" } }).budget.glbNodes,
|
||||
1200,
|
||||
);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log("Asset budget tests passed.");
|
||||
Reference in New Issue
Block a user