refactor: 抽 osmassets 包,要素注册表,材质单一定义源 (P0-P3)

generate_scene.py 从 1271 → 816 行 (-455)

P0: 纯函数搬家
- osmassets/osm.py: parse_osm / Projector / parse_height
- osmassets/geom.py: clip_polygon / point_in_polygon / distance_to_ring / sample_tree_row 等
- blender/tests/test_pure.py: 42 个 unittest (脱离 bpy 运行)

P1: 单一定义源
- osmassets/catalog.py: ROAD_LAYERS + MATERIALS (含 cesium 导出参数)
- 对接 osm2streets_scene_style.json 做图层一致性 warning
- 干掉 road_mats / layer_z / 材质参数三份副本

P2: 要素注册表
- osmassets/{water,grass,scrub}.py: 每个要素一个 assemble() 函数
- build() 中的 if/elif 链收缩为注册表调用
- 计数器集中到 counts 字典

P3: 材质契约化
- catalog.py 扩展 CESIUM_EXPORT 段 (tint/metallic/emission)
- 标记已发现的死条目 Office White Metal Facade (四表各一组)

校验:
- parity.js + scene_digest.py + glb-digest.js 三位一体
- control-1 vs p0/p1/p2a/p2b/p3-counts: 两区域全 PARITY OK
- 42 个纯 Python 测试全部通过

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:59:32 +08:00
parent 23ae63bc2a
commit 24e02e2041
16 changed files with 1924 additions and 598 deletions

112
docs/refactor-plan.md Normal file
View File

@@ -0,0 +1,112 @@
# 重构施工计划:`osmassets` 包化P0P3
> 临时工作文档。P3 收尾后把结论并入 `docs/changelog.md`,本文件删除。
## 目标
把「从 OSM 生成 Blender / Cesium 资产」的逻辑从两个单体脚本里拆成可复用的库,使得:
- 新增一种 OSM 要素 = 新增一个 `features/*.py` + 注册一行,不改 `build()`
- 道路图层表、材质规格只有一份定义JS 侧与 Python 侧不再各存一份
- `export_cesium.py` 不再靠材质名字符串跟 `generate_scene.py` 对接
- 纯几何 / 解析逻辑脱离 `bpy`,可用系统 python 直接测
## 硬约束:严格产物一致
P0P3 全程 **不改变任何输出**。每期结束必须通过 parity 校验,任何差异都要么消除、要么在本文件里逐条记录原因。
已知缺陷(本轮**只记录、不修**
| # | 位置 | 现象 |
|---|---|---|
| D1 | `export_cesium.py:26,34,40,52` | `"Office White Metal Facade"` 四张表里都有,`generate_scene.py` 里已无此材质——死条目 |
| D2 | `scene-layers.js:15` vs `generate_scene.py:1017` | 同一批图层的颜色两侧各自手调,无一致性保证 |
| D3 | `generate_scene.py:788` | `tuft_density_wave` 注释仍在跟已删除的 hedge banding 作对比 |
## Parity 工具与基线
`outputs/` 已在 `.gitignore` 中,基线快照放 `outputs/_refactor-baseline/`,不入库。
| 工具 | 位置 | 作用 |
|---|---|---|
| 场景摘要 | `blender/tools/scene_digest.py` | 在 Blender 内打开 `.blend`,输出稳定 JSON对象名/顶点数/面数/材质槽/自定义属性、材质参数、场景属性 |
| GLB 摘要 | `scripts/glb-digest.js` | 纯 Node 读 GLB 的 JSON chunk输出 node/mesh/material 清单与 PBR 参数,附 buffer 字节长度 |
| 驱动 | `scripts/parity.sh <label>` | 跑 blender+cesium 阶段 → 收集 `SCENE_DONE` / `CESIUM_EXPORT_DONE` / 两份摘要 / 渲染 PNG 到 `outputs/_refactor-baseline/<label>/` |
**先做对照实验control**用未改动的代码连跑两次diff 两份摘要。这一步确定哪些字段天然不确定,把这些字段列入忽略名单。没做这步的 parity 校验是假的。
已完成,结论如下(`control-1` vs `control-2`,两区域):
- `.blend` **结构摘要两次完全一致** —— 这是主校验信号,可信
- `.blend` 文件 sha256 不一致:内嵌绝对路径 + 图片打包顺序随哈希表走
- 渲染 PNG sha256 不一致EEVEE 非位级可复现
- GLB 结构node / mesh / primitive / material / image两次完全一致但 accessor 数 399 vs 398、buffer 差 720 字节glTF 导出器会去重相同 accessor`smart_project` 的 UV 带浮点噪声,一次能去重一次不能
忽略名单(写在 `scripts/parity.js:IGNORED_PATHS`,附原因):`files.{blend,glb,render}.sha256``files.{glb,render}.bytes``glbDigest.{fileBytes,buffers,counts.accessors}`
保留比对的即真正的契约:`SCENE_DONE` / `CESIUM_EXPORT_DONE` 标记、`.blend` 全量结构摘要、GLB 的 node/mesh/material/image 结构、`<area>.json` 元数据。加上 `control-1``control-2` 两份基线已落盘。
样本区域:
- `nantaizi-lake-innovation-valley` — 主样本OSM + osm2streets GeoJSON 齐全
- `hanyang-block` — 次样本,只有 `intermediates` 产物,需先补跑一次 blender 阶段生成基线
## 分期
### P0 — 抽纯函数(行为零变化)
新建 `blender/osmassets/`,只搬运、不改逻辑:
| 目标文件 | 从 `generate_scene.py` 搬入 | 依赖 |
|---|---|---|
| `osm.py` | `tags` (94)、`parse_osm` (99)、`Projector` (140)、`parse_height` (506) | 无 bpy |
| `geom.py` | `geometry_rings` (406)、`feature_in_bounds` (418)、`clip_polygon` (426)、`sample_tree_row` (513)、`polygon_area` (688)、`point_in_polygon` (697)、`distance_to_ring` (712) | 无 bpy |
- `generate_scene.py` 顶部加 `sys.path` 引导(`--factory-startup``blender/` 不在 `sys.path`),改为 `from osmassets import ...`
- 新增 `blender/tests/test_geom.py``test_osm.py``unittest` 标准库,系统 `python3` 直接跑(本机 3.9,避免 3.10+ 语法)
- 验收:`python3 -m unittest discover blender/tests` 通过 + parity 全绿
### P1 — 单一定义源
新建 `blender/osmassets/catalog.py`
- `ROAD_LAYERS``id` / `blender_z` / `material_name` / `color`,替换 `generate_scene.py:1017``road_mats``1122``layer_z` 两份副本
- `MATERIAL_SPECS`:目前散在 `build()` 里的全部 `make_material` / `make_textured_material` 调用参数
- 新增一致性检查:读输出目录里已存在的 `osm2streets_scene_style.json`,比对图层 id 集合与顺序,不一致则打 warning**不**改颜色,改了就破坏 parity → 见 D2
验收parity 全绿;手动删一个图层 id 验证 warning 生效。
### P2 — 要素注册表
新建 `blender/osmassets/features/`,每种要素一个模块,导出 `SPEC`
```
water.py natural=water / water=lake
grass.py landuse=grass含 tuft 散布)
scrub.py natural=scrub
tree.py natural=tree 节点 + natural=tree_row + 两种树风格
building.py building=*(含 roof / windows
fountain.py amenity=fountain
roads.py osm2streets GeoJSON 图层 + highway 折线回退
```
- `scene.py::assemble()` 遍历注册表;`build()` 收缩为「解析 → assemble → 灯光相机 → 存盘渲染」
- 计数器改由注册表汇总,但 `SCENE_DONE``scene[...]` 的键名、顺序保持逐字不变
- if/elif 的**匹配顺序**是语义的一部分(`building` 分支在最后),注册表必须保序
验收parity 全绿 —— 这期风险最高,逐要素分次提交,每次单独跑 parity。
### P3 — 材质契约化
- `catalog.py` 的材质规格扩展出 cesium 段:`tint` / `metallic` / `base_color` / `emission`
- `generate_scene.py` 把规格写进材质自定义属性 `material["cesium_export"] = json.dumps(spec)`
- `export_cesium.py` 优先读自定义属性;读不到时回落到现有四张名字表(**原样保留,含 D1 死条目**),保证旧 `.blend` 仍能导出且 parity 成立
- `Tree Crown` 的程序化贴图特例保持不变
验收parity 全绿;另外用重构前生成的旧 `.blend` 跑一次导出,确认回落路径可用。
## 不在本轮范围
- 输出目标可插拔(整场景 / 每要素单独 GLB——原 P4
- `build-area.js` 里 390 行内联 HTML 与手写 glTF 的拆分——原 P4
- 上表 D1D3 的修复