From d4c3baf6088b86863ecea8a59eec1fc395ca81a2 Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 4 Aug 2026 09:16:14 +0800 Subject: [PATCH] Add area diagnostics command --- .../spec/guides/code-reuse-thinking-guide.md | 27 +- .trellis/spec/guides/index.md | 7 +- .trellis/spec/pipeline/cli-and-stages.md | 107 ++++- .trellis/spec/pipeline/index.md | 11 +- .../08-04-add-area-diagnostics/check.jsonl | 1 + .../implement.jsonl | 1 + .../tasks/08-04-add-area-diagnostics/prd.md | 40 ++ .../08-04-add-area-diagnostics/task.json | 26 ++ README.md | 13 + docs/changelog.md | 10 + package.json | 3 +- scripts/build-area.js | 126 +---- scripts/diagnose-area.js | 438 ++++++++++++++++++ scripts/glb-digest.js | 41 +- scripts/lib/area-config.js | 133 ++++++ 15 files changed, 819 insertions(+), 165 deletions(-) create mode 100644 .trellis/tasks/08-04-add-area-diagnostics/check.jsonl create mode 100644 .trellis/tasks/08-04-add-area-diagnostics/implement.jsonl create mode 100644 .trellis/tasks/08-04-add-area-diagnostics/prd.md create mode 100644 .trellis/tasks/08-04-add-area-diagnostics/task.json create mode 100644 scripts/diagnose-area.js create mode 100644 scripts/lib/area-config.js diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md index 90bd946..befbe46 100644 --- a/.trellis/spec/guides/code-reuse-thinking-guide.md +++ b/.trellis/spec/guides/code-reuse-thinking-guide.md @@ -42,13 +42,14 @@ Blender 高度与线性颜色。两侧靠 `catalog.check_layers()` 对账集合 ### 区域输出路径 -输出路径只在 `scripts/build-area.js:74` 的 `normalizeAreaConfig()` 推导。 -低层脚本读取 `_pipeline/osm2streets-qgis.config.json`,不要重新读取 +输出路径只在 `scripts/lib/area-config.js` 的 `normalizeAreaConfig()` 推导。 +`scripts/build-area.js` 和 `scripts/diagnose-area.js` 都必须通过 `readAreaConfig()` 读取 +区域配置。低层脚本读取 `_pipeline/osm2streets-qgis.config.json`,不要重新读取 `config/areas/*.json` 或在阶段函数里现场拼路径。 -新增产物时,在 `normalizeAreaConfig` 的 `outputs` 里加一项,再按需写入 -`writeDerivedConfig()`(`build-area.js:189`)。这样 `intermediates`、`reimport`、 -`blender`、`cesium`、`preview` 仍然只通过磁盘产物耦合。 +新增产物时,在 `area-config.js` 的 `outputs` 里加一项,再按需写入 +`writeDerivedConfig()`。这样 `intermediates`、`reimport`、`blender`、`cesium`、 +`preview` 和读-only 诊断仍然只通过磁盘产物耦合。 ### 材质声明 @@ -65,18 +66,20 @@ Cesium 导出调色也属于同一个材质声明:新场景把 `catalog.MATERI ## 可接受的重复 -### 三份 `parseArgs` +### 多份 `parseArgs` -`parseArgs` 现在重复在三个独立入口: +`parseArgs` 现在重复在多个独立入口: -- `scripts/build-area.js:50` +- `scripts/build-area.js:54` - `scripts/build-osm2streets-qgis.js:153` - `scripts/reimport-gpkg.js:93` +- `scripts/compress-glb.js:16` +- `scripts/diagnose-area.js:17` 语义一致:`--kebab-case value` 变 `kebabCase: "value"`,无值 flag 变字符串 `"true"`。 -这份重复目前是可接受技术债,因为三个脚本都能独立运行。改其中一处解析语义时,不要顺手 -只改一份;要么保持三份一致,要么把"抽公共模块"作为独立重构并跑 parity。 +这份重复目前是可接受技术债,因为这些脚本都能独立运行。改其中一处解析语义时,不要顺手 +只改一份;要么保持全部入口一致,要么把"抽公共模块"作为独立重构并跑对应入口检查。 ### JS 与 Python 的图层颜色 @@ -132,7 +135,7 @@ Cesium 导出调色也属于同一个材质声明:新场景把 `catalog.MATERI 不要因为代码相似就抽象: -- 三份 `parseArgs` 当前保持独立入口价值 +- 多份 `parseArgs` 当前保持独立入口价值 - `ROAD_LAYERS` 与 `SCENE_LAYERS` 跨语言且承载不同字段 - 每个要素模块各自调用 `clip_polygon` 是模块边界,不是可消除重复 @@ -156,6 +159,6 @@ Cesium 导出调色也属于同一个材质声明:新场景把 `catalog.MATERI | 新增一份图层名列表 | 回到旧的四份同步,漏改静默错栈 | | 把两套颜色表统一 | 破坏 QGIS 与 Blender 各自调过的视觉结果 | | 低层脚本直接读 `config/areas/*.json` | 两层配置边界失效 | -| 只改一份 `parseArgs` 的语义 | 三个入口行为分裂 | +| 只改一份 `parseArgs` 的语义 | 独立入口行为分裂 | | 把要素模块裁剪逻辑挪到调用方 | 不同要素的越界处理开始漂移 | | 只改 `export_cesium.py` 的旧回退表,不写 `MATERIALS[*]["cesium"]` | 新 `.blend` 不会携带 Cesium 导出契约 | diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md index ec2372a..56a457a 100644 --- a/.trellis/spec/guides/index.md +++ b/.trellis/spec/guides/index.md @@ -30,8 +30,9 @@ ### 读代码复用思考指南 - [ ] 准备新增第二份或第三份图层、材质、配置字段枚举 -- [ ] 修改三份重复的 `parseArgs` 之一: - `build-area.js:50`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93` +- [ ] 修改多份重复的 `parseArgs` 之一: + `build-area.js:54`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93`、 + `compress-glb.js:16`、`diagnose-area.js:17` - [ ] 多个要素模块都要做同一件几何预处理,比如 `water.py:9`、`grass.py:9`、`scrub.py:8` 都先 `clip_polygon` - [ ] 低层脚本想直接读取 `config/areas/*.json`,绕开派生配置 @@ -66,7 +67,7 @@ grep -rn "要改的值" scripts blender config - 先看它有没有读到对应包的 index 和本目录指南 - 对任何"行为没变"的结论,要求说明是否需要 parity;需要却没跑就是风险 - 对任何"可以合并重复"的建议,先判断重复是不是刻意边界: - 三份 `parseArgs` 目前是可接受技术债,JS/Python 图层颜色则是刻意不同步 + 多份 `parseArgs` 目前是可接受技术债,JS/Python 图层颜色则是刻意不同步 - 对任何"加精度、加默认值、直接覆盖文件"的建议,回到真实代码注释验证; `reimport-gpkg.js:152-156` 和 `reimport-gpkg.js:11-13` 都是反直觉约束 diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 00e0ac2..93b1cd3 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -4,13 +4,14 @@ --- -## 三个入口脚本 +## 命令入口脚本 | 脚本 | 角色 | 入口方式 | |---|---|---| | `scripts/build-area.js` | **主入口**。读区域配置,按阶段调度 | `npm run build` / `build:area` | | `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/parity.js` 和 `scripts/glb-digest.js` 是校验工具,不属于构建链,见 [产物一致性指南](../guides/artifact-parity-guide.md)。 @@ -119,10 +120,102 @@ npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a-compressed- --- +## 区域诊断命令 + +### 1. Scope / Trigger + +`diagnose:area` 是手工编辑 OSM、排查 building relation、高度语义、植被数量和现有 +产物体量时的快速读-only 检查。它不属于构建阶段,不进入 `--stages`,也不调用 QGIS、 +Blender、Cesium 或 `gltf-transform`。 + +### 2. Signatures + +```bash +npm run diagnose:area +npm run diagnose:area -- --config config/areas/.json +``` + +底层入口: + +```bash +node scripts/diagnose-area.js [--config config/areas/.json] +``` + +### 3. Contracts + +- 不传 `--config` 时默认读取 `config/areas/nantaizi-lake-innovation-valley.json`。 +- 区域配置必须通过 `scripts/lib/area-config.js` 的 `readAreaConfig()` 归一化,和 + `build-area.js` 共用同一套输出路径、压缩文件名、默认阶段和配置字段语义。 +- 命令只读取: + - 区域配置 + - 配置里的 OSM XML + - 已存在的 `area.outputs.*` 产物 + - 已存在的默认 GLB(通过 `scripts/glb-digest.js` 导出的 `digest()`) +- 输出为 text report,包含 OSM bounds、节点/way/relation 数量、building way、 + building multipolygon relation、显式 `height` / `building:levels`、植被数量、 + 产物存在性、metadata 摘要、GLB size/counts/extensions 和 warnings。 +- warnings 不改变退出码;配置缺失、输入 OSM 缺失、GLB 文件损坏这类无法继续读取的错误才 + 非零退出。 + +### 4. Validation & Error Matrix + +| 条件 | 结果 | +|---|---| +| 配置文件不存在 | `Config file not found: `,非零 | +| `id` / `input` 缺失 | `Missing config key: `,非零 | +| OSM XML 不存在 | `Input OSM XML not found: `,非零 | +| OSM 缺 `` | warning,不中断 | +| building multipolygon 缺 outer / unresolved way / open ring | warning,不中断 | +| baseline 产物缺失 | warning,不中断 | +| metadata JSON 损坏 | warning,不中断 | +| GLB 存在但不是合法 GLB | 抛出 `glb-digest` 错误,非零 | +| GLB 超过保守预算 | warning,不中断 | + +### 5. Good/Base/Bad Cases + +- Good: 手工改完 OSM 后先跑 `npm run diagnose:area -- --config ...`,确认 building + relation healthy,再跑 `--stages blender,cesium`。 +- Base: 只生成过部分阶段时运行诊断,缺失产物以 warning 暴露,用来判断下一步该补哪个阶段。 +- Bad: 把诊断做成 `build-area` 的新 stage;它是读-only 工具,不应参与构建调度或产物生成。 + +### 6. Tests Required + +- `node --check scripts/diagnose-area.js` +- `node --check scripts/lib/area-config.js` +- `node --check scripts/build-area.js` +- `node --check scripts/glb-digest.js` +- `npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json` +- 如果抽取了配置归一化,至少跑一个轻量 `build-area` 阶段确认主入口仍能调度: + `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview` + +### 7. Wrong vs Correct + +Wrong: + +```js +const config = JSON.parse(fs.readFileSync("config/areas/a.json", "utf8")); +const glb = path.join(config.outputRoot, config.id, `${config.id}.glb`); +``` + +Correct: + +```js +const { readAreaConfig } = require("./lib/area-config"); +const area = readAreaConfig(configPath, { repoRoot }); +const glb = area.outputs.glb; +``` + +--- + ## CLI 参数解析 -三个脚本各有一份**完全相同**的 `parseArgs` -(`build-area.js:50`、`build-osm2streets-qgis.js:153`、`reimport-gpkg.js:93`): +独立入口脚本各有一份同语义的 `parseArgs`: + +- `build-area.js:54` +- `build-osm2streets-qgis.js:153` +- `reimport-gpkg.js:93` +- `compress-glb.js:16` +- `diagnose-area.js:17` ```js --kebab-case value → { kebabCase: "value" } @@ -136,9 +229,9 @@ npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a-compressed- - **不做校验**。未知参数被静默收集,缺失参数由下游的 `requireText` / `Number.isFinite` 报错 -> 这份重复是已知的、**当前被接受的**技术债:三个脚本要能各自独立运行,抽公共模块的 -> 收益还不抵引入一层依赖。改其中一份时**不要**顺手把另外两份重构掉——那是独立的决定, -> 且会扩大 diff。真要抽取,三处一起改并跑 parity。 +> 这份重复是已知的、**当前被接受的**技术债:这些脚本要能各自独立运行,抽公共模块的 +> 收益还不抵引入一层依赖。改其中一份解析语义时**不要**只改一份;要么保持全部一致, +> 要么把抽公共解析器作为独立重构并跑对应入口检查。 --- @@ -279,7 +372,7 @@ parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SC | 让 `reimport` / `preview` 能从配置文件默认开启 | 恢复动作变成常规行为 | | 新阶段忘了 `ensureFile` 前置校验 | 单跑时报底层堆栈而非人话 | | 改 stage 的 stdout 标记 | 静默破坏 parity 契约 | -| 顺手把三份 `parseArgs` 合并 | 扩大 diff,且三个脚本的独立性是刻意的 | +| 顺手把多份 `parseArgs` 合并 | 扩大 diff,且独立入口的独立性是刻意的 | --- diff --git a/.trellis/spec/pipeline/index.md b/.trellis/spec/pipeline/index.md index 1d2b111..3acc039 100644 --- a/.trellis/spec/pipeline/index.md +++ b/.trellis/spec/pipeline/index.md @@ -13,6 +13,7 @@ | 改九个 osm2streets 图层(增/删/改顺序/改色) | [图层表](./layer-registry.md) ← **最容易出静默错误** | | 调 QGIS / GDAL / Blender 子进程 | [外部工具调用](./external-tools.md) | | 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) | +| 改区域诊断命令或共享区域配置归一化 | [CLI 与阶段](./cli-and-stages.md#区域诊断命令) | | 改预览页生成 | [../preview/](../preview/index.md) | | 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) | @@ -23,7 +24,9 @@ ``` config/areas/.json │ - ▼ build-area.js — normalizeAreaConfig() 推导全部输出路径 + ▼ lib/area-config.js — normalizeAreaConfig() 推导全部输出路径 + │ + ▼ build-area.js — 阶段调度 _pipeline/osm2streets-qgis.config.json (派生配置) │ ├─[intermediates]─▶ build-osm2streets-qgis.js @@ -75,14 +78,16 @@ config/areas/.json | 文件 | 行数 | 职责 | |---|---|---| -| `build-area.js` | 774 | 主入口:配置归一化、阶段调度、Cesium 预览页与车辆巡航生成 | +| `build-area.js` | 745 | 主入口:区域配置读取、阶段调度、Cesium 预览页与车辆巡航生成 | +| `diagnose-area.js` | 438 | 快速诊断:OSM building relation、植被统计、现有产物和 GLB digest | +| `lib/area-config.js` | 133 | 区域配置归一化与输出路径推导,供 build / diagnose 复用 | | `build-osm2streets-qgis.js` | 1468 | intermediates:osm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 | | `reimport-gpkg.js` | 179 | reimport:GeoPackage → GeoJSON 反向导出 | | `lib/scene-layers.js` | 164 | 九个图层的单一事实源 + 四个派生函数 | | `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) | | `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) | | `parity.js` | 270 | 产物一致性校验驱动 | -| `glb-digest.js` | 121 | GLB 结构摘要 | +| `glb-digest.js` | 132 | GLB 结构摘要,CLI 和诊断脚本共用 | --- diff --git a/.trellis/tasks/08-04-add-area-diagnostics/check.jsonl b/.trellis/tasks/08-04-add-area-diagnostics/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-04-add-area-diagnostics/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-04-add-area-diagnostics/implement.jsonl b/.trellis/tasks/08-04-add-area-diagnostics/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-04-add-area-diagnostics/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-04-add-area-diagnostics/prd.md b/.trellis/tasks/08-04-add-area-diagnostics/prd.md new file mode 100644 index 0000000..fc24b8b --- /dev/null +++ b/.trellis/tasks/08-04-add-area-diagnostics/prd.md @@ -0,0 +1,40 @@ +# Add area diagnostics command + +## Goal + +Add a fast area diagnostics CLI for OSM structure and existing output artifact health. + +## Requirements + +- Add a fast diagnostics CLI for an area config. +- The command must not invoke QGIS, Blender, Cesium, or GLB compression. +- The report must scan the configured OSM XML for common manual-edit risks: + bounds presence, nodes, ways, relations, building ways, building multipolygon + relations, explicit heights, building levels, malformed relation members, + unresolved member ways, open rings, and basic vegetation counts. +- The report must inspect existing output artifacts when present: + GeoJSON directory, GeoPackage, QGIS project, `.blend`, render PNG, GLB, + metadata, Cesium preview, compressed GLB, compressed metadata, and compressed + preview. +- If a GLB exists, the report should reuse the existing GLB digest logic to + summarize size, nodes, meshes, materials, images, accessors, and extensions. +- The command should emit clear warnings for likely trouble, including missing + OSM bounds, malformed building multipolygons, missing expected artifacts, GLB + over a conservative size budget, high node count, and high texture count. +- Add an npm script and README usage. + +## Acceptance Criteria + +- [x] `npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json` + prints a useful text report without running heavy build stages. +- [x] The diagnostics report includes OSM structure, building relation health, + vegetation counts, output artifact status, and GLB digest summary. +- [x] Missing optional output artifacts are warnings, not hard failures. +- [x] Syntax checks pass for the changed Node scripts. +- [x] README documents the new diagnostic command. + +## 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/.trellis/tasks/08-04-add-area-diagnostics/task.json b/.trellis/tasks/08-04-add-area-diagnostics/task.json new file mode 100644 index 0000000..e3247db --- /dev/null +++ b/.trellis/tasks/08-04-add-area-diagnostics/task.json @@ -0,0 +1,26 @@ +{ + "id": "add-area-diagnostics", + "name": "add-area-diagnostics", + "title": "Add area diagnostics command", + "description": "Add a fast area diagnostics CLI for OSM structure and existing output artifact health.", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-04", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index 5def2af..fd0f73e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,19 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress ``` +## 区域诊断 + +手工编辑 OSM 或怀疑产物变大时,先跑快速诊断。它只读取区域配置、OSM XML 和已有输出 +文件,不会启动 QGIS、Blender 或 Cesium 构建: + +```bash +npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json +``` + +诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、 +现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。 +缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`。 + ## 区域配置 新区域从模板复制: diff --git a/docs/changelog.md b/docs/changelog.md index 4175e0b..6c8990e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-08-04 + +- 新增区域快速诊断入口:`npm run diagnose:area -- --config config/areas/.json`。 + 诊断只读取区域配置、OSM XML 和已有输出,不启动 QGIS / Blender / Cesium 构建;报告 + OSM bounds、building way / multipolygon relation、显式 `height` / + `building:levels`、植被数量、产物存在性、metadata 和 GLB digest,并对 malformed + building relation、缺失基线产物、GLB size / nodes / images 超预算给 warning。 + 同时把区域配置归一化抽到 `scripts/lib/area-config.js`,`build-area.js` 和 + `diagnose-area.js` 共用同一套输出路径推导,避免诊断脚本复制路径规则。 + ## 2026-08-03 - 修复 OSM `type=multipolygon` building relation 不渲染的问题:`parse_osm()` 现在会把 diff --git a/package.json b/package.json index 5c16077..2e20bf5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "node scripts/build-area.js", "build:area": "node scripts/build-area.js", "build:qgis": "node scripts/build-osm2streets-qgis.js", - "compress:glb": "node scripts/compress-glb.js" + "compress:glb": "node scripts/compress-glb.js", + "diagnose:area": "node scripts/diagnose-area.js" }, "dependencies": { "osm2streets-js-node": "0.1.4" diff --git a/scripts/build-area.js b/scripts/build-area.js index 86fb9f6..e02d968 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -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(",") diff --git a/scripts/diagnose-area.js b/scripts/diagnose-area.js new file mode 100644 index 0000000..7a4f4c9 --- /dev/null +++ b/scripts/diagnose-area.js @@ -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(/]*)\/?>/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 collectWarnings(area, osm, artifacts, 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}).`); + } + } + 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(); diff --git a/scripts/glb-digest.js b/scripts/glb-digest.js index f23b62d..b1451ff 100644 --- a/scripts/glb-digest.js +++ b/scripts/glb-digest.js @@ -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 [--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 [--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, +}; diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js new file mode 100644 index 0000000..1324832 --- /dev/null +++ b/scripts/lib/area-config.js @@ -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, +};