Add area diagnostics command

This commit is contained in:
2026-08-04 09:16:14 +08:00
parent 78cb69edb6
commit d4c3baf608
15 changed files with 819 additions and 165 deletions

View File

@@ -3,13 +3,14 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const repoRoot = path.resolve(__dirname, "..");
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(
args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"),
);
const area = normalizeAreaConfig(readJson(configPath));
const area = readAreaConfig(configPath, { repoRoot });
const requestedStages = args.stages
? splitList(args.stages)
: null;
@@ -67,129 +68,6 @@ function parseArgs(argv) {
return out;
}
function readJson(file) {
if (!fs.existsSync(file)) {
throw new Error(`Config file not found: ${file}`);
}
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function normalizeAreaConfig(raw) {
const id = requireText(raw.id, "id");
const input = path.resolve(requireText(raw.input, "input"));
if (!fs.existsSync(input)) {
throw new Error(`Input OSM XML not found: ${input}`);
}
const outputRoot = path.resolve(raw.outputRoot || path.join(repoRoot, "outputs"));
const outputOverrides = raw.outputs || {};
const areaDir = path.resolve(outputOverrides.areaDir || path.join(outputRoot, id));
const fileStem = outputOverrides.fileStem || id;
const compress = normalizeCompressConfig(raw.compress);
const compressedFileStem = outputOverrides.compressedFileStem ||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
const outputs = {
areaDir,
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
),
compressedGlb: path.resolve(
outputOverrides.compressedGlb || path.join(areaDir, `${compressedFileStem}.glb`),
),
compressedMetadata: path.resolve(
outputOverrides.compressedMetadata || path.join(areaDir, `${compressedFileStem}.json`),
),
compressedCesiumPreview: path.resolve(
outputOverrides.compressedCesiumPreview || path.join(areaDir, `${compressedFileStem}-cesium-preview.html`),
),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")),
};
return {
id,
input,
outputRoot,
qgisApp: raw.qgisApp || "/Applications/QGIS.app",
blenderApp: raw.blenderApp || "/Applications/Blender.app",
stages: {
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true,
blender: raw.stages?.blender ?? true,
cesium: raw.stages?.cesium ?? true,
reimport: false,
preview: false,
compress: false,
},
qgis: {
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
arrowMergeTriangles: raw.qgis?.arrowMergeTriangles ?? raw.arrowMergeTriangles ?? true,
arrowOutlineSimplifyMeters: raw.qgis?.arrowOutlineSimplifyMeters ?? raw.arrowOutlineSimplifyMeters ?? 0.05,
intersectionCornerSourceMaxDimensionMeters: raw.qgis?.intersectionCornerSourceMaxDimensionMeters ?? raw.intersectionCornerSourceMaxDimensionMeters ?? 2.6,
clipPad: raw.qgis?.clipPad ?? raw.clipPad ?? 0.002,
canvasPad: raw.qgis?.canvasPad ?? raw.canvasPad ?? 0.001,
previewPad: raw.qgis?.previewPad ?? raw.previewPad ?? 0.0007,
canvasExtent: raw.qgis?.canvasExtent ?? raw.canvasExtent ?? null,
previewExtent: raw.qgis?.previewExtent ?? raw.previewExtent ?? null,
layerPrefix: raw.qgis?.layerPrefix ?? raw.layerPrefix ?? "osm2streets",
},
osm2streets: raw.osm2streets || {
debug_each_step: false,
dual_carriageway_experiment: false,
sidepath_zipping_experiment: false,
inferred_sidewalks: true,
osm2lanes: true,
},
blender: {
treeStyle: raw.blender?.treeStyle || "natural",
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
},
compress,
outputs,
};
}
function requireText(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing config key: ${key}`);
}
return value;
}
function normalizeCompressConfig(raw) {
const value = raw || {};
return {
textureSize: numberOption(value.textureSize, 768, "compress.textureSize", 64, 4096),
quality: numberOption(value.quality, 82, "compress.quality", 1, 100),
effort: numberOption(value.effort, 80, "compress.effort", 0, 100),
meshopt: booleanOption(value.meshopt, false, "compress.meshopt"),
};
}
function numberOption(value, fallback, label, min, max) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isFinite(number) || number < min || number > max) {
throw new Error(`${label} must be a finite number in [${min}, ${max}]`);
}
return number;
}
function booleanOption(value, fallback, label) {
if (value === undefined) return fallback;
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
throw new Error(`${label} must be boolean`);
}
function splitList(value) {
return String(value)
.split(",")

438
scripts/diagnose-area.js Normal file
View File

@@ -0,0 +1,438 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { digest: glbDigest } = require("./glb-digest");
const repoRoot = path.resolve(__dirname, "..");
const DEFAULT_CONFIG = path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json");
const BUDGETS = {
glbBytes: 25 * 1024 * 1024,
glbNodes: 1000,
glbImages: 24,
};
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
out[key] = "true";
} else {
out[key] = next;
i += 1;
}
}
return out;
}
function xmlAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
attrs[match[1]] = match[3] !== undefined ? match[3] : match[4];
}
return attrs;
}
function parseTags(body) {
const tags = {};
for (const match of body.matchAll(/<tag\b([^>]*)\/?>/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.k) tags[attrs.k] = attrs.v || "";
}
return tags;
}
function parseOsm(xml) {
const bounds = parseBounds(xml);
const nodeIds = new Set();
const nodeStats = { total: 0, naturalTree: 0 };
const nodePattern = /<node\b([^>]*?)\/>|<node\b([^>]*?)>([\s\S]*?)<\/node>/g;
for (const match of xml.matchAll(nodePattern)) {
const attrs = xmlAttrs(match[1] || match[2] || "");
if (attrs.id) nodeIds.add(attrs.id);
nodeStats.total += 1;
const tags = parseTags(match[3] || "");
if (tags.natural === "tree") nodeStats.naturalTree += 1;
}
const ways = new Map();
const wayStats = {
total: 0,
buildings: 0,
buildingsWithHeight: 0,
buildingsWithLevels: 0,
buildingsWithBadHeight: 0,
missingNodeRefs: 0,
grass: 0,
scrub: 0,
treeRows: 0,
};
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
const body = match[2];
const tags = parseTags(body);
const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?>/g)) {
const nd = xmlAttrs(ndMatch[1]);
if (nd.ref) refs.push(nd.ref);
}
const missingNodeRefs = refs.filter((ref) => !nodeIds.has(ref)).length;
const way = {
id: attrs.id || "",
refs,
tags,
closed: refs.length > 1 && refs[0] === refs[refs.length - 1],
missingNodeRefs,
};
if (way.id) ways.set(way.id, way);
wayStats.total += 1;
wayStats.missingNodeRefs += missingNodeRefs;
if (tags.building) {
wayStats.buildings += 1;
if (isExplicitHeight(tags)) wayStats.buildingsWithHeight += 1;
if (tags["building:levels"]) wayStats.buildingsWithLevels += 1;
if (tags.height && !parseHeightMeters(tags.height)) wayStats.buildingsWithBadHeight += 1;
}
if (tags.landuse === "grass") wayStats.grass += 1;
if (tags.natural === "scrub") wayStats.scrub += 1;
if (tags.natural === "tree_row") wayStats.treeRows += 1;
}
const relationStats = {
total: 0,
buildingMultipolygons: 0,
buildingsWithHeight: 0,
buildingsWithLevels: 0,
buildingsWithBadHeight: 0,
healthyBuildingMultipolygons: 0,
issues: [],
};
for (const match of xml.matchAll(/<relation\b([^>]*)>([\s\S]*?)<\/relation>/g)) {
const attrs = xmlAttrs(match[1]);
const body = match[2];
const tags = parseTags(body);
const members = [];
for (const memberMatch of body.matchAll(/<member\b([^>]*)\/?>/g)) {
members.push(xmlAttrs(memberMatch[1]));
}
relationStats.total += 1;
if (tags.type !== "multipolygon" || !tags.building) continue;
relationStats.buildingMultipolygons += 1;
if (isExplicitHeight(tags)) relationStats.buildingsWithHeight += 1;
if (tags["building:levels"]) relationStats.buildingsWithLevels += 1;
if (tags.height && !parseHeightMeters(tags.height)) relationStats.buildingsWithBadHeight += 1;
const health = buildingRelationHealth(attrs.id || "", members, ways);
if (health.ok) {
relationStats.healthyBuildingMultipolygons += 1;
} else {
relationStats.issues.push(health);
}
}
return {
bounds,
nodes: nodeStats,
ways: wayStats,
relations: relationStats,
};
}
function parseBounds(xml) {
const match = xml.match(/<bounds\b([^>]*)\/?>/);
if (!match) return null;
const attrs = xmlAttrs(match[1]);
const bounds = {
minLon: Number(attrs.minlon),
minLat: Number(attrs.minlat),
maxLon: Number(attrs.maxlon),
maxLat: Number(attrs.maxlat),
};
return Object.values(bounds).every(Number.isFinite) ? bounds : null;
}
function isExplicitHeight(tags) {
return Boolean(tags.height && parseHeightMeters(tags.height));
}
function parseHeightMeters(value) {
const match = String(value).trim().match(/^(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const height = Number(match[1]);
return Number.isFinite(height) && height > 0 ? height : null;
}
function buildingRelationHealth(id, members, ways) {
const issues = [];
const outerMembers = members.filter((member) => member.type === "way" && member.role === "outer");
const innerMembers = members.filter((member) => member.type === "way" && member.role === "inner");
const nonWayMembers = members.filter((member) => member.type && member.type !== "way");
if (!outerMembers.length) issues.push("no outer way members");
if (nonWayMembers.length) issues.push(`${nonWayMembers.length} non-way member(s)`);
const outerHealth = ringGroupHealth(outerMembers, ways);
const innerHealth = ringGroupHealth(innerMembers, ways);
issues.push(...outerHealth.issues.map((issue) => `outer ${issue}`));
issues.push(...innerHealth.issues.map((issue) => `inner ${issue}`));
return {
id,
ok: issues.length === 0,
outerMembers: outerMembers.length,
innerMembers: innerMembers.length,
unresolvedMembers: outerHealth.unresolved + innerHealth.unresolved,
openRings: outerHealth.open + innerHealth.open,
issues,
};
}
function ringGroupHealth(members, ways) {
if (!members.length) return { issues: [], unresolved: 0, open: 0 };
const issues = [];
let unresolved = 0;
let open = 0;
const fragments = [];
for (const member of members) {
const way = ways.get(member.ref);
if (!way) {
unresolved += 1;
continue;
}
if (way.refs.length < 4) {
issues.push(`member ${member.ref} has fewer than 4 node refs`);
continue;
}
fragments.push(way.refs);
}
if (unresolved) issues.push(`${unresolved} unresolved member way(s)`);
if (!fragments.length) return { issues, unresolved, open };
if (fragments.length === 1) {
if (!isClosedRefs(fragments[0])) {
open += 1;
issues.push(`member ${members[0].ref} is not closed`);
}
return { issues, unresolved, open };
}
const endpointDegrees = new Map();
for (const refs of fragments) {
if (isClosedRefs(refs)) continue;
open += 1;
addEndpoint(endpointDegrees, refs[0]);
addEndpoint(endpointDegrees, refs[refs.length - 1]);
}
const badEndpoints = [...endpointDegrees.values()].filter((count) => count !== 2).length;
if (badEndpoints) {
issues.push(`${open} open member fragment(s) do not stitch into closed rings`);
}
return { issues, unresolved, open: badEndpoints ? open : 0 };
}
function isClosedRefs(refs) {
return refs.length > 1 && refs[0] === refs[refs.length - 1];
}
function addEndpoint(map, ref) {
map.set(ref, (map.get(ref) || 0) + 1);
}
function artifactStatus(area) {
const entries = [
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
["GeoPackage", area.outputs.gpkg, true, "file"],
["QGIS project", area.outputs.qgisProject, true, "file"],
["QGIS preview", area.outputs.qgisPreview, true, "file"],
["Blend scene", area.outputs.blend, true, "file"],
["Render PNG", area.outputs.render, true, "file"],
["Cesium GLB", area.outputs.glb, true, "file"],
["Cesium metadata", area.outputs.metadata, true, "file"],
["Cesium preview", area.outputs.cesiumPreview, true, "file"],
["Compressed GLB", area.outputs.compressedGlb, false, "file"],
["Compressed metadata", area.outputs.compressedMetadata, false, "file"],
["Compressed preview", area.outputs.compressedCesiumPreview, false, "file"],
];
return entries.map(([label, file, expected, type]) => {
const exists = fs.existsSync(file);
const stat = exists ? fs.statSync(file) : null;
const validType = !exists || (type === "dir" ? stat.isDirectory() : stat.isFile());
const geojsonFiles = exists && type === "dir"
? fs.readdirSync(file).filter((entry) => entry.endsWith(".geojson")).length
: null;
return {
label,
path: file,
expected,
type,
exists,
validType,
bytes: stat && stat.isFile() ? stat.size : null,
geojsonFiles,
};
});
}
function metadataSummary(file, warnings) {
if (!fs.existsSync(file)) return null;
try {
const metadata = JSON.parse(fs.readFileSync(file, "utf8"));
return {
asset: metadata.asset || null,
assets: Array.isArray(metadata.assets) ? metadata.assets.length : 0,
origin: metadata.origin || metadata.center || null,
};
} catch (error) {
warnings.push(`Cesium metadata is not valid JSON: ${error.message}`);
return null;
}
}
function collectWarnings(area, osm, artifacts, glb, metadata) {
const warnings = [];
if (!osm.bounds) warnings.push("OSM has no valid <bounds>; scene extent may be wrong.");
if (osm.ways.missingNodeRefs) {
warnings.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`);
}
if (osm.ways.buildingsWithBadHeight || osm.relations.buildingsWithBadHeight) {
warnings.push("Some building height tags could not be parsed as positive meters.");
}
for (const issue of osm.relations.issues) {
warnings.push(`Building relation ${issue.id}: ${issue.issues.join("; ")}.`);
}
for (const artifact of artifacts) {
if (artifact.expected && !artifact.exists) {
warnings.push(`Expected artifact missing: ${artifact.label} (${artifact.path}).`);
} else if (artifact.exists && !artifact.validType) {
warnings.push(`Artifact has wrong type: ${artifact.label} (${artifact.path}).`);
}
}
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}.`);
}
}
if (metadata && metadata.assets < 1) {
warnings.push("Cesium metadata has no assets entries.");
}
if (!area.blender.treeStyle) {
warnings.push("No Blender tree style configured.");
}
return warnings;
}
function printReport(area, configPath, osm, artifacts, glb, metadata, warnings) {
console.log("Area diagnostics");
console.log(`Area: ${area.id}`);
console.log(`Config: ${configPath}`);
console.log(`Input: ${area.input}`);
console.log(`Output: ${area.outputs.areaDir}`);
console.log("");
console.log("OSM");
console.log(` Bounds: ${osm.bounds ? formatBounds(osm.bounds) : "missing"}`);
console.log(` Nodes: ${formatNumber(osm.nodes.total)} (${formatNumber(osm.nodes.naturalTree)} natural=tree)`);
console.log(` Ways: ${formatNumber(osm.ways.total)}`);
console.log(` Relations: ${formatNumber(osm.relations.total)}`);
console.log(
` Buildings: ${formatNumber(osm.ways.buildings)} way(s), ` +
`${formatNumber(osm.relations.buildingMultipolygons)} multipolygon relation(s)`,
);
console.log(
` Building heights: ${formatNumber(osm.ways.buildingsWithHeight + osm.relations.buildingsWithHeight)} ` +
`height tag(s), ${formatNumber(osm.ways.buildingsWithLevels + osm.relations.buildingsWithLevels)} ` +
"building:levels tag(s)",
);
console.log(
` Building relations healthy: ${formatNumber(osm.relations.healthyBuildingMultipolygons)} / ` +
`${formatNumber(osm.relations.buildingMultipolygons)}`,
);
console.log(
` Vegetation ways: ${formatNumber(osm.ways.grass)} grass, ` +
`${formatNumber(osm.ways.scrub)} scrub, ${formatNumber(osm.ways.treeRows)} tree_row`,
);
if (osm.relations.issues.length) {
console.log(" Relation issues:");
for (const issue of osm.relations.issues) {
console.log(` - ${issue.id}: ${issue.issues.join("; ")}`);
}
}
console.log("");
console.log("Artifacts");
for (const artifact of artifacts) {
const state = artifact.exists && artifact.validType ? "ok" : (artifact.expected ? "missing" : "absent");
const suffix = artifact.geojsonFiles !== null
? `, ${artifact.geojsonFiles} GeoJSON file(s)`
: artifact.bytes !== null ? `, ${formatBytes(artifact.bytes)}` : "";
console.log(` ${state.padEnd(7)} ${artifact.label}: ${artifact.path}${suffix}`);
}
if (metadata) {
console.log(` metadata asset: ${metadata.asset || "missing"}, assets: ${metadata.assets}`);
}
if (glb) {
console.log("");
console.log("GLB digest");
console.log(` Size: ${mb(glb.fileBytes)} MB`);
console.log(
` Counts: ${glb.counts.nodes} nodes, ${glb.counts.meshes} meshes, ` +
`${glb.counts.materials} materials, ${glb.counts.images} images, ${glb.counts.accessors} accessors`,
);
console.log(` Extensions: ${glb.extensionsUsed.length ? glb.extensionsUsed.join(", ") : "none"}`);
}
console.log("");
console.log("Warnings");
if (!warnings.length) {
console.log(" none");
} else {
for (const warning of warnings) console.log(` - ${warning}`);
}
}
function formatBounds(bounds) {
return `${bounds.minLon},${bounds.minLat} -> ${bounds.maxLon},${bounds.maxLat}`;
}
function formatNumber(value) {
return new Intl.NumberFormat("en-US").format(value);
}
function mb(bytes) {
return Number((bytes / 1024 / 1024).toFixed(2));
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${mb(bytes)} MB`;
if (bytes >= 1024) return `${Number((bytes / 1024).toFixed(1))} KB`;
return `${bytes} B`;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || DEFAULT_CONFIG);
const area = readAreaConfig(configPath, { repoRoot });
const osm = parseOsm(fs.readFileSync(area.input, "utf8"));
const artifacts = artifactStatus(area);
const metadataWarnings = [];
const metadata = metadataSummary(area.outputs.metadata, metadataWarnings);
const glb = fs.existsSync(area.outputs.glb) ? glbDigest(area.outputs.glb) : null;
const warnings = [
...metadataWarnings,
...collectWarnings(area, osm, artifacts, glb, metadata),
];
printReport(area, configPath, osm, artifacts, glb, metadata, warnings);
}
main();

View File

@@ -102,20 +102,31 @@ function digest(file) {
};
}
const argv = process.argv.slice(2);
const file = argv.find((arg) => !arg.startsWith("--"));
if (!file) {
console.error("usage: node scripts/glb-digest.js <file.glb> [--out digest.json]");
process.exit(1);
function main() {
const argv = process.argv.slice(2);
const file = argv.find((arg) => !arg.startsWith("--"));
if (!file) {
console.error("usage: node scripts/glb-digest.js <file.glb> [--out digest.json]");
process.exit(1);
}
const outIndex = argv.indexOf("--out");
const result = digest(path.resolve(file));
const text = `${JSON.stringify(result, null, 2)}\n`;
if (outIndex >= 0 && argv[outIndex + 1]) {
const out = path.resolve(argv[outIndex + 1]);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text);
console.log(`GLB digest: ${out}`);
} else {
process.stdout.write(text);
}
}
const outIndex = argv.indexOf("--out");
const result = digest(path.resolve(file));
const text = `${JSON.stringify(result, null, 2)}\n`;
if (outIndex >= 0 && argv[outIndex + 1]) {
const out = path.resolve(argv[outIndex + 1]);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text);
console.log(`GLB digest: ${out}`);
} else {
process.stdout.write(text);
if (require.main === module) {
main();
}
module.exports = {
digest,
readGlbJson,
};

133
scripts/lib/area-config.js Normal file
View File

@@ -0,0 +1,133 @@
"use strict";
const fs = require("fs");
const path = require("path");
function readAreaConfig(file, options = {}) {
if (!fs.existsSync(file)) {
throw new Error(`Config file not found: ${file}`);
}
return normalizeAreaConfig(JSON.parse(fs.readFileSync(file, "utf8")), options);
}
function normalizeAreaConfig(raw, options = {}) {
const repoRoot = options.repoRoot || path.resolve(__dirname, "..", "..");
const id = requireText(raw.id, "id");
const input = path.resolve(requireText(raw.input, "input"));
if (!fs.existsSync(input)) {
throw new Error(`Input OSM XML not found: ${input}`);
}
const outputRoot = path.resolve(raw.outputRoot || path.join(repoRoot, "outputs"));
const outputOverrides = raw.outputs || {};
const areaDir = path.resolve(outputOverrides.areaDir || path.join(outputRoot, id));
const fileStem = outputOverrides.fileStem || id;
const compress = normalizeCompressConfig(raw.compress);
const compressedFileStem = outputOverrides.compressedFileStem ||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
const outputs = {
areaDir,
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
),
compressedGlb: path.resolve(
outputOverrides.compressedGlb || path.join(areaDir, `${compressedFileStem}.glb`),
),
compressedMetadata: path.resolve(
outputOverrides.compressedMetadata || path.join(areaDir, `${compressedFileStem}.json`),
),
compressedCesiumPreview: path.resolve(
outputOverrides.compressedCesiumPreview || path.join(areaDir, `${compressedFileStem}-cesium-preview.html`),
),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")),
};
return {
id,
input,
outputRoot,
qgisApp: raw.qgisApp || "/Applications/QGIS.app",
blenderApp: raw.blenderApp || "/Applications/Blender.app",
stages: {
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true,
blender: raw.stages?.blender ?? true,
cesium: raw.stages?.cesium ?? true,
reimport: false,
preview: false,
compress: false,
},
qgis: {
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
arrowMergeTriangles: raw.qgis?.arrowMergeTriangles ?? raw.arrowMergeTriangles ?? true,
arrowOutlineSimplifyMeters: raw.qgis?.arrowOutlineSimplifyMeters ?? raw.arrowOutlineSimplifyMeters ?? 0.05,
intersectionCornerSourceMaxDimensionMeters: raw.qgis?.intersectionCornerSourceMaxDimensionMeters ?? raw.intersectionCornerSourceMaxDimensionMeters ?? 2.6,
clipPad: raw.qgis?.clipPad ?? raw.clipPad ?? 0.002,
canvasPad: raw.qgis?.canvasPad ?? raw.canvasPad ?? 0.001,
previewPad: raw.qgis?.previewPad ?? raw.previewPad ?? 0.0007,
canvasExtent: raw.qgis?.canvasExtent ?? raw.canvasExtent ?? null,
previewExtent: raw.qgis?.previewExtent ?? raw.previewExtent ?? null,
layerPrefix: raw.qgis?.layerPrefix ?? raw.layerPrefix ?? "osm2streets",
},
osm2streets: raw.osm2streets || {
debug_each_step: false,
dual_carriageway_experiment: false,
sidepath_zipping_experiment: false,
inferred_sidewalks: true,
osm2lanes: true,
},
blender: {
treeStyle: raw.blender?.treeStyle || "natural",
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
},
compress,
outputs,
};
}
function requireText(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing config key: ${key}`);
}
return value;
}
function normalizeCompressConfig(raw) {
const value = raw || {};
return {
textureSize: numberOption(value.textureSize, 768, "compress.textureSize", 64, 4096),
quality: numberOption(value.quality, 82, "compress.quality", 1, 100),
effort: numberOption(value.effort, 80, "compress.effort", 0, 100),
meshopt: booleanOption(value.meshopt, false, "compress.meshopt"),
};
}
function numberOption(value, fallback, label, min, max) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isFinite(number) || number < min || number > max) {
throw new Error(`${label} must be a finite number in [${min}, ${max}]`);
}
return number;
}
function booleanOption(value, fallback, label) {
if (value === undefined) return fallback;
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
throw new Error(`${label} must be boolean`);
}
module.exports = {
normalizeAreaConfig,
readAreaConfig,
};