# 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/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);`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 | | `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): ... 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。 `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_` / `OSM_Road_` 对象。 ### 加一种新 OSM 要素 目标形态:**新增一个模块 + 注册一行,不改 `build()`**。 1. 新建 `osmassets/.py`,写 `assemble(...)`,签名照抄上面 2. 只 import 需要的:`from osmassets.geom import clip_polygon`、 `from osmassets.mesh import MeshBatch` 3. 材质规格加进 `catalog.MATERIALS`(**追加到末尾**,顺序决定 GLB 材质索引) 4. `generate_scene.py` 的要素分发处加一行调用 5. 纯几何部分若有新函数,放 `geom.py` 并**补 `blender/tests/test_pure.py`** --- ## 两个入口脚本 | | `generate_scene.py` (899行) | `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 字符串 ↓ .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 层的回归靠它兜底