Initialize Trellis project guidelines
This commit is contained in:
218
.trellis/spec/blender/asset-generation.md
Normal file
218
.trellis/spec/blender/asset-generation.md
Normal 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):改完怎么验证产物没变
|
||||
146
.trellis/spec/blender/index.md
Normal file
146
.trellis/spec/blender/index.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Blender:Python 场景生成层
|
||||
|
||||
> 覆盖 `blender/**/*.py`。
|
||||
> 运行时:**两个**——Blender 内嵌 Python(bpy 层)和系统 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**,零依赖跑得起来
|
||||
185
.trellis/spec/blender/module-structure.md
Normal file
185
.trellis/spec/blender/module-structure.md
Normal 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 层的回归靠它兜底
|
||||
180
.trellis/spec/blender/testing.md
Normal file
180
.trellis/spec/blender/testing.md
Normal 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` 侧模块 | 整个套件无法运行 |
|
||||
| 只测一种绕向 | 反向多边形进来才发现 |
|
||||
Reference in New Issue
Block a user