Files
osmWorkflow/.trellis/spec/blender/module-structure.md
2026-08-03 18:15:22 +08:00

307 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# osmassets 模块结构
> 适用:在 `blender/` 下新增或移动代码。
> 核心是一条**按依赖切的边界**——切错了,整个测试套件就跑不起来。
---
## 按依赖分包,不按功能
`osmassets/__init__.py:3-12` 写明了这个包的切分原则:
> The package is split by dependency, not by feature:
> `osm` and `geom` are pure Python. They import no `bpy` and can be run and
> tested with a plain interpreter. Everything else may touch `bpy` and only
> runs inside Blender.
```
┌─ 纯 Python 层(无 bpy可用系统 python 直接跑和测)
│ osmassets/osm.py OSM XML 解析 + 局部米制投影
│ osmassets/geom.py 平面几何:裁剪、采样、面积、点在多边形内
│ osmassets/catalog.py 图层与材质的声明(只有 json/os无 bpy
└─ bpy 层(只能在 Blender 内运行)
osmassets/mesh.py MeshBatch、prism、polyline
osmassets/materials.py 材质构建
osmassets/tree.py 树实例化
osmassets/features.py 要素分发注册
osmassets/water.py grass.py scrub.py
osmassets/building.py fountain.py roads.py 要素装配
generate_scene.py export_cesium.py 两个入口脚本
tools/scene_digest.py 结构摘要工具
```
**这条线是几何可测试的唯一前提。** 拆分之前,验证 `clip_polygon``sample_tree_row`
的唯一办法是渲染整片区域然后看图(`__init__.py:9-12``test_pure.py:5-8`)。
> **在纯 Python 模块里写 `import bpy` 会静默废掉 51 个单元测试**——它们不会失败,
> 而是 import 阶段就崩,看起来像环境问题。
`catalog.py` 属于纯 Python 层是刻意的:它只声明"是什么",让任何不启动 Blender 的
工具也能读到场景的材质定义(`materials.py:3-7`)。
---
## 各模块职责
| 模块 | 层 | 职责 |
|---|---|---|
| `osm.py` | 纯 | `parse_osm()` 读 OSM XML → (bounds, ways, points)building multipolygon relation 会作为 synthetic way 进入 ways`Projector` 局部米制投影;`parse_height()``tags()` |
| `geom.py` | 纯 | 平面几何全家桶。**输入输出一律是投影后的米**,例外只有 `geometry_rings` / `feature_in_bounds`(收原始 GeoJSON 的经纬度) |
| `catalog.py` | 纯 | `ROAD_LAYERS``MATERIALS``road_material_specs()``check_layers()` |
| `mesh.py` | bpy | `MeshBatch``make_prism``add_roof``add_wall_panel``add_polyline`、集合管理 |
| `materials.py` | bpy | 把 `catalog` 的规格变成真实材质:`from_spec()`、贴图、程序化噪声、tint、alpha-clip |
| `features.py` | bpy | `FeatureHandler``dispatch_ways()`,保持 OSM way 要素 first-match 分发顺序 |
| `tree.py` | bpy | 两个 vendored 模型 → 一套可实例化的运行时形状 |
| `water.py` / `grass.py` / `scrub.py` | bpy | 单一 OSM 面要素的装配 |
| `building.py` | bpy | 单一 OSM 面要素 `building=*` 的装配 |
| `fountain.py` | bpy | 单一 OSM 点要素 `amenity=fountain` 的装配 |
| `roads.py` | bpy | osm2streets GeoJSON road layer 装配与 OSM highway 折线回退 |
### `geom.py` 的两条隐含约定
- **环是 `(x, y)` 元组的列表**。重复的闭合点"到处容忍但从不要求"
`geom.py:8-9`)——新函数要维持这个宽容度
- **单位是米**,除非函数名另有说明
---
## 要素模块的统一形状
`water.py` / `grass.py` / `scrub.py` 三个面要素模块签名一致:
```python
def assemble(ring, [way_id,] scene_xmin, scene_xmax, scene_ymin, scene_ymax,
<颜色/材质...>, [回调...]):
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
if len(ring) < 3:
return 0[, ...]
...
return <计数>[, <焦点用的点集>]
```
四条约定:
1. **自己裁剪**。收边界参数而不是收已裁剪的环,不假设调用方做过
(三个 `assemble` 的第一行都是 `clip_polygon`
2. **退化输入返回零,不抛异常**`len(ring) < 3` 直接返回计数 0
3. **返回计数**供调用方汇总统计;需要参与相机取景的还返回点集(`grass.py` / `scrub.py`
`focus`
4. **不自己找数据**。ring 由 `generate_scene.py` 传入,模块只负责装配
`fountain.py` 是点要素模块,不收 ring 或裁剪边界:
```python
def assemble(name, x, y, collection, materials):
...
```
`generate_scene.py` 仍负责过滤 `amenity=fountain`、检查点是否在范围内、投影坐标、
创建三张 fountain 材质,并维护 `counts["fountain_count"]`。模块只负责在已投影
坐标上创建 basin / water / pedestal / spray objects保持对象名和自定义属性不变。
`building.py` 是面要素模块,但不裁剪 ring入口在调用前已经过滤
`len(ring) >= 3`,并且建筑要保持原始 OSM footprint 参与相机取景:
```python
def assemble(ring, way_id, tag, office_overrides, collection, materials,
inner_rings=None):
...
return added, industrial_added, ring_pts
```
`generate_scene.py` 仍负责解析 `--office-overrides`、创建六张 building 材质、
分发 `building=*` way、累加 `counts["building_count"]` /
`counts["industrial_count"]`,以及写 `scene["office_override_way_ids"]`
模块只负责 prism / roof / windows / bevel / building custom properties。
### Building multipolygon 与 height
`parse_osm()` 会把 `type=multipolygon` 且 relation 自身带 `building=*` 的 relation
追加为 synthetic way
```python
{
"id": relation_id,
"coords": outer_ring_lon_lat,
"inner_coords": [inner_ring_lon_lat, ...],
"tags": relation_tags,
"source": "relation",
}
```
member way 不需要带 building tagsrelation-level `height` / `building` 才是事实源。
`generate_scene.py``handle_building()` 里把 `inner_coords` 投影后传给
`building.assemble(..., inner_rings=...)``building.py` 用 Blender
`tessellate_polygon()` 生成带洞的 prism 和 roof没有 inner rings 时仍走原来的
`MeshBatch` / `make_prism` 路径。
建筑高度语义:
- relation / way 上有合法显式 `height``render_height == height`
- `building=industrial`:沿用 OSM height / 默认高度
- 普通 building 没有显式 height 且低于 tall threshold使用办公楼视觉默认 `11.4m`
不要再把有显式 `height=8` 的普通建筑压成 `11.4m`;用户手工编辑 OSM 高度时OSM
tag 是更高优先级的事实。
`roads.py` 是 road layer 装配模块,不拥有图层表或计数:
```python
def assemble_geojson_layer(path, layer_id, projector, collection, material, z): ...
def assemble_osm_fallback(ways, projector, collection, material): ...
```
`generate_scene.py` 仍负责创建 `road_mats`、调用 `catalog.check_layers()` 并打印
`Layer catalog warning:`、遍历 `catalog.ROAD_LAYERS`、维护 `road_counts`,以及写
`scene["road_feature_counts"]` / `SCENE_DONE["road_features"]`。模块只负责把已选定的
GeoJSON layer 或 OSM fallback ways 变成 `Road_<layer_id>` / `OSM_Road_<way_id>` 对象。
### OSM way 要素注册
`features.py` 只负责分发机制,不拥有 feature state
```python
FeatureHandler = namedtuple("FeatureHandler", ("name", "matches", "handle"))
dispatch_ways(ways, projector, handlers)
```
`dispatch_ways()` 的契约:
- 对每个 way 先执行 `any(projector.inside(c) for c in way["coords"])`,不在范围内就跳过
- 只投影一次 `ring = projector.ring(way["coords"])`
- 按传入的 `handlers` 顺序检查
- 第一个 `matches(way, tag, ring)` 为真的 handler 执行 `handle(way, tag, ring)` 后停止
当前注册顺序必须等同旧 `if` / `elif` 链:
```text
water -> grass -> scrub -> tree_row -> building
```
这只是保守 registry不是 ownership 反转。`generate_scene.py` 仍负责 collection /
material 创建顺序、`counts``focus_points``tree_rows``scrub_trees`、scene metadata
`SCENE_DONE`。不要把这些状态藏进 `features.py` 的全局变量里。
### 加一种新 OSM 要素
目标形态:**新增一个模块 + 在 OSM way handler 表里注册一项,不改主分发循环**。
1. 新建 `osmassets/<feature>.py`,写 `assemble(...)`,签名照抄上面
2. 只 import 需要的:`from osmassets.geom import clip_polygon`
`from osmassets.mesh import MeshBatch`
3. 材质规格加进 `catalog.MATERIALS`**追加到末尾**,顺序决定 GLB 材质索引)
4. `generate_scene.py` 的 OSM way handler 注册表里追加 handler保持顺序语义明确
5. 纯几何部分若有新函数,放 `geom.py` 并**补 `blender/tests/test_pure.py`**
---
## 两个入口脚本
| | `generate_scene.py` (895行) | `export_cesium.py` (647行) |
|---|---|---|
| 调用 | `--background --factory-startup --python` | `--background --python` |
| 输入 | `--osm` + `--geojson` | `--blend` |
| 输出 | `--output`(.blend) + `--render`(.png) | `--glb` + `--metadata`(.json) |
| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` |
**完成标记是 parity 契约的一部分**`parity.js:121` 解析它们),改动打印格式等于改动
契约。
### `sys.path` 那段样板不能删
两个脚本开头都有(`generate_scene.py:30-35``export_cesium.py:19-24`
```python
# --factory-startup does not put the script's own directory on sys.path, so the
# osmassets package next to this file is not importable without this.
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
```
因此其后的 import 全部带 `# noqa: E402`。这不是可以"整理"掉的坏味道。
### CLI 参数解析
两个脚本各有一份 `cli_args()`,都从 `--` 之后取参数:
```python
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
```
`export_cesium.py:118-124` 对三个必填项逐个 `raise RuntimeError`。**必填项显式抛错,
不要给静默默认值**——Blender 子进程里一个错的默认路径会写到意想不到的地方。
---
## 材质名对接:新场景靠自定义属性,旧场景靠回退表
`export_cesium.py` **不 import `catalog`**,这是刻意边界:导出器消费 `.blend`
里保存的材质事实,而不是用当前源码里的 catalog 按材质名反查。
新生成场景的数据流是:
```
catalog.MATERIALS[*]["cesium"] # 纯 Python 数据,无 bpy
materials.from_spec() # 写 material["cesium_export"] JSON 字符串
<area>.blend # 契约随场景文件保存
export_cesium.py # 优先读 material custom property
```
`cesium` 子契约可携带:
| 字段 | 作用 |
|---|---|
| `tint` | diffuse 贴图导出前往目标颜色混合 |
| `metallic` | 覆盖导出 PBR metallic |
| `base_color` | 直接覆盖导出材质 base color并禁用 diffuse/normal 图 |
| `emission` | 设置导出材质自发光 |
为了兼容旧 `.blend``export_cesium.py` 仍保留四张以**材质名字符串**为键的回退表:
| 表 | 位置 |
|---|---|
| `EXPORT_TINTS` | `export_cesium.py` |
| `EXPORT_METALLIC_OVERRIDES` | `export_cesium.py` |
| `EXPORT_BASE_COLOR_OVERRIDES` | `export_cesium.py` |
| `EXPORT_EMISSION_OVERRIDES` | `export_cesium.py` |
约束:
- **新材质的 Cesium 调色写在 `catalog.MATERIALS[*]["cesium"]`,不要只加到四张回退表**。
否则新生成的 `.blend` 不会自带契约
- `catalog.py` 仍属纯 Python 层,不能 import `bpy`;序列化发生在 `materials.py`
- `"Office White Metal Facade"` 在四张表里都有,但 `catalog` 里**已无此材质**
(历史上记为缺陷 D1。它只能作为旧 `.blend` 回退兼容存在,
不要迁回新契约源
**改材质名时**`catalog.MATERIALS` + `catalog.ROAD_LAYERS` + 四张旧回退表全部
grep 一遍;新生成场景靠 `cesium_export` 属性,旧场景仍靠回退表。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 在 `osm.py` / `geom.py` / `catalog.py``import bpy` | 51 个单元测试整体崩,且像环境问题 |
| 按功能而非依赖新建模块(把几何和 bpy 混在一起) | 该几何从此不可测 |
| 删掉 `sys.path.insert` 样板或 `# noqa: E402` | Blender 里 import 不到 osmassets |
| 要素模块假设 ring 已裁剪 | 越界几何进场景 |
| 要素模块对退化输入抛异常 | 一个坏多边形中断整片区域 |
| 改材质名只改一处 | 旧 `.blend` 的 Cesium 回退调色可能静默失效 |
| 只改四张旧回退表,不改 `MATERIALS[*]["cesium"]` | 新 `.blend` 不会携带 Cesium 导出契约 |
---
## 相关
- [资产生成](./asset-generation.md)`MeshBatch`、材质、实例化
- [测试](./testing.md):纯 Python 层怎么测
- [图层表](../pipeline/layer-registry.md)`catalog.ROAD_LAYERS` 与 JS 侧的对账
- [产物一致性指南](../guides/artifact-parity-guide.md)bpy 层的回归靠它兜底