"use strict"; const fs = require("fs"); const path = require("path"); const { readAreaConfig } = require("./area-config"); const { SCENE_LAYERS, SCENE_FILE, SCENE_STYLE_FILE, layerFile, } = require("./scene-layers"); const { digest: glbDigest } = require("../glb-digest"); const { BUDGETS, evaluateGlbBudget, fileRecord, glbBudgetWarnings, readStageManifest, stageManifestPath, } = require("./stage-manifest"); function defaultConfigPath(repoRoot) { return path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"); } function analyzeArea(configPath, options = {}) { const repoRoot = options.repoRoot || path.resolve(__dirname, "..", ".."); const resolvedConfig = path.resolve(configPath || defaultConfigPath(repoRoot)); const area = readAreaConfig(resolvedConfig, { repoRoot }); const osm = parseOsm(fs.readFileSync(area.input, "utf8")); const artifacts = artifactStatus(area); const manifests = stageManifestStatus(area, resolvedConfig); 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, manifests, glb, metadata), ]; return { area, configPath: resolvedConfig, osm, artifacts, manifests, metadata, metadataWarnings, glb, warnings, }; } 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(/]*)\/?>/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 = /]*?)\/>|]*?)>([\s\S]*?)<\/node>/g; for (const match of xml.matchAll(nodePattern)) { const attrs = xmlAttrs(match[1] || match[2] || ""); if (attrs.action === "delete") continue; 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, buildingsWithBadLevels: 0, buildingGeometryIssues: [], missingNodeRefs: 0, grass: 0, scrub: 0, treeRows: 0, }; for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.action === "delete") continue; const body = match[2]; const tags = parseTags(body); const refs = []; for (const ndMatch of body.matchAll(/]*)\/?>/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["building:levels"] && !parseBuildingLevels(tags["building:levels"])) { wayStats.buildingsWithBadLevels += 1; } if (refs.length < 4) { wayStats.buildingGeometryIssues.push({ id: way.id, issue: "has fewer than 4 node refs" }); } else if (!way.closed) { wayStats.buildingGeometryIssues.push({ id: way.id, issue: "is not closed" }); } } 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, buildingsWithBadLevels: 0, healthyBuildingMultipolygons: 0, issues: [], }; for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/relation>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.action === "delete") continue; const body = match[2]; const tags = parseTags(body); const members = []; for (const memberMatch of body.matchAll(/]*)\/?>/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; if (tags["building:levels"] && !parseBuildingLevels(tags["building:levels"])) { relationStats.buildingsWithBadLevels += 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(/]*)\/?>/); 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.minLon < bounds.maxLon && bounds.minLat < bounds.maxLat ? 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 parseBuildingLevels(value) { const levels = Number(String(value).trim()); return Number.isFinite(levels) && levels > 0; } function analyzeOsmPreflight(osm) { const errors = []; const warnings = []; if (!osm.bounds) errors.push("OSM has no valid ."); if (osm.ways.missingNodeRefs) { errors.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`); } for (const issue of osm.ways.buildingGeometryIssues) { errors.push(`Building way ${issue.id}: ${issue.issue}.`); } if (osm.ways.buildingsWithBadHeight || osm.relations.buildingsWithBadHeight) { errors.push("Some building height tags could not be parsed as positive meters."); } for (const issue of osm.relations.issues) { errors.push(`Building relation ${issue.id}: ${issue.issues.join("; ")}.`); } const badLevels = osm.ways.buildingsWithBadLevels + osm.relations.buildingsWithBadLevels; if (badLevels) { warnings.push(`${badLevels} building:levels tag(s) could not be parsed as positive numbers.`); } return { errors: uniqueLines(errors), warnings: uniqueLines(warnings), summary: { bounds: osm.bounds, nodes: osm.nodes.total, ways: osm.ways.total, relations: osm.relations.total, buildingWays: osm.ways.buildings, buildingMultipolygons: osm.relations.buildingMultipolygons, missingNodeRefs: osm.ways.missingNodeRefs, buildingWayIssues: osm.ways.buildingGeometryIssues.length, buildingRelationIssues: osm.relations.issues.length, }, }; } 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"], ["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"], ["Traffic signal runtime", area.outputs.trafficSignals, 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"], ]; 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 stageManifestStatus(area, configPath = null) { const compressionComplete = fs.existsSync(stageManifestPath(area, "compress")); const reimportManifest = stageManifestPath(area, "reimport"); const hasReimportManifest = fs.existsSync(reimportManifest); const derivedConfig = path.join(area.outputs.pipelineDir, "osm2streets-qgis.config.json"); const stages = [ { stage: "preflight", expected: fs.existsSync(stageManifestPath(area, "preflight")), inputs: { ...(configPath ? { config: configPath } : {}), osm: area.input, }, outputs: {}, }, { stage: "intermediates", expected: ( fs.existsSync(area.outputs.geojsonDir) || fs.existsSync(area.outputs.gpkg) || fs.existsSync(area.outputs.qgisProject) ) && !hasReimportManifest, inputs: { ...(configPath ? { config: configPath } : {}), osm: area.input, }, outputs: { derivedConfig, geojsonDir: area.outputs.geojsonDir, ...sceneGeojsonFiles(area), trafficSignalAssemblies: area.outputs.trafficSignalAssemblies, trafficSignals: area.outputs.trafficSignals, gpkg: area.outputs.gpkg, qgisProject: area.outputs.qgisProject, qgisPreview: optionalExpectedFile(area.outputs.qgisPreview), }, }, { stage: "reimport", expected: hasReimportManifest, inputs: { ...(configPath ? { config: configPath } : {}), derivedConfig, gpkg: area.outputs.gpkg, }, outputs: { geojsonDir: area.outputs.geojsonDir, ...sceneGeojsonFiles(area), trafficSignalAssemblies: area.outputs.trafficSignalAssemblies, trafficSignals: area.outputs.trafficSignals, }, }, { stage: "blender", expected: fs.existsSync(area.outputs.blend), inputs: { ...(configPath ? { config: configPath } : {}), osm: area.input, geojsonDir: area.outputs.geojsonDir, ...sceneGeojsonFiles(area), trafficSignalAssemblies: area.outputs.trafficSignalAssemblies, trafficSignals: area.outputs.trafficSignals, }, outputs: { blend: area.outputs.blend, render: area.outputs.render, }, }, { stage: "cesium", expected: fs.existsSync(area.outputs.glb), inputs: { blend: area.outputs.blend, }, outputs: compressionComplete ? {} : { glb: area.outputs.glb, metadata: area.outputs.metadata, }, }, { stage: "preview", expected: fs.existsSync(area.outputs.cesiumPreview), inputs: { ...(configPath ? { config: configPath } : {}), osm: area.input, ...(compressionComplete ? {} : { glb: area.outputs.glb, metadata: area.outputs.metadata, }), lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"), network: path.join(area.outputs.geojsonDir, "network.json"), intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"), previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"), previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"), }, outputs: compressionComplete ? { vehicleRoute: area.outputs.vehicleRoute, vehicleModel: area.outputs.vehicleModel, } : { cesiumPreview: area.outputs.cesiumPreview, vehicleRoute: area.outputs.vehicleRoute, vehicleModel: area.outputs.vehicleModel, }, }, { stage: "compress", expected: fs.existsSync(area.outputs.glb), inputs: {}, outputs: { glb: area.outputs.glb, metadata: area.outputs.metadata, cesiumPreview: area.outputs.cesiumPreview, }, }, ]; return stages.map((entry) => { const file = stageManifestPath(area, entry.stage); try { const manifest = readStageManifest(area, entry.stage); if (!manifest) { return { stage: entry.stage, path: file, expected: entry.expected, exists: false, valid: false, fresh: false, manifestWarnings: [], issues: entry.expected ? ["manifest missing"] : [], }; } const issues = [ ...manifestFileIssues(manifest.inputs || {}, entry.inputs, "input"), ...manifestFileIssues(manifest.outputs || {}, entry.outputs, "output"), ]; return { stage: entry.stage, path: file, expected: entry.expected, exists: true, valid: true, fresh: issues.length === 0, finishedAt: manifest.finishedAt || null, durationMs: manifest.durationMs ?? null, summary: manifest.summary || null, manifestWarnings: Array.isArray(manifest.warnings) ? manifest.warnings : [], issues, }; } catch (error) { return { stage: entry.stage, path: file, expected: entry.expected, exists: fs.existsSync(file), valid: false, fresh: false, manifestWarnings: [], issues: [`manifest unreadable: ${error.message}`], }; } }); } function manifestFileIssues(records, expectedFiles, label) { const issues = []; for (const [key, expected] of Object.entries(expectedFiles)) { const { file, required } = normalizeExpectedFile(expected); const recorded = records[key]; if (!required && !recorded && !fs.existsSync(file)) { continue; } if (!recorded) { issues.push(`${label} ${key} not recorded`); continue; } if (!required && recorded === null && !fs.existsSync(file)) { continue; } if (!fs.existsSync(file)) { issues.push(`${label} ${key} file missing`); continue; } const current = fileRecord(file); if (recorded.bytes !== current.bytes) { issues.push(`${label} ${key} bytes changed`); } else if (recorded.sha256 && recorded.sha256 !== current.sha256) { issues.push(`${label} ${key} sha256 changed`); } } return issues; } function normalizeExpectedFile(expected) { if (expected && typeof expected === "object" && expected.path) { return { file: expected.path, required: expected.required !== false, }; } return { file: expected, required: true, }; } function optionalExpectedFile(file) { return { path: file, required: false }; } function sceneGeojsonFiles(area) { const files = {}; for (const layer of SCENE_LAYERS) { files[layer.id] = path.join(area.outputs.geojsonDir, layerFile(layer)); } files[SCENE_FILE] = path.join(area.outputs.geojsonDir, SCENE_FILE); files[SCENE_STYLE_FILE] = path.join(area.outputs.geojsonDir, SCENE_STYLE_FILE); return files; } function collectWarnings(area, osm, artifacts, manifests, glb, metadata) { const warnings = []; if (!osm.bounds) warnings.push("OSM has no valid ; 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}).`); } } for (const manifest of manifests) { if (manifest.expected && !manifest.exists) { warnings.push(`Expected stage manifest missing: ${manifest.stage} (${manifest.path}).`); } else if (manifest.exists && !manifest.valid) { warnings.push(`Stage manifest invalid: ${manifest.stage} (${manifest.issues.join("; ")}).`); } else if (manifest.exists && !manifest.fresh) { warnings.push(`Stage manifest stale: ${manifest.stage} (${manifest.issues.join("; ")}).`); } for (const warning of manifest.manifestWarnings) { warnings.push(`Stage manifest warning (${manifest.stage}): ${warning}.`); } } if (glb) { warnings.push(...glbBudgetWarnings("GLB", glb, area.budget).map((warning) => `${warning}.`)); } 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 printDiagnosticsReport(result) { const { area, configPath, osm, artifacts, manifests, glb, metadata, warnings } = result; 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}`); } console.log(""); console.log("Stage manifests"); for (const manifest of manifests) { let state = "absent"; if (manifest.exists && !manifest.valid) state = "invalid"; else if (manifest.exists && !manifest.fresh) state = "stale"; else if (manifest.exists) state = "ok"; else if (manifest.expected) state = "missing"; const timing = manifest.finishedAt ? `, finished ${manifest.finishedAt}, ${manifest.durationMs ?? "?"} ms` : ""; const issues = manifest.issues.length ? `, ${manifest.issues.join("; ")}` : ""; console.log(` ${state.padEnd(7)} ${manifest.stage}: ${manifest.path}${timing}${issues}`); for (const warning of manifest.manifestWarnings) { console.log(` warning: ${warning}`); } const glbSummary = manifest.summary?.glb || manifest.summary?.compressedGlb; if (glbSummary?.counts) { console.log( ` GLB: ${formatBytes(glbSummary.fileBytes)}, ${glbSummary.counts.nodes} nodes, ` + `${glbSummary.counts.meshes} meshes, ${glbSummary.counts.images} images`, ); } } 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"}`); printGlbBudget(glb, area.budget); printGlbSources(glb); printGlbImages(glb); } console.log(""); console.log("Warnings"); if (!warnings.length) { console.log(" none"); } else { for (const warning of warnings) console.log(` - ${warning}`); } } function classifyAreaQuality(result) { const failures = []; const warnings = []; const { osm, artifacts, manifests, glb, metadata, metadataWarnings } = result; if (!osm.bounds) { failures.push("OSM has no valid ."); } if (osm.ways.buildingsWithBadHeight || osm.relations.buildingsWithBadHeight) { failures.push("Some building height tags could not be parsed as positive meters."); } for (const issue of osm.relations.issues) { failures.push(`Building relation ${issue.id}: ${issue.issues.join("; ")}.`); } if (osm.ways.missingNodeRefs) { warnings.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`); } const fatalArtifacts = new Set(["Cesium GLB", "Cesium metadata", "Cesium preview"]); for (const artifact of artifacts) { if (fatalArtifacts.has(artifact.label)) { if (!artifact.exists) { failures.push(`Required artifact missing: ${artifact.label} (${artifact.path}).`); } else if (!artifact.validType) { failures.push(`Required artifact has wrong type: ${artifact.label} (${artifact.path}).`); } continue; } if (artifact.expected && !artifact.exists) { warnings.push(`Expected artifact missing: ${artifact.label} (${artifact.path}).`); } else if (artifact.expected && artifact.exists && !artifact.validType) { failures.push(`Artifact has wrong type: ${artifact.label} (${artifact.path}).`); } } for (const warning of metadataWarnings) { failures.push(warning); } if (metadata && metadata.assets < 1) { warnings.push("Cesium metadata has no assets entries."); } for (const manifest of manifests) { if (!manifest.expected) { if (manifest.exists && !manifest.valid) { warnings.push(`Stage manifest invalid: ${manifest.stage} (${manifest.issues.join("; ")}).`); } else if (manifest.exists && !manifest.fresh) { warnings.push(`Stage manifest stale: ${manifest.stage} (${manifest.issues.join("; ")}).`); } for (const warning of manifest.manifestWarnings) { warnings.push(`Stage manifest warning (${manifest.stage}): ${warning}.`); } continue; } if (manifest.expected && !manifest.exists) { failures.push(`Expected stage manifest missing: ${manifest.stage} (${manifest.path}).`); } else if (manifest.exists && !manifest.valid) { failures.push(`Stage manifest invalid: ${manifest.stage} (${manifest.issues.join("; ")}).`); } else if (manifest.exists && !manifest.fresh) { failures.push(`Stage manifest stale: ${manifest.stage} (${manifest.issues.join("; ")}).`); } for (const warning of manifest.manifestWarnings) { if (/exceeds budget/i.test(warning)) { failures.push(`Stage manifest warning (${manifest.stage}): ${warning}.`); } else { warnings.push(`Stage manifest warning (${manifest.stage}): ${warning}.`); } } } if (glb) { failures.push(...glbBudgetWarnings("GLB", glb, result.area.budget).map((warning) => `${warning}.`)); } if (!result.area.blender.treeStyle) { warnings.push("No Blender tree style configured."); } return { failures: uniqueLines(failures), warnings: uniqueLines(warnings), }; } function uniqueLines(lines) { return [...new Set(lines)]; } 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 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, analyzeArea, artifactStatus, classifyAreaQuality, collectWarnings, defaultConfigPath, formatBytes, mb, parseOsm, printDiagnosticsReport, stageManifestStatus, };