Initialize Trellis project guidelines

This commit is contained in:
2026-08-03 10:56:21 +08:00
parent 7f4ebe8fb7
commit 4c5981c555
166 changed files with 28564 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
# 资产生成
> 适用:改动 Blender 侧的几何构建、材质、实例化,或往场景里加新资产。
> 贯穿全篇的约束是两条:**压低对象数**GLB 要能在浏览器里跑)和
> **构建必须确定性**parity 校验的前提)。
---
## MeshBatch几何构建的主力
`mesh.py:1-9` 说明了它为什么存在:场景的绝大部分是平面多边形和拉伸棱柱,
**把它们批进一个 mesh datablock 能同时压低 Blender 对象数和导出 glTF 的节点数**
用法固定为「累积 → 一次 `finish()`」:
```python
batch = MeshBatch("Lake Surface", water_c, water_mat)
batch.add_polygon(ring, 0.10)
batch.finish() # water.py:12-14
```
### 三条内建行为
1. **自动去掉重复的闭合点**`mesh.py:41-42, 55-56`)。传闭合环或开放环都行,
`geom.py` 的宽容度一致
2. **退化输入静默返回**`len(ring) < 3` 直接 return不抛
3. **空批次 `finish()` 返回 `None`**`mesh.py:66-67`),不产生空对象
### 名字前缀是承重的
```python
# Foliage reads as blobby volume, so it wants smooth normals; the built
# environment wants its facets. The name prefix is the discriminator.
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
for polygon in mesh.polygons:
polygon.use_smooth = True # mesh.py:72-77
```
**改植被对象的命名前缀会静默改变着色**。加新的植被类资产时要么沿用
`Tree_` / `Scrub_` 前缀,要么显式扩展这个判断。
### 单材质约束
一个 `MeshBatch` 只挂一个材质(`mesh.py:64`)。需要多材质就开多个 batch——
这也正是[材质顺序决定 GLB 索引](../pipeline/layer-registry.md#顺序是承重的)的地方。
### 便捷包装
`make_prism` / `add_roof``mesh.py:83, 89`)是「单个形体」的一次性包装,内部就是
`MeshBatch` + `finish()`。只放一个形体时用它们,批量累积时直接用 `MeshBatch`
---
## 材质:声明与构建分离
```
catalog.MATERIALS 声明「是什么」 纯 Python无 bpy
▼ materials.from_spec(spec) materials.py:198
真实的 bpy.types.Material 只在 Blender 内
```
这个拆分让 catalog 能被任何不启动 Blender 的工具读取(`materials.py:3-7`)。
### `kind` 选构建器
| `kind` | 走哪条路 | 必填字段 |
|---|---|---|
| `solid` | `make_material()` | `name``color` |
| `textured` | `make_textured_material()` | `name``diffuse``normal``scale` |
`solid` 可以再叠 `procedural`(噪声驱动的基色和凹凸,`from_spec` 里判断)。
可选字段一律 `spec.get(key, 默认值)`——**加新的可选字段不要改已有条目**。
### 加一种材质
1. `catalog.MATERIALS` **末尾追加**一个条目(顺序决定 GLB 材质索引)
2.`kind``textured`,贴图放 `assets/textures/``materials.py:14`
`TEXTURE_ROOT`
3. `generate_scene.py` 里用 `material_from_spec(catalog.MATERIALS["<key>"])`
4. **若这个材质在 Cesium 里需要调色,去 `export_cesium.py` 的四张表加**——
见下文,那边按材质名字符串匹配
### 颜色是线性 RGB
`catalog` 里的 `color` 是 Blender 的线性值,**不是** sRGB hex也**不**从
`scripts/lib/scene-layers.js` 换算。两套配色独立调过,理由见
[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步)。
---
## 实例化:树
`tree.py:1-8` 的模式——**import 一次 → bake 朝向 → 每棵树只 link 一个轻对象复用
同一个 datablock**
> Nothing is duplicated per tree, so the .blend and the exported GLB carry each
> mesh and each texture exactly once no matter how many trees are planted.
`TreeVariant``(meshes, height, base_z)` 三元组(`tree.py:62-65`
- `height` — 变体自身的高度,目标高度除以它得到缩放系数
- `base_z` — 变体自身的地面线,**取负乘以缩放**就能把树干落到 `z=0`
不管源文件把原点放在哪(`tree.py:301-303`
`assemble()` 返回种植数量,**0 表示模型缺失或 style 未知**,调用方据此回退到程序化
树(`tree.py:277-279`)。
### 材质在本地重建,不沿用源文件
`tree.py:12-16`:两个 vendored 模型的材质都不能直接用。apple 的贴图 57% 是透明的
(那是叶片卡),没有 alpha-clip 设置的话整个树冠会渲染成一块。
**vendored 资产的材质一律重建**,不要 `append` 源文件的材质。
### 已删除的第三种 style 有记录
`tree.py:18-25` 记着 `polyhaven` style 被删的原因LOD1 对象不是整棵树,是给几何节点
散布用的树枝和叶簇,直接种出来是一地树枝,连同 78MB 资产一起删了)。
**这类"试过、不行、为什么"的记录要保留。** 删掉它,下一个人会重新引入同一个资产。
---
## 确定性:用无理数周期代替 RNG
这是全仓最容易被无意破坏的约定。`tree.py:290-292`
```python
# Irrational periods stand in for an RNG: no repeat over any realistic
# tree count, and a pure function of the index, so rebuilding an area
# plants the identical forest.
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
```
黄金角 `GOLDEN_TURN``tree.py:47-49`)让相邻的树朝向永不重复也永不成规律——
一排树看起来像种的,不像盖章盖的。
**规则**:需要"随机"外观时,用 `index` 的纯函数(无理数周期 / 黄金角),
或者收一个显式 `seed``geom.sample_polygon_interior` 就是这么做的)。
**绝不要**用无种子的 `random` 或任何时间相关的量——
重建同一片区域必须得到逐字节相同的结构,否则
[parity 校验](../guides/artifact-parity-guide.md)永久性地红。
---
## Cesium 导出:一层独立的调色
`export_cesium.py:1-9`:创作用的场景刻意使用了一些 Blender 专有节点
(草地 tint、程序化树冠变化而 glTF 的材质词汇小得多。所以导出器
**新建临时的、仅供导出的 PBR 材质**,展 UV把引用到的图片全部内嵌进 GLB。
导出材质带 `EXPORT_PREFIX = "Cesium "` 前缀(`export_cesium.py:30`
这样第二遍扫到实例化网格的共享材质槽时能认出自己的产物、跳过不重复处理。
模型保持在**局部 ENU 坐标系**X 东、Y 北、Z 上),靠伴生 JSON 配合
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆放。
### 四张覆盖表(按材质名字符串)
| 表 | 位置 | 作用 |
|---|---|---|
| `EXPORT_TINTS` | `:68` | 往某个颜色混合 |
| `EXPORT_METALLIC_OVERRIDES` | `:80` | 平铺的金属度覆盖 |
| `EXPORT_BASE_COLOR_OVERRIDES` | `:86` | 直接替换基色 |
| `EXPORT_EMISSION_OVERRIDES` | `:95` | 自发光兜底 |
⚠️ `export_cesium.py` **不 import `catalog`**,靠材质名字符串匹配。
`catalog.CESIUM_EXPORT` 是**死代码**。改材质名前先读
[模块结构](./module-structure.md#已知现状两个入口靠材质名字符串对接)。
### 为什么新资产总是"发黑"
`export_cesium.py:38-54` 记录了这个反复出现的问题:
> Cesium 的默认光照偏白,**场景里每一个材质都被手工提亮过**——草往亮绿混 72%、
> 带肋墙面往白混 86%、建筑自发光 0.18。一个没调过的新资产是唯一如实渲染的东西,
> 放在旁边就显得发黑。
所以**加新资产时,"它在 Blender 里看着对"不代表在 Cesium 里对**,必须去四张表里
给它配一份调校。
抠图植被走的是另一套(`FOLIAGE_ALBEDO_GAIN = 2.1` + `FOLIAGE_SATURATION = 1.75`
`:55, 66`),用**增益**而不是 tint——因为那是一张同时装着叶片、树皮、果实的图集
往绿色混会把树干也染绿。增益保留色相关系,只把整体曝光抬到和邻居一致。
`FOLIAGE_EMISSION = 0.25` 的职责只是给背光面兜底,**不是主要提亮手段**
`:32-36`)。想让植被更亮就调增益,别调自发光。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 每个形体建一个对象而不用 `MeshBatch` | 对象数与 glTF 节点数爆炸 |
| 改 `Tree_` / `Scrub_` 命名前缀 | 平滑着色静默失效 |
| 用无种子 `random` 或时间量做抖动 | parity 校验永久红 |
| 每棵树复制一份 mesh/贴图 | .blend 与 GLB 体积按棵数线性膨胀 |
| 直接 append vendored 资产的材质 | alpha-clip 缺失,树冠渲染成一块 |
| 删掉"试过不行"的注释 | 下一个人重新踩同一个坑 |
| 从 `scene-layers.js` 的 hex 换算 Blender 颜色 | 抹掉独立调过的配色 |
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 |
---
## 相关
- [模块结构](./module-structure.md):往哪儿放新代码
- [测试](./testing.md):纯几何部分怎么测
- [图层表](../pipeline/layer-registry.md):道路九层的材质从哪来
- [产物一致性指南](../guides/artifact-parity-guide.md):改完怎么验证产物没变

View File

@@ -0,0 +1,146 @@
# BlenderPython 场景生成层
> 覆盖 `blender/**/*.py`。
> 运行时:**两个**——Blender 内嵌 Pythonbpy 层)和系统 Python纯 Python 层)。
> 这条内部边界是本层最重要的结构约束。
---
## 先读哪一篇
| 你要做的事 | 读 |
|---|---|
| 新建模块、挪代码、加一种 OSM 要素 | [模块结构](./module-structure.md) ← **先确认放在哪一层** |
| 改几何构建、材质、实例化、Cesium 调色 | [资产生成](./asset-generation.md) |
| 改 `geom.py` / `osm.py` 或加纯函数 | [测试](./testing.md) |
| 改材质名、动 `MATERIALS` 顺序 | [模块结构 · 材质名对接](./module-structure.md#已知现状两个入口靠材质名字符串对接) |
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |
---
## 依赖分层
```
┌─────────────────────────────────────────────────────────┐
│ 纯 Python 层 —— 无 bpy系统 python 可跑可测 │
│ │
│ osmassets/osm.py OSM XML 解析 + 局部米制投影 │
│ osmassets/geom.py 平面几何(米制) │
│ osmassets/catalog.py 图层与材质的声明 │
│ │
│ ▲ blender/tests/test_pure.py 覆盖这一层51 个用例) │
└─────────────────────────────────────────────────────────┘
▲ 只能单向依赖
┌─────────────────────────────────────────────────────────┐
│ bpy 层 —— 只在 Blender 内运行,无单元测试 │
│ │
│ osmassets/mesh.py MeshBatch 等几何构建 │
│ osmassets/materials.py 材质构建(消费 catalog 的声明) │
│ osmassets/tree.py 树实例化 │
│ osmassets/water.py grass.py scrub.py 要素装配 │
│ │
│ generate_scene.py export_cesium.py 两个入口 │
│ tools/scene_digest.py 结构摘要工具 │
│ │
│ ▲ 回归防线是 parity 校验,不是单元测试 │
└─────────────────────────────────────────────────────────┘
```
**在纯 Python 层里 `import bpy` 会静默废掉整个测试套件**——它不会失败,
而是 import 阶段就崩,看起来像环境问题。
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`,放进 `geom.py`
就立刻获得被测试覆盖的资格。
---
## 两个入口
| | `generate_scene.py` | `export_cesium.py` |
|---|---|---|
| 行数 | 999 | 624 |
| 调用 | `--background --factory-startup --python` | `--background --python` |
| 输入 | `--osm` + `--geojson`(可选) | `--blend` |
| 输出 | `--output`(.blend)、`--render`(.png) | `--glb``--metadata`(.json) |
| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` |
| 由谁调起 | `build-area.js``blender` 阶段 | `build-area.js``cesium` 阶段 |
两个 stdout 标记是 [parity 契约](../guides/artifact-parity-guide.md)的一部分
`scripts/parity.js:121` 解析它们),**改动打印格式等于改动契约**。
### `--factory-startup` 只在 generate 阶段用
它屏蔽本机 Blender 的 preferences 和 addon保证场景生成不受用户配置影响。
副作用是脚本自己的目录不在 `sys.path` 上,所以两个入口开头都有那段
`sys.path.insert` 样板 + `# noqa: E402`——**不是可以整理掉的坏味道**。
---
## 场景构建的输入约定
`generate_scene.py:9-12` 记录了一个容易踩的坑:
> **范围只认 OSM 的 `bounds` 元素**不用全部节点算包围盒。OSM 导出可能带上
> 请求范围之外的 relation 成员,用全部节点会得到一个大得离谱的模型。
`--geojson` 是可选的。给了就用 osm2streets 的精细道路面、人行道、车道标线、
斑马线;不给则回退到简单的 OSM `highway` 折线(`generate_scene.py:14-16`
回退逻辑在 `:849`)。
植被映射(`generate_scene.py:18-20`
| OSM 标签 | 产物 |
|---|---|
| `natural=tree`(节点) | 单棵树 |
| `natural=tree_row`way | 等距成排的树 |
| `landuse=grass` | 绿地 + 可选草簇散布 |
| `natural=scrub` | 低矮灌木覆盖 |
| `amenity=fountain` | 低模喷泉水池 |
---
## 三条贯穿全层的约定
1. **构建必须确定性**
`index` 的纯函数(无理数周期 / 黄金角)或显式 `seed` 代替 RNG
绝不用无种子 `random` 或时间量。重建同一片区域必须得到相同结构,
否则 parity 校验永久红。见[资产生成](./asset-generation.md#确定性用无理数周期代替-rng)。
2. **压低对象数**
`MeshBatch` 批处理 + 树实例化共享 datablock。GLB 要在浏览器里跑,
节点数和贴图数都是硬成本。
3. **退化输入返回空,不抛异常**
一个坏多边形不该中断整片区域的构建。要素模块 `len(ring) < 3` 直接返回 0
`MeshBatch` 静默 return`geom.py` 的函数返回 `[]`
---
## 文件速查
| 文件 | 行数 | 层 |
|---|---|---|
| `generate_scene.py` | 999 | bpy · 入口 |
| `export_cesium.py` | 624 | bpy · 入口 |
| `osmassets/tree.py` | 318 | bpy |
| `osmassets/geom.py` | 241 | 纯 |
| `osmassets/materials.py` | 218 | bpy |
| `osmassets/catalog.py` | 195 | 纯 |
| `osmassets/mesh.py` | 126 | bpy |
| `osmassets/osm.py` | 89 | 纯 |
| `osmassets/water.py` / `grass.py` / `scrub.py` | 15 / 22 / 13 | bpy |
| `tools/scene_digest.py` | 171 | bpy · 工具 |
| `tests/test_pure.py` | 372 | 纯 · 测试 |
`blender/README.md` 是面向使用者的运行说明,与本目录互补——**用法写那边,
改法写这边**。
---
## 技术选型现状
- **Blender 4.x**`bpy` + `mathutils``export_cesium.py` 额外用 `numpy`Blender 自带)
- **纯 Python 层只用标准库**`math``json``os``xml.etree`
这是它能用系统 python 跑的前提——**不要给它加第三方依赖**
- **无类型标注、无 lint 配置**。保持现状;引入工具链是独立决定
- **`unittest` 而非 pytest**,零依赖跑得起来

View File

@@ -0,0 +1,185 @@
# 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 要素装配
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 要素的装配 |
### `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` 传入,模块只负责装配
### 加一种新 OSM 要素
目标形态(`docs/refactor-plan.md`**新增一个模块 + 注册一行,不改 `build()`**。
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` 的要素分发处加一行调用
5. 纯几何部分若有新函数,放 `geom.py` 并**补 `blender/tests/test_pure.py`**
---
## 两个入口脚本
| | `generate_scene.py` (999行) | `export_cesium.py` (624行) |
|---|---|---|
| 调用 | `--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`**(它只 import
`from osmassets.materials import link_alpha_clip`)。它自己维护四张以**材质名字符串**
为键的覆盖表:
| 表 | 位置 |
|---|---|
| `EXPORT_TINTS` | `export_cesium.py:68` |
| `EXPORT_METALLIC_OVERRIDES` | `:80` |
| `EXPORT_BASE_COLOR_OVERRIDES` | `:86` |
| `EXPORT_EMISSION_OVERRIDES` | `:95` |
后果:
- **改 `catalog.MATERIALS` 里的 `name` 会静默断开这些覆盖**。没有任何校验,
材质只是悄悄退回未调过的样子
- `catalog.CESIUM_EXPORT``catalog.py:142`**是死代码**——定义了但全仓无人引用。
它是一次未完成的迁移,不要以为改它会生效
- `"Office White Metal Facade"` 在四张表里都有,但 `catalog` 里**已无此材质**
`docs/refactor-plan.md` 记为缺陷 D1本轮只记录不修
**改材质名时**:四张表 + `catalog.MATERIALS` + `catalog.ROAD_LAYERS` 全部 grep 一遍。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 在 `osm.py` / `geom.py` / `catalog.py``import bpy` | 51 个单元测试整体崩,且像环境问题 |
| 按功能而非依赖新建模块(把几何和 bpy 混在一起) | 该几何从此不可测 |
| 删掉 `sys.path.insert` 样板或 `# noqa: E402` | Blender 里 import 不到 osmassets |
| 要素模块假设 ring 已裁剪 | 越界几何进场景 |
| 要素模块对退化输入抛异常 | 一个坏多边形中断整片区域 |
| 改材质名只改一处 | Cesium 侧调色静默失效 |
| 以为改 `catalog.CESIUM_EXPORT` 会影响导出 | 它是死代码 |
---
## 相关
- [资产生成](./asset-generation.md)`MeshBatch`、材质、实例化
- [测试](./testing.md):纯 Python 层怎么测
- [图层表](../pipeline/layer-registry.md)`catalog.ROAD_LAYERS` 与 JS 侧的对账
- [产物一致性指南](../guides/artifact-parity-guide.md)bpy 层的回归靠它兜底

View File

@@ -0,0 +1,180 @@
# 测试
> 适用:改动 `osmassets/osm.py`、`osmassets/geom.py`,或往里加新的纯函数。
---
## 怎么跑
```bash
python3 -m unittest discover blender/tests
```
**不需要 Blender**,用系统 Python 就行。当前 51 个用例,运行约 0.01 秒。
这是全仓唯一的自动化测试。快到没有理由不在每次改动后跑一遍。
能这么跑的前提是 `osmassets` 的[依赖分层](./module-structure.md)——
`test_pure.py:19` 手动把 `blender/` 塞进 `sys.path`,然后只 import 纯 Python 模块。
---
## 最重要的一条:期望值必须从几何推导
`test_pure.py:9-11` 写得很直白:
> The expected values are derived from the geometry, not captured from the
> implementation — **a test that just records current output would ratify a bug.**
具体做法是在测试里写清楚**为什么**是这个数:
```python
def test_half_outside_polygon_is_cut_at_the_boundary(self):
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
# A 10x10 square clipped to half its width is a 5x10 rectangle.
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
```
反例——**不要这样写**
```python
def test_clip(self):
# 跑一遍把输出粘过来
self.assertEqual(clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0),
[(0.0, 0.0), (5.0, 0.0), (5.0, 10.0), (0.0, 10.0)])
```
这种测试在实现正确时和实现错误时**同样会通过**。它固化的是当前行为,不是需求。
**自检**:把被测的那个特性从实现里删掉,测试还能过吗?能过就是无效测试。
---
## 每个几何函数都要覆盖退化输入
这是本套测试最系统的部分。`geom.py` 的函数会收到真实 OSM 数据里的各种畸形几何,
所以每个函数都有一个 `test_degenerate_input`
| 退化情形 | 例子 |
|---|---|
| 空输入 | `clip_polygon([], ...)``[]` (`:73`) |
| 点数不足成面 | `clip_polygon([(0,0),(1,1)], ...)``[]` (`:74`) |
| 完全在裁剪框外 | `clip_polygon(SQUARE, 20,30,20,30)``[]` (`:70`) |
| 零长线段 | `sample_tree_row` 跳过而非除零 (`:203`) |
| 重复顶点 | `distance_to_ring` 不除零 (`:178`) |
| 参数为零 | `sample_ring_boundary(SQUARE, spacing=0.0)``[]` (`:123`) |
| 单点输入 | `sample_polygon_interior([(0,0)], ...)``[]` (`:146`) |
**加新几何函数就配一个 `test_degenerate_input`。** 约定是"返回空/零"而不是抛异常——
一个坏多边形不该中断整片区域的构建。
### 除零守卫要指名道姓
覆盖某个具体守卫时,注释写清楚针对哪一行:
```python
def test_axis_aligned_edge_does_not_divide_by_zero(self):
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
# guard in the intersection lambdas.
```
这样守卫被误删时,失败的测试能直接说明它保护的是什么(`test_pure.py:76-78`)。
---
## 其余几条约定
### 随机采样必须验 seed 可复现
```python
def test_seed_is_deterministic(self):
first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
self.assertEqual(first, second)
```
不可复现的采样会让 [parity 校验](../guides/artifact-parity-guide.md)永久性地红。
任何带随机的新函数都要收 `seed` 参数并配这个测试(`test_pure.py:135-138`)。
### 绕向无关的性质要两个方向都测
`sample_ring_boundary``inset` 对顺时针和逆时针都必须往内缩:
```python
ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0)
cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0)
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw))
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw))
```
同理 `polygon_area` 对绕向不敏感、`signed_polygon_area` 敏感,两者分别验
`test_pure.py:88-91, 109-115`)。
### 跨边界的连续性要单独测
真实几何常见的坑是"分段处理时每段各自重新开始"
```python
def test_spacing_carries_across_segment_joins(self):
# Two 3m segments with 4m spacing: the second sample must land 1m into
# the second segment, not restart at its origin.
```
`test_pure.py:190-196`
### 解析器的容错语义要写死
`parse_osm` 的两档行为必须都有测试(`test_pure.py:343-356`
- **坏节点跳过,不致命**`lon='oops'` 的节点被忽略,其余照常解析
- **缺 `bounds` 直接抛 `RuntimeError`**:没有 bounds 就无法建立投影,继续下去毫无意义
分档理由见 `generate_scene.py:9-12`OSM 导出可能带上区域外的 relation 成员,
所以范围只认 `bounds` 元素,不用全部节点算包围盒。
### 用真实格式的 fixture
`OSM_SAMPLE``test_pure.py:285`)是一段真的 OSM XML故意塞进了坏节点、
`action='delete'` 的 way、引用了不存在节点的 way。写进临时文件再解析
`tearDown``os.unlink`
**别 mock 解析器。** 解析器的价值就在于处理真实世界的脏数据。
---
## 命名
- 类名 = 被测函数的驼峰 + `Test``ClipPolygonTest``SampleTreeRowTest`
- 方法名是**陈述句,说明这个行为是什么**,不是 `test_case_1`
`test_polygon_keeps_only_the_exterior_ring`
`test_trailing_point_is_skipped_when_it_would_double_plant`
方法名读起来就是这个函数的规格说明。
---
## 测不到的部分怎么办
`bpy` 层(`mesh.py``materials.py``tree.py`、两个入口脚本)**没有单元测试**
也不打算有——它们需要真实的 Blender 运行时。
这一层的回归防线是 **parity 校验**:结构摘要比对,而不是单元测试。
见[产物一致性指南](../guides/artifact-parity-guide.md)。
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`
放进 `geom.py` 就立刻获得测试覆盖的资格。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 |
| 新几何函数不配退化输入测试 | 真实数据一进来就崩 |
| 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 |
| 带随机的函数不收 `seed` | parity 校验永久红 |
| mock 掉 OSM 解析 | 测不到它唯一的价值 |
| 在测试里 import `bpy` 侧模块 | 整个套件无法运行 |
| 只测一种绕向 | 反向多边形进来才发现 |