13 KiB
osmassets 模块结构
适用:在
blender/下新增或移动代码。 核心是一条按依赖切的边界——切错了,整个测试套件就跑不起来。
按依赖分包,不按功能
osmassets/__init__.py:3-12 写明了这个包的切分原则:
The package is split by dependency, not by feature:
osmandgeomare pure Python. They import nobpyand can be run and tested with a plain interpreter. Everything else may touchbpyand 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 三个面要素模块签名一致:
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 <计数>[, <焦点用的点集>]
四条约定:
- 自己裁剪。收边界参数而不是收已裁剪的环,不假设调用方做过
(三个
assemble的第一行都是clip_polygon) - 退化输入返回零,不抛异常。
len(ring) < 3直接返回计数 0 - 返回计数供调用方汇总统计;需要参与相机取景的还返回点集(
grass.py/scrub.py的focus) - 不自己找数据。ring 由
generate_scene.py传入,模块只负责装配
fountain.py 是点要素模块,不收 ring 或裁剪边界:
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 参与相机取景:
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:
{
"id": relation_id,
"coords": outer_ring_lon_lat,
"inner_coords": [inner_ring_lon_lat, ...],
"tags": relation_tags,
"source": "relation",
}
member way 不需要带 building tags;relation-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 装配模块,不拥有图层表或计数:
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:
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 链:
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 表里注册一项,不改主分发循环。
- 新建
osmassets/<feature>.py,写assemble(...),签名照抄上面 - 只 import 需要的:
from osmassets.geom import clip_polygon、from osmassets.mesh import MeshBatch - 材质规格加进
catalog.MATERIALS(追加到末尾,顺序决定 GLB 材质索引) generate_scene.py的 OSM way handler 注册表里追加 handler,保持顺序语义明确- 纯几何部分若有新函数,放
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):
# --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(),都从 -- 之后取参数:
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 层,不能 importbpy;序列化发生在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 导出契约 |