diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md
index c3da593..51418e3 100644
--- a/.trellis/spec/pipeline/cli-and-stages.md
+++ b/.trellis/spec/pipeline/cli-and-stages.md
@@ -12,6 +12,7 @@
| `scripts/build-osm2streets-qgis.js` | intermediates 阶段的实现 | 由 build-area 调起;`npm run build:qgis` 可单跑 |
| `scripts/reimport-gpkg.js` | reimport 阶段的实现 | 由 build-area 调起 |
| `scripts/diagnose-area.js` | 快速区域诊断;只读配置、OSM 和已有产物 | `npm run diagnose:area` |
+| `scripts/check-area.js` | 区域质量门;复用诊断事实源并给出 PASS/FAIL 退出码 | `npm run check:area` |
`scripts/parity.js` 和 `scripts/glb-digest.js` 是校验工具,不属于构建链,见
[产物一致性指南](../guides/artifact-parity-guide.md)。
@@ -210,6 +211,91 @@ const glb = area.outputs.glb;
---
+## 区域质量门命令
+
+### 1. Scope / Trigger
+
+`check:area` 是提交或交付某个区域前的只读质量门。它复用 `scripts/lib/area-diagnostics.js`
+里的 OSM、产物、metadata、stage manifest 和 GLB digest 检查,只负责把诊断结果分类为
+failure / warning 并设置退出码。
+
+它不属于构建阶段,不进入 `--stages`,也不调用 QGIS、Blender、Cesium、压缩或重建。
+
+### 2. Signatures
+
+```bash
+npm run check:area
+npm run check:area -- --config config/areas/.json
+```
+
+底层入口:
+
+```bash
+node scripts/check-area.js [--config config/areas/.json]
+```
+
+### 3. Contracts
+
+- 不传 `--config` 时默认读取 `config/areas/nantaizi-lake-innovation-valley.json`。
+- 区域配置必须通过 `scripts/lib/area-config.js` 的 `readAreaConfig()` 归一化。
+- OSM / artifacts / metadata / GLB / manifest 的解析和检查必须来自
+ `scripts/lib/area-diagnostics.js`,不要在 `check-area.js` 里复制解析逻辑。
+- 输出为短 text report,包含 area、config、output、GLB 摘要、PASS/FAIL、failure
+ 计数和 warning 计数。
+- 有 failure 时 `process.exitCode = 1`;只有 warning 或全绿时 `process.exitCode = 0`。
+
+### 4. Validation & Error Matrix
+
+| 条件 | 结果 |
+|---|---|
+| 配置文件不存在 / `id` 缺失 / OSM 文件不存在 | 共享诊断抛错,非零 |
+| OSM `` 缺失或无效 | failure |
+| building multipolygon 缺 outer / unresolved way / open ring | failure |
+| building `height` 不能解析为正数米 | failure |
+| Cesium GLB / metadata / preview 缺失或类型错误 | failure |
+| metadata JSON 损坏 | failure |
+| GLB size / nodes / images 超保守预算 | failure |
+| expected stage manifest 缺失、损坏或 stale | failure |
+| stage manifest warning 内容包含 budget exceeded | failure |
+| QGIS preview 缺失 | warning,不阻断 |
+| GeoJSON / GeoPackage / QGIS project / Blend / render 缺失 | warning,不阻断 |
+| OSM way 引用缺失 node | warning |
+| metadata 存在但没有 `assets[]` | warning |
+
+### 5. Good/Base/Bad Cases
+
+- Good: `diagnose:area` 用于调查完整细节,`check:area` 用于提交前给 CI/人一个明确退出码。
+- Base: 旧区域只缺 QGIS preview 时,`check:area` 仍 PASS,但报告 warning。
+- Bad: `check:area` 内部重新拼输出路径或重新解析 GLB budget;这会和诊断事实源漂移。
+- Bad: 把 `check:area` 做成 `build-area --stages check`;质量门是只读命令,不是构建阶段。
+
+### 6. Tests Required
+
+- `node --check scripts/lib/area-diagnostics.js`
+- `node --check scripts/diagnose-area.js`
+- `node --check scripts/check-area.js`
+- `npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
+- `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
+- 用临时配置指向不存在的输出目录,确认 `node scripts/check-area.js --config ` 非零退出。
+
+### 7. Wrong vs Correct
+
+Wrong:
+
+```js
+const metadata = JSON.parse(fs.readFileSync("outputs/a/a.json", "utf8"));
+const glb = glbDigest("outputs/a/a.glb");
+```
+
+Correct:
+
+```js
+const result = analyzeArea(configPath, { repoRoot });
+const gate = classifyAreaQuality(result);
+```
+
+---
+
## Stage Manifest 契约
### 1. Scope / Trigger
@@ -359,7 +445,8 @@ writeStageManifest(area, {
- `build-osm2streets-qgis.js:153`
- `reimport-gpkg.js:93`
- `compress-glb.js:16`
-- `diagnose-area.js:17`
+- `diagnose-area.js:13`
+- `check-area.js:13`
```js
--kebab-case value → { kebabCase: "value" }
diff --git a/.trellis/spec/pipeline/index.md b/.trellis/spec/pipeline/index.md
index 075415a..93b8f81 100644
--- a/.trellis/spec/pipeline/index.md
+++ b/.trellis/spec/pipeline/index.md
@@ -13,7 +13,7 @@
| 改九个 osm2streets 图层(增/删/改顺序/改色) | [图层表](./layer-registry.md) ← **最容易出静默错误** |
| 调 QGIS / GDAL / Blender 子进程 | [外部工具调用](./external-tools.md) |
| 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) |
-| 改区域诊断命令或共享区域配置归一化 | [CLI 与阶段](./cli-and-stages.md#区域诊断命令) |
+| 改区域诊断/质量门命令或共享区域配置归一化 | [CLI 与阶段](./cli-and-stages.md#区域诊断命令) 和 [质量门](./cli-and-stages.md#区域质量门命令) |
| 改 stage manifest 写入、读取或 stale 判断 | [CLI 与阶段](./cli-and-stages.md#stage-manifest-契约) |
| 改预览页生成 | [../preview/](../preview/index.md) |
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |
@@ -84,7 +84,9 @@ config/areas/.json
| 文件 | 行数 | 职责 |
|---|---|---|
| `build-area.js` | 815 | 主入口:区域配置读取、阶段调度、Cesium 预览页、车辆巡航和 stage manifest 写入 |
-| `diagnose-area.js` | 557 | 快速诊断:OSM building relation、植被统计、现有产物、stage manifest 和 GLB digest |
+| `diagnose-area.js` | 36 | 快速诊断入口:调用共享 area diagnostics 并打印完整报告 |
+| `check-area.js` | 74 | 区域质量门入口:调用共享 area diagnostics,输出 PASS/FAIL 并设置退出码 |
+| `lib/area-diagnostics.js` | 660 | 共享区域诊断事实源:OSM、产物、metadata、stage manifest、GLB digest 和质量门分类 |
| `lib/area-config.js` | 135 | 区域配置归一化与输出路径推导,供 build / diagnose 复用 |
| `lib/stage-manifest.js` | 100 | stage manifest 路径、文件记录、GLB budget warning 和原子 JSON 写入 |
| `build-osm2streets-qgis.js` | 1468 | intermediates:osm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 |
diff --git a/.trellis/tasks/08-04-add-area-quality-gate/prd.md b/.trellis/tasks/08-04-add-area-quality-gate/prd.md
new file mode 100644
index 0000000..434ac60
--- /dev/null
+++ b/.trellis/tasks/08-04-add-area-quality-gate/prd.md
@@ -0,0 +1,41 @@
+# Add area quality gate
+
+## Goal
+
+Add a lightweight check:area command that turns diagnostics and manifests into pass/fail quality gates.
+
+## Requirements
+
+- Add a lightweight `check:area` npm command for an area quality gate.
+- The command must not run QGIS, Blender, Cesium, compression, or rebuild stages.
+- The command must reuse the same diagnostics source of truth used by
+ `diagnose:area`; avoid duplicating OSM parsing, artifact checks, GLB budget
+ checks, or manifest stale checks.
+- The first quality gate must fail on:
+ - invalid or missing OSM bounds
+ - malformed building multipolygon relations
+ - missing baseline GLB / metadata / Cesium preview artifacts
+ - invalid metadata JSON
+ - GLB size / node / image budgets exceeded
+ - missing, invalid, or stale expected stage manifests
+- Missing QGIS preview remains a warning in this first version.
+- The command must print a concise pass/fail report and use exit code `0` for
+ pass and `1` for fail.
+- README and pipeline spec must document command usage and failure policy.
+
+## Acceptance Criteria
+
+- [x] `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
+ passes against current nantaizi outputs.
+- [x] The check report includes pass/fail counts and any warning lines.
+- [x] `check:area` exits non-zero for a deliberately impossible missing-output
+ config or equivalent controlled failure case.
+- [x] `diagnose:area` still works after any shared diagnostics refactor.
+- [x] Syntax checks pass for changed Node scripts.
+- [x] README and Trellis pipeline spec document `check:area`.
+
+## Notes
+
+- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
+- Lightweight tasks can remain PRD-only.
+- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
diff --git a/README.md b/README.md
index f5930a3..f4a9f4d 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,22 @@ manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时、GL
`diagnose:area` 会读取这些 manifest;如果已有 GLB 但 manifest 缺失,或者 manifest
记录的输入/输出 sha/bytes 和当前文件不一致,会在 `Stage manifests` 和 `Warnings` 里标出来。
+## 区域质量门
+
+提交或交付某个区域前,跑只读质量门:
+
+```bash
+npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json
+```
+
+`check:area` 复用 `diagnose:area` 的 OSM、产物、metadata、GLB digest 和 stage manifest
+检查,但输出更短的 PASS/FAIL 报告。它不会启动 QGIS、Blender、Cesium、压缩或任何重建阶段。
+
+第一版会在这些条件下退出非零:OSM bounds 缺失/无效、building multipolygon relation
+异常、建筑 `height` 无法按正数米解析、Cesium GLB / metadata / preview 缺失或类型错误、
+metadata JSON 损坏、GLB size / nodes / images 超过保守预算、期望存在的 stage manifest
+缺失/损坏/stale。缺 QGIS preview 目前只作为 warning,不阻断。
+
## 区域配置
新区域从模板复制:
diff --git a/docs/changelog.md b/docs/changelog.md
index 34e19ab..cd03d5c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,13 @@
## 2026-08-04
+- 新增区域质量门入口:`npm run check:area -- --config config/areas/.json`。
+ 它复用 `scripts/lib/area-diagnostics.js` 的 OSM、产物、metadata、stage manifest 和
+ GLB digest 检查,只读已有文件,不调用 QGIS / Blender / Cesium / 压缩 / 重建;报告
+ PASS/FAIL、failure 计数和 warning 计数,并在 failure 时退出非零。第一版阻断
+ OSM bounds 缺失/无效、malformed building multipolygon、坏 height、Cesium GLB /
+ metadata / preview 缺失、metadata JSON 损坏、GLB budget 超限、expected manifest
+ missing / invalid / stale;缺 QGIS preview 保持 warning。
- 新增第一版 stage manifest 契约:`cesium` 和 `compress` 阶段成功后分别写入
`outputs//_pipeline/stages/cesium.manifest.json` 与
`compress.manifest.json`。manifest 记录输入/输出文件的 path / bytes /
diff --git a/package.json b/package.json
index 2e20bf5..a274cdc 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"build": "node scripts/build-area.js",
"build:area": "node scripts/build-area.js",
"build:qgis": "node scripts/build-osm2streets-qgis.js",
+ "check:area": "node scripts/check-area.js",
"compress:glb": "node scripts/compress-glb.js",
"diagnose:area": "node scripts/diagnose-area.js"
},
diff --git a/scripts/check-area.js b/scripts/check-area.js
new file mode 100644
index 0000000..6209920
--- /dev/null
+++ b/scripts/check-area.js
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+"use strict";
+
+const path = require("path");
+const {
+ analyzeArea,
+ classifyAreaQuality,
+ defaultConfigPath,
+ formatBytes,
+} = require("./lib/area-diagnostics");
+
+const repoRoot = path.resolve(__dirname, "..");
+
+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 main() {
+ const args = parseArgs(process.argv.slice(2));
+ const configPath = path.resolve(args.config || defaultConfigPath(repoRoot));
+ const result = analyzeArea(configPath, { repoRoot });
+ const gate = classifyAreaQuality(result);
+
+ printCheckReport(result, gate);
+ process.exitCode = gate.failures.length ? 1 : 0;
+}
+
+function printCheckReport(result, gate) {
+ console.log("Area quality gate");
+ console.log(`Area: ${result.area.id}`);
+ console.log(`Config: ${result.configPath}`);
+ console.log(`Output: ${result.area.outputs.areaDir}`);
+ if (result.glb) {
+ console.log(
+ `GLB: ${formatBytes(result.glb.fileBytes)}, ${result.glb.counts.nodes} nodes, ` +
+ `${result.glb.counts.images} images`,
+ );
+ }
+ console.log("");
+
+ const status = gate.failures.length ? "FAIL" : "PASS";
+ console.log(`${status}: ${gate.failures.length} failure(s), ${gate.warnings.length} warning(s)`);
+ console.log("");
+
+ console.log(`Failures (${gate.failures.length})`);
+ if (!gate.failures.length) {
+ console.log(" none");
+ } else {
+ for (const failure of gate.failures) console.log(` - ${failure}`);
+ }
+ console.log("");
+
+ console.log(`Warnings (${gate.warnings.length})`);
+ if (!gate.warnings.length) {
+ console.log(" none");
+ } else {
+ for (const warning of gate.warnings) console.log(` - ${warning}`);
+ }
+}
+
+main();
diff --git a/scripts/diagnose-area.js b/scripts/diagnose-area.js
index 0ba3dab..10a05ba 100644
--- a/scripts/diagnose-area.js
+++ b/scripts/diagnose-area.js
@@ -1,19 +1,14 @@
#!/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 { fileRecord, readStageManifest, stageManifestPath } = require("./lib/stage-manifest");
+const {
+ analyzeArea,
+ defaultConfigPath,
+ printDiagnosticsReport,
+} = require("./lib/area-diagnostics");
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 = {};
@@ -32,545 +27,10 @@ function parseArgs(argv) {
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(/]*)\/?>/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.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(/]*)>([\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(/]*)\/?>/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(/]*)>([\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(/]*)\/?>/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(/]*)\/?>/);
- 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 stageManifestStatus(area) {
- const stages = [
- {
- stage: "cesium",
- expected: fs.existsSync(area.outputs.glb),
- inputs: {
- blend: area.outputs.blend,
- },
- outputs: {
- glb: area.outputs.glb,
- metadata: area.outputs.metadata,
- cesiumPreview: area.outputs.cesiumPreview,
- },
- },
- {
- stage: "compress",
- expected: fs.existsSync(area.outputs.compressedGlb),
- inputs: {
- glb: area.outputs.glb,
- metadata: area.outputs.metadata,
- cesiumPreview: area.outputs.cesiumPreview,
- },
- outputs: {
- compressedGlb: area.outputs.compressedGlb,
- compressedMetadata: area.outputs.compressedMetadata,
- compressedCesiumPreview: area.outputs.compressedCesiumPreview,
- },
- },
- ];
- 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, file] of Object.entries(expectedFiles)) {
- const recorded = records[key];
- if (!recorded) {
- issues.push(`${label} ${key} not recorded`);
- 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 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) {
- 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, manifests, 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}`);
- }
- 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"}`);
- }
- 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 manifests = stageManifestStatus(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, manifests, glb, metadata),
- ];
- printReport(area, configPath, osm, artifacts, manifests, glb, metadata, warnings);
+ const configPath = path.resolve(args.config || defaultConfigPath(repoRoot));
+ printDiagnosticsReport(analyzeArea(configPath, { repoRoot }));
}
main();
diff --git a/scripts/lib/area-diagnostics.js b/scripts/lib/area-diagnostics.js
new file mode 100644
index 0000000..71023c3
--- /dev/null
+++ b/scripts/lib/area-diagnostics.js
@@ -0,0 +1,670 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { readAreaConfig } = require("./area-config");
+const { digest: glbDigest } = require("../glb-digest");
+const { BUDGETS, fileRecord, 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);
+ 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.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(/]*)>([\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(/]*)\/?>/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(/]*)>([\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(/]*)\/?>/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(/]*)\/?>/);
+ 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 stageManifestStatus(area) {
+ const stages = [
+ {
+ stage: "cesium",
+ expected: fs.existsSync(area.outputs.glb),
+ inputs: {
+ blend: area.outputs.blend,
+ },
+ outputs: {
+ glb: area.outputs.glb,
+ metadata: area.outputs.metadata,
+ cesiumPreview: area.outputs.cesiumPreview,
+ },
+ },
+ {
+ stage: "compress",
+ expected: fs.existsSync(area.outputs.compressedGlb),
+ inputs: {
+ glb: area.outputs.glb,
+ metadata: area.outputs.metadata,
+ cesiumPreview: area.outputs.cesiumPreview,
+ },
+ outputs: {
+ compressedGlb: area.outputs.compressedGlb,
+ compressedMetadata: area.outputs.compressedMetadata,
+ compressedCesiumPreview: area.outputs.compressedCesiumPreview,
+ },
+ },
+ ];
+ 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, file] of Object.entries(expectedFiles)) {
+ const recorded = records[key];
+ if (!recorded) {
+ issues.push(`${label} ${key} not recorded`);
+ 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 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) {
+ 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 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"}`);
+ }
+ 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) {
+ 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}.`);
+ }
+ }
+
+ 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`;
+}
+
+module.exports = {
+ BUDGETS,
+ analyzeArea,
+ artifactStatus,
+ classifyAreaQuality,
+ collectWarnings,
+ defaultConfigPath,
+ formatBytes,
+ mb,
+ parseOsm,
+ printDiagnosticsReport,
+ stageManifestStatus,
+};