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` 侧模块 | 整个套件无法运行 |
| 只测一种绕向 | 反向多边形进来才发现 |

View File

@@ -0,0 +1,187 @@
# Config区域配置
> 覆盖 `config/areas/*.json`、`config/examples/template.json`、`config/default.json`。
> 无运行时——这是**管线三层共同消费的契约**,改一个字段会同时影响 Node、Blender、
> 预览页。
---
## 两层配置
用户只写第一层,第二层是机器生成的中间产物:
```
config/areas/<id>.json ← 你写的
│ build-area.js: normalizeAreaConfig() 补默认值 + 推导 14 个输出路径
<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改
▼ build-osm2streets-qgis.js / reimport-gpkg.js
```
派生配置落在 `_pipeline/` 而不是临时目录——**构建失败时它还在**,可以直接拿去复现。
`config/default.json``config/hanyang-block.json` 是**低层脚本**
`npm run build:qgis`)用的旧格式配置,与 `config/areas/` 不是一回事。
新工作一律用 `config/areas/`
---
## 新增一个区域
```bash
cp config/examples/template.json config/areas/my-area.json
```
`id``input` 就能跑。其余全有默认值。
---
## 字段全表
### 顶层
| 字段 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `id` | ✅ | — | 区域标识。**同时是默认输出目录名和全部产物的文件名 stem** |
| `input` | ✅ | — | OSM XML 的**绝对路径**。不存在直接抛错 |
| `outputRoot` | | `<repo>/outputs` | 输出根目录 |
| `qgisApp` | | `/Applications/QGIS.app` | 也可用环境变量 `QGIS_APP` |
| `blenderApp` | | `/Applications/Blender.app` | |
| `stages` | | 见下 | 各阶段默认开关 |
| `qgis` | | 见下 | QGIS/osm2streets 旋钮 |
| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 |
| `blender` | | 见下 | Blender 侧选项 |
| `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 |
**路径一律绝对**`normalizeAreaConfig` 对每一项都做 `path.resolve`,相对路径会
相对于**进程 cwd** 解析,不是相对于配置文件——所以别用。
### `stages`
| 字段 | 默认 | 说明 |
|---|---|---|
| `intermediates` | `true` | 旧名 `qgis` 仍被接受 |
| `blender` | `true` | |
| `cesium` | `true` | |
`reimport``preview` **在这里配也没用**——`normalizeAreaConfig:117-118` 把它们
硬编码为 `false`,只能靠 `--stages` 显式请求。
> 恢复动作reimport和补丁动作preview不该被一份配置文件变成默认行为。
`--stages` 会整体覆盖这里的默认值。
### `qgis`
| 字段 | 默认 | 说明 |
|---|---|---|
| `arrowScale` | `0.8` | 导出前对 osm2streets 车道箭头多边形的缩放 |
| `arrowMergeTriangles` | `true` | 把箭头的三角网合并成一个合法多边形。**保留原箭头形状和转向**,同时消掉共享三角边处的渲染缝隙 |
| `arrowOutlineSimplifyMeters` | `0.05` | 去掉合并后箭头外轮廓上的亚分米级折角。默认值刚好去掉两个畸形尾顶点而**不动箭头头部**,剩下的尾边与杆身垂直 |
| `intersectionCornerSourceMaxDimensionMeters` | `2.6` | 只保留小尺寸的 `sidewalk corner` 多边形。**大的路口标记多边形不当人行道处理**,因为它们会盖住可行驶的路口 |
| `clipPad` | `0.002` | 送进 osm2streets 的裁剪框外扩(度) |
| `canvasPad` | `0.001` | QGIS 画布范围外扩(度) |
| `previewPad` | `0.0007` | 预览图范围外扩(度) |
| `canvasExtent` | `null` | 显式画布范围,覆盖 `canvasPad` |
| `previewExtent` | `null` | 显式预览范围,覆盖 `previewPad` |
| `layerPrefix` | `"osm2streets"` | QGIS 图层名前缀 |
三个 pad 单位是**度不是米**,且必须 `>= 0``build-osm2streets-qgis.js:49-53` 校验)。
`arrowScale` 必须 `> 0`
> 这四个 arrow/corner 旋钮的默认值都是调出来的,**改之前先看 README 里记的理由**。
> 尤其 `arrowOutlineSimplifyMeters`——调大会开始削箭头头部。
### `osm2streets`
原样透传给 `JsStreetNetwork` 构造函数(`build-osm2streets-qgis.js:77`)。默认:
```json
{
"debug_each_step": false,
"dual_carriageway_experiment": false,
"sidepath_zipping_experiment": false,
"inferred_sidewalks": true,
"osm2lanes": true
}
```
⚠️ **给了就整体替换,不做逐字段合并**`build-area.js:132``raw.osm2streets || {...}`)。
只想改一个开关也必须把五个字段全写上,否则其余四个会退到 osm2streets 自己的默认值。
### `blender`
| 字段 | 默认 | 说明 |
|---|---|---|
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py:144``TREE_STYLES``natural``procedural`,加上 `tree.py` 注册的模型 style |
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
### `outputs`(逃生舱)
默认全部从 `id` 推导为 `<outputRoot>/<id>/<fileStem>.<ext>`。需要定制时逐项覆盖:
```json
{
"outputs": {
"areaDir": "/absolute/path/to/custom-area",
"blend": "/absolute/path/to/custom.blend",
"glb": "/absolute/path/to/custom.glb",
"cesiumPreview": "/absolute/path/to/custom-preview.html"
}
}
```
可覆盖的键(`build-area.js:87-102``areaDir``fileStem``geojsonDir``gpkg`
`qgisProject``qgisPreview``blend``render``glb``metadata``cesiumPreview`
`vehicleRoute``vehicleModel``pipelineDir`
**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
---
## 加一个配置字段
1. `normalizeAreaConfig``build-area.js:74`)里加进对应的分组,**用 `??` 不用 `||`**
`false` / `0` 可能是合法值)
2. 只写两级 fallback`raw.<group>?.<key> ?? 默认值`
**不要**制造新的顶层平铺别名——那三级写法是历史兼容,不是模式
3. 若要传给低层脚本,加进 `writeDerivedConfig``:189`)的 `derivedConfig` 对象
4. 若是数值,在消费侧加 `Number.isFinite` + 范围校验,**在任何副作用之前**
5. 更新 `config/examples/template.json`
6. 更新本文档的字段表
若新字段产出新文件,同时在 `outputs` 里加一行路径推导。
---
## 已沉淀的区域
- `config/areas/nantaizi-lake-innovation-valley.json`(默认构建目标)
- `config/areas/hanyang-block.json`
两者都是 parity 校验的样本区域(`parity.js:26``DEFAULT_AREAS`)——
**改动它们会影响基线比对**
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 用相对路径 | 相对 cwd 解析,换个目录跑就错 |
| 手改 `_pipeline/*.config.json` | 下次构建被覆盖 |
| 只写 `osm2streets` 的一个字段 | 其余四个静默退到 osm2streets 默认值 |
| 布尔字段用 `\|\|` 兜底 | `false` 被翻转 |
| 给新字段造顶层平铺别名 | 扩大历史包袱 |
| 逐个覆盖 `outputs` 而不用 `fileStem` | 漏掉某个产物路径 |
| 在 `stages` 里配 `reimport` / `preview` | 无效,被硬编码为 false |
| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 |
---
## 相关
- [CLI 与阶段](../pipeline/cli-and-stages.md):配置怎么被读取和派生
- [外部工具调用](../pipeline/external-tools.md)`qgisApp` / `blenderApp` 怎么用
- README「区域配置」节面向使用者的说明

View File

@@ -0,0 +1,201 @@
# 产物一致性Parity指南
> **触发条件**:任何声称"纯重构、产物不变"的改动。
>
> 这条管线的产物是 `.blend` / `.glb` / `.png`——**二进制、无法 code review、
> 肉眼看不出 5% 的几何漂移**。parity 校验是这一层唯一的回归防线。
---
## 为什么不能直接比字节
三类文件全都**不是位级可复现**的,同一份代码跑两次就会不一样:
| 产物 | 为什么不稳定 |
|---|---|
| `.blend` | 内嵌绝对路径;图片按哈希表顺序打包 |
| `.png` | EEVEE 渲染非位级可复现 |
| `.glb` | glTF 导出器会去重相同的 accessor`smart_project` 的 UV 带浮点噪声——实测两次跑出 399 vs 398 个 accessor、差 720 字节,而 node/mesh/primitive/material/image **完全一致** |
所以比对的是**结构摘要**,不是字节。
---
## 三件套
| 工具 | 位置 | 作用 |
|---|---|---|
| 场景摘要 | `blender/tools/scene_digest.py` | 在 Blender 内打开 `.blend`,输出稳定 JSON对象名/顶点数/面数/材质槽/自定义属性、材质参数、场景属性 |
| GLB 摘要 | `scripts/glb-digest.js` | 纯 Node 读 GLB 的 JSON chunk输出 node/mesh/material 清单与 PBR 参数,附 buffer 字节长度 |
| 驱动 | `scripts/parity.js` | 跑构建 → 采集摘要 → 快照 / 比对 |
```bash
node scripts/parity.js capture <label> [--areas a,b] [--stages blender,cesium]
node scripts/parity.js compare <labelA> <labelB>
```
基线落在 `outputs/_refactor-baseline/<label>/`,在 `.gitignore` 里——
**本地草稿,不是产物,不入库**`parity.js:16-17`)。
默认样本区域两个(`parity.js:26``nantaizi-lake-innovation-valley`(主,
OSM + osm2streets GeoJSON 齐全)、`hanyang-block`(次)。
---
## 必须先做 control 实验
**这是最容易被跳过、跳过之后整个校验就是假的一步。**
用**未改动**的代码连跑两次diff 两份摘要。这一步确定哪些字段天然不确定,
把它们列入忽略名单。
```bash
node scripts/parity.js capture control-1
node scripts/parity.js capture control-2
node scripts/parity.js compare control-1 control-2 # 必须全绿
```
没做这步就开始改代码,你会分不清一个差异是"重构引入的 bug"还是"本来就每次都不一样"。
已完成的 control 结论(`docs/refactor-plan.md`
- `.blend` **结构摘要两次完全一致** ← 这是主校验信号,可信
- `.blend` 文件 sha256 不一致
- 渲染 PNG sha256 不一致
- GLB 结构node / mesh / primitive / material / image两次完全一致
但 accessor 数 399 vs 398、buffer 差 720 字节
---
## 忽略名单:必须附理由
`parity.js:25-54``IGNORED_PATHS`,每一条上面都写着为什么被忽略:
```
files.blend.sha256 / files.glb.{sha256,bytes} / files.render.{sha256,bytes}
glbDigest.fileBytes / glbDigest.buffers / glbDigest.counts.accessors
capturedAt / durationMs / label
```
这些字段**仍然被记录**——人读快照时想看到它们——只是不参与比对
`parity.js:25-27`)。
> **往忽略名单里加东西是有代价的动作。**
> 加之前先确认这个字段是**真的**每次都变(用 control 实验证明),
> 而不是你的改动让它变了。注释里必须写清楚证据。
---
## 真正的契约
忽略名单之外剩下的就是契约,**改动它们 = 改动产物**
| 契约项 | 谁产生 |
|---|---|
| `SCENE_DONE` stdout 标记及其 JSON 内容 | `generate_scene.py` |
| `CESIUM_EXPORT_DONE` stdout 标记及其 JSON 内容 | `export_cesium.py` |
| `.blend` 全量结构摘要(对象、网格、材质、自定义属性) | `scene_digest.py` |
| GLB 的 node / mesh / material / image 结构 | `glb-digest.js` |
| `<area>.json` 放置元数据 | `export_cesium.py` |
**改动 stage 的打印格式会静默破坏 parity 契约**——`parity.js:118-121` 解析这两个标记。
---
## 摘要工具本身的两条约定
`scene_digest.py` / `glb-digest.js` 时:
1. **浮点数四舍五入到 6 位**`scene_digest.py:17-18`。Blender 会把浮点数
round-trip 过单精度repr 的最后几位不是有意义的信号
2. **不稳定字段属于 `UNSTABLE_*` / `IGNORED_*` 名单,不属于摘要**
`scene_digest.py:11-13`。Blender 自己加的对象自定义属性
`_RNA_UI``cycles`)就是这么排除的(`scene_digest.py:24-25`
> 否则校验就是噪音,然后就会被忽略。一个天天报红的检查等于没有检查。
---
## 什么时候必须跑
| 改动 | 要不要跑 |
|---|---|
| 挪函数、拆模块、改导入 | **必须**——纯重构的定义就是产物不变 |
| 调整 `ROAD_LAYERS` / `MATERIALS` 的**顺序** | **必须**——会平移 GLB 材质索引 |
| 改材质名 | **必须**——可能静默断开 Cesium 侧的四张覆盖表 |
| 改几何构建、采样、实例化逻辑 | **必须** |
| 改 stage 的 stdout 打印 | **必须**——标记本身是契约 |
| 改 `.trellis/` 下的文档 | 不用 |
| 改 README / changelog | 不用 |
| **有意**改变产物(新功能、修渲染 bug | 跑,但目的是**看清差异范围**,不是要全绿 |
最后一行很重要parity 不只是"证明没变"的工具,也是"确认只变了预期的那部分"的工具。
加一种新植被,应该只看到新增对象,不该看到道路网格的顶点数也动了。
---
## 有意改变产物时怎么做
1. 先 capture 一份改动前的基线
2.
3. capture 改动后
4. compare**逐条读差异**
5. 差异要么是预期的,要么就是 bug——**没有第三种**
6. 把结论写进 `docs/changelog.md`
---
## 风险高的改动要分次提交
`docs/refactor-plan.md` 对风险最高的一期写着:
> 逐要素分次提交,每次单独跑 parity。
一次改十个要素然后发现摘要有差异,你不知道是哪个引起的。**一次一个,每次跑校验。**
---
## 当前重构进度(`docs/refactor-plan.md`
那份计划是**临时工作文档**P3 收尾后会并入 changelog 并删除。当前状态:
| 期 | 内容 | 状态 |
|---|---|---|
| P0 | 抽纯函数到 `osmassets/{osm,geom}.py` | ✅ 已完成 |
| P1 | `catalog.py` 单一定义源 + `check_layers` | ✅ 已完成 |
| P2 | 要素注册表 | ⚠️ **部分**——`water/grass/scrub/tree.py` 已拆出,但**没有 `features/` 注册表**`building` / `fountain` / `roads` 仍在 `generate_scene.py` 里 |
| P3 | 材质契约化(自定义属性传递 spec | ❌ **未做**——`export_cesium.py` 仍不 import `catalog`,靠四张材质名表;`catalog.CESIUM_EXPORT` 是死代码 |
### 已知缺陷(记录在案,本轮不修)
| # | 位置 | 现象 |
|---|---|---|
| D1 | `export_cesium.py:74,82,91,100` | `"Office White Metal Facade"` 四张表里都有,但 `catalog` 里已无此材质——死条目 |
| D2 | `scene-layers.js` vs `catalog.py` | 同一批图层的颜色两侧各自手调,无一致性保证(**这是刻意的**,见[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步) |
| D3 | `generate_scene.py` `tuft_density_wave` | 注释仍在跟已删除的 hedge banding 作对比 |
**碰到它们不要顺手修**——修复会改变产物或扩大 diff属于独立决定。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 跳过 control 实验直接开始改 | 分不清真回归和天然噪声 |
| 因为"老是报红"往忽略名单里加字段 | 把真回归一起忽略掉 |
| 忽略名单不写理由 | 下一个人无法判断该不该移出来 |
| 直接 diff 文件字节 | 永远红,然后所有人都不看了 |
| 一次改十个地方再跑校验 | 差异定位不到具体改动 |
| 改 stage 打印格式 | 静默破坏契约 |
| 摘要里保留不稳定字段 | 检查变噪音,最终被忽略 |
| 顺手修 D1D3 | 改变产物或扩大 diff |
---
## 相关
- [模块结构](../blender/module-structure.md)bpy 层为什么没有单元测试
- [测试](../blender/testing.md):纯 Python 层的防线
- [资产生成](../blender/asset-generation.md):为什么构建必须确定性
- [图层表](../pipeline/layer-registry.md):顺序为什么是承重的

View File

@@ -0,0 +1,158 @@
# 代码复用思考指南
> 目的:在新增 helper、常量、配置字段或枚举表之前先判断这个项目里"应该复用"和
> "刻意重复"的边界。这里的关键不是追求抽象,而是避免事实漂移。
---
## 先搜索,再决定
改任何值或新增类似逻辑前先跑:
```bash
grep -rn "关键字或现有值" scripts blender config
```
本项目的重复有两类:
- **危险重复**:同一事实被多处维护,漏改会静默错产物
- **可接受重复**:运行时边界不同或独立入口需要保留,抽象会扩大耦合
判断之前不要凭直觉抽取。
---
## 必须复用的事实源
### 九个 osm2streets 图层
JS 侧只认 `scripts/lib/scene-layers.js:15``SCENE_LAYERS`
需要文件名、合并场景、style JSON、QGIS 颜色时,使用同文件导出的派生函数:
- `layerFile(layer)``scene-layers.js:103`
- `mergeScene(getCollection)``scene-layers.js:109`
- `sceneStyle()``scene-layers.js:126`
- `qgisRgba(hex, alpha)``scene-layers.js:143`
不要在 `build-osm2streets-qgis.js``reimport-gpkg.js` 或 QGIS 项目生成代码里再枚举
九个图层。旧问题正是同一顺序复制到四处,漏一处不报错,只让 Blender/Cesium 场景错栈。
Python 侧必须有 `blender/osmassets/catalog.py:28``ROAD_LAYERS`,因为它还声明
Blender 高度与线性颜色。两侧靠 `catalog.check_layers()` 对账集合和顺序;颜色故意不同步。
### 区域输出路径
输出路径只在 `scripts/build-area.js:74``normalizeAreaConfig()` 推导。
低层脚本读取 `_pipeline/osm2streets-qgis.config.json`,不要重新读取
`config/areas/*.json` 或在阶段函数里现场拼路径。
新增产物时,在 `normalizeAreaConfig``outputs` 里加一项,再按需写入
`writeDerivedConfig()``build-area.js:189`)。这样 `intermediates``reimport`
`blender``cesium``preview` 仍然只通过磁盘产物耦合。
### 材质声明
Blender 内材质声明集中在 `catalog.MATERIALS``catalog.py:56`)。
真实 `bpy.types.Material``materials.from_spec()``materials.py:198`)构建。
注意当前有一个未完成迁移:`export_cesium.py:68``:80``:86``:95` 的四张表
仍按材质名字符串匹配。改材质名时不能只改 `catalog`;必须全仓 grep 材质名。
---
## 可接受的重复
### 三份 `parseArgs`
`parseArgs` 现在重复在三个独立入口:
- `scripts/build-area.js:50`
- `scripts/build-osm2streets-qgis.js:153`
- `scripts/reimport-gpkg.js:93`
语义一致:`--kebab-case value``kebabCase: "value"`,无值 flag 变字符串 `"true"`
这份重复目前是可接受技术债,因为三个脚本都能独立运行。改其中一处解析语义时,不要顺手
只改一份;要么保持三份一致,要么把"抽公共模块"作为独立重构并跑 parity。
### JS 与 Python 的图层颜色
`scene-layers.js` 的颜色是 QGIS 2D 调试 sRGB hex`catalog.py` 的颜色是 Blender
线性 RGB。`catalog.py:11-15` 明确说颜色不是同步目标。
把两边颜色抽成同一个表不是复用,是破坏两个运行时各自调过的视觉结果。
---
## 重复模式检查
### 看到第二份枚举表
问:
- 这份表是否已经能从 `SCENE_LAYERS``ROAD_LAYERS``MATERIALS` 或配置派生?
- 如果必须跨语言重复,是否已有对账机制?
- 追加顺序是否影响 GLB 材质索引?
没有对账机制的重复表必须特别谨慎。材质名覆盖就是当前已知风险:
`generate_scene.py` 创建材质,`export_cesium.py` 靠字符串覆盖,没有校验。
### 看到多个模块同样预处理
`water.py:9``grass.py:9``scrub.py:8` 都调用 `clip_polygon`,这是对要素模块签名的
统一要求:模块接收边界、自己裁剪、退化输入返回 0。
新增第四个要素模块时先照这个形状写,不要把裁剪逻辑上移到调用方。否则旧模块和新模块
的边界会不同,真实 OSM 的越界几何会按要素类型表现不一致。
### 看到多个地方解析同一格式
优先找已有解析器:
- OSM XML → `osmassets/osm.py:parse_osm()`
- 米制几何 → `osmassets/geom.py`
- GeoJSON 场景合并 → `scene-layers.js:mergeScene(getCollection)`
- 区域配置 → `build-area.js:normalizeAreaConfig()`
如果确实需要新解析器,把输入格式、容错语义和调用者写清楚,并给纯 Python 逻辑补测试。
---
## 什么时候抽象
抽象只在满足至少一条时做:
- 同一事实会被三处以上消费,且有真实漏改风险
- 同一段校验逻辑跨多个入口影响产物安全
- 抽出来后能保留运行时边界,比如纯 Python 逻辑进入 `geom.py` 后可被
`python3 -m unittest discover blender/tests` 覆盖
不要因为代码相似就抽象:
- 三份 `parseArgs` 当前保持独立入口价值
- `ROAD_LAYERS``SCENE_LAYERS` 跨语言且承载不同字段
- 每个要素模块各自调用 `clip_polygon` 是模块边界,不是可消除重复
---
## 提交前自检
- [ ] 已 grep 关键值或新字段
- [ ] 没有新增第二份九图层枚举
- [ ] 没有在阶段函数里重新拼输出路径
- [ ] 改材质名时已检查 `catalog.py``generate_scene.py``export_cesium.py`
- [ ] 新纯几何逻辑放进 `geom.py` 并补 `test_pure.py`
- [ ] 声称产物不变的重构已按[产物一致性指南](./artifact-parity-guide.md)校验
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 新增一份图层名列表 | 回到旧的四份同步,漏改静默错栈 |
| 把两套颜色表统一 | 破坏 QGIS 与 Blender 各自调过的视觉结果 |
| 低层脚本直接读 `config/areas/*.json` | 两层配置边界失效 |
| 只改一份 `parseArgs` 的语义 | 三个入口行为分裂 |
| 把要素模块裁剪逻辑挪到调用方 | 不同要素的越界处理开始漂移 |
| 只在 `catalog.CESIUM_EXPORT` 加导出覆盖 | 当前不会生效;导出器没读它 |

View File

@@ -0,0 +1,217 @@
# 跨层思考指南
> **目的**:动手前把数据流走一遍,把"没想到"变成"想过了"。
>
> 本项目的层是**跨语言、跨进程、跨运行时**的,边界比普通应用多得多。
---
## 本项目的层与边界
```
config/areas/*.json JSON 数据
↓ ①
build-area.js Node宿主机
↓ ② 派生配置 JSON
build-osm2streets-qgis.js Node + osm2streets WASM
↓ ③ 子进程 + 环境变量
ogr2ogr / ogrinfo / QGIS Python GDAL/QGIS 运行时
↓ ④ GeoJSON / GeoPackage 文件
generate_scene.py Blender 内嵌 Python
↓ ⑤ .blend 文件 + 材质名字符串
export_cesium.py Blender 内嵌 Python
↓ ⑥ GLB + JSON
cesium-preview.js 浏览器
```
| # | 边界 | 常见问题 |
|---|---|---|
| ① | 用户配置 → 归一化 | `??` vs `\|\|`、相对路径、字段整体替换 |
| ② | 两层配置 | 低层脚本读错配置源 |
| ③ | Node → 外部进程 | 环境变量缺失、退出码与信号、0 字节产物 |
| ④ | 文件交换 | 图层集合/顺序漂移、精度丢失 |
| ⑤ | Python → Python | **材质名字符串**,无校验 |
| ⑥ | Blender → 浏览器 | 坐标系约定、材质在两种光照下的差异 |
---
## 什么时候该读这篇
- [ ] 改动同时出现在 `scripts/``blender/`
- [ ] 你在改九个 osm2streets 图层中的任何一个
- [ ] 你在改材质名、材质顺序
- [ ] 你在往配置里加字段
- [ ] 你在改任何被 `execFileSync` / `spawnSync` 调起的东西
- [ ] 你在改 stage 的 stdout 打印
- [ ] 你要新增一种在 Blender 里生成、要在 Cesium 里看的资产
---
## Step 1画数据流
对每一个箭头问三件事:
- **格式是什么**——JSONGeoJSON FeatureCollectionGeoPackage 图层?字符串键?
- **可能出什么错**——文件不存在0 字节?字段名对不上?
- **谁负责校验**——上游写的时候,还是下游读的时候?
本项目的答案通常是:**上游写完就走,下游读的时候校验**。因为上游经常是外部工具
ogr2ogr、Blender改不动。
## Step 2找出"约定型"边界
最危险的不是有 schema 的边界,是**靠约定连接**的边界:
| 边界 | 靠什么连接 | 有没有校验 |
|---|---|---|
| `scene-layers.js``catalog.py` | 图层 `id` 的集合与顺序 | ✅ `check_layers()`warn |
| `generate_scene.py``export_cesium.py` | **材质名字符串** | ❌ **无** |
| GeoJSON 文件名 ↔ 图层 id | `layerFile()``<id>.geojson` | 部分reimport 会检查 gpkg 图层是否齐全) |
| stage stdout ↔ `parity.js` | `SCENE_DONE` / `CESIUM_EXPORT_DONE` 字面量 | ❌ 无 |
| GLB 材质索引 ↔ `MATERIALS` 顺序 | 隐式的创建顺序 | ❌ 无(靠 parity 事后发现) |
**没有校验的那几行就是本项目最容易静默出错的地方。**
## Step 3定契约
对每个边界写清楚:输入格式、输出格式、能出什么错。
本项目已定的契约见[产物一致性指南](./artifact-parity-guide.md#真正的契约)。
---
## 本项目真实踩过的坑
### 坑 1同一份事实存了四份
九个图层的顺序曾同时存在于 z_index 表、样式 JSON、QGIS 工程、README。
改一处漏三处,**不报错**,只是下游场景悄悄错栈。
**修法**`scripts/lib/scene-layers.js` 单一事实源 + 四个派生函数。
跨语言那一份(`catalog.py`)无法消除,改用运行时对账。
→ [图层表](../pipeline/layer-registry.md)
### 坑 2外部工具失败但留下了文件
`ogr2ogr` 遇到不存在的图层退出码非零,**但已经创建了一个 0 字节文件**。
直接覆盖目标目录就会用空文件冲掉好数据,而且看起来像成功。
**修法**staging 目录 → 全部校验 → 才落盘。
→ [外部工具调用](../pipeline/external-tools.md#2-导出失败会留下-0-字节文件)
### 坑 3为了"输出干净"加了个参数
`ogr2ogr` 显式设 `COORDINATE_PRECISION`,触发了 GDAL 的精度裁剪,
7 个箭头多边形丢了 28 个顶点。默认行为本来就能完整往返双精度。
**教训**:跨边界时,**显式设置一个"看起来更安全"的参数,可能触发上游的另一条代码路径**。
### 坑 4新资产在 Blender 里对、在 Cesium 里发黑
场景里每一个材质都被手工提亮过(草往亮绿混 72%、建筑自发光 0.18
因为 Cesium 默认光照偏白。新资产没调过,是唯一如实渲染的东西,
放在旁边就显得发黑。
**教训****同一份数据在两个运行时里的"正确"可能不一样**。
第二个运行时如果有一整套补偿,新东西必须也进那套补偿。
→ [资产生成](../blender/asset-generation.md#为什么新资产总是发黑)
### 坑 5两个 Python 脚本靠字符串对接
`export_cesium.py` 不 import `catalog`,靠材质名字符串匹配四张覆盖表。
改个材质名Cesium 侧的调色**静默失效**。`catalog.CESIUM_EXPORT` 想解决这个问题,
但迁移没做完,它现在是死代码。
**教训****字符串键的跨模块耦合必须配一个对账机制**,否则重命名就是定时炸弹。
---
## 加东西时的检查清单
### 加一个图层
- [ ] `scene-layers.js:SCENE_LAYERS` 末尾追加
- [ ] `catalog.py:ROAD_LAYERS` 末尾追加,**顺序一致**
- [ ] 确认 osm2streets 拆分结果里有对应的 `splitKey`
- [ ] 跑一次构建,确认日志里没有 `Layer catalog warning:`
- [ ] 跑 parity确认只多了预期的对象
### 加一个材质
- [ ] `catalog.MATERIALS` **末尾**追加(中间插入会平移 GLB 材质索引)
- [ ] 若在 Cesium 里需要调色,去 `export_cesium.py` 的四张表加
- [ ] 跑 parity
### 加一个配置字段
- [ ] `normalizeAreaConfig` 里用 `??` 不用 `||`
- [ ] 需要传给低层脚本?加进 `writeDerivedConfig`
- [ ] 数值?在消费侧加 `Number.isFinite` + 范围校验,**在任何副作用之前**
- [ ] 更新 `config/examples/template.json`
- [ ] 更新 [config spec](../config/index.md) 的字段表
### 加一个 stage
- [ ] `normalizeAreaConfig``stages` + `resolveStages``aliases`
- [ ] 想清楚进不进 `all`(恢复类/补丁类不进)
- [ ] 与已有 stage 有覆盖关系?加互斥检查
- [ ] 产出新文件?加进 `outputs` 路径推导
- [ ] 阶段函数开头 `ensureFile` 校验依赖产物
### 加一种 OSM 要素
- [ ] 新模块放 `osmassets/``assemble(...)` 签名照抄现有三个
- [ ] 只 import 需要的,**纯几何逻辑放 `geom.py` 并补测试**
- [ ] 材质加进 `catalog.MATERIALS` 末尾
- [ ] `generate_scene.py` 分发处加一行
- [ ] 需要"随机"外观?用 index 的纯函数或显式 seed**不要用 `random`**
- [ ] 跑 parity
---
## 通用的四个错误
### 隐式格式假设
跨边界时假设"上游肯定给的是 X 格式"。本项目的做法是**读的时候验**
`reimport-gpkg.js:175` 明确检查 `type === "FeatureCollection" && Array.isArray(features)`
### 校验散在各处
同一个约束在三个地方各写一遍,改的时候漏一个。
本项目把参数校验集中在脚本开头(`build-osm2streets-qgis.js:41-70`
**在任何副作用之前一次验完**
### 抽象泄漏
低层脚本如果直接读 `config/areas/*.json`,两层配置的边界就白设了。
它们只该读派生配置。
### 每个消费方各自解析同一份数据
看到两处代码用各自的方式从同一份 payload 里挖同一个字段,
就该有一个共享的解析函数了。`mergeScene(getCollection)` 用回调而不是数组,
就是为了让 build 和 reimport 共用一套合并逻辑。
---
## 一条铁律
> **改任何值之前,先全仓 grep 一遍。**
```bash
grep -rn "要改的值" scripts blender config
```
本项目跨两种语言IDE 的"查找引用"帮不上忙。这一个习惯能挡掉大部分
"忘了同步 X" 的 bug。
---
## 相关
- [代码复用思考指南](./code-reuse-thinking-guide.md)
- [产物一致性指南](./artifact-parity-guide.md)
- [图层表](../pipeline/layer-registry.md)

View File

@@ -0,0 +1,76 @@
# 思考指南索引
> 目的:在改代码前补一遍"跨层会不会断、重复事实会不会漂、产物是否仍一致"。
> 本目录不替代包级 spec它用于那些单看一个文件容易误判的改动。
---
## 可用指南
| 指南 | 关注点 | 什么时候读 |
|---|---|---|
| [跨层思考指南](./cross-layer-thinking-guide.md) | JS、GDAL/QGIS、Blender Python、浏览器之间的数据契约 | 改图层、材质名、配置字段、stage 输出、外部工具调用 |
| [代码复用思考指南](./code-reuse-thinking-guide.md) | 单一事实源、重复解析、可接受重复与应抽取重复的边界 | 改 `parseArgs`、图层表、配置归一化、几何工具 |
| [产物一致性指南](./artifact-parity-guide.md) | `.blend` / `.glb` / metadata 的结构摘要校验 | 任何声称"纯重构、产物不变"的改动 |
---
## 本项目触发点
### 读跨层思考指南
- [ ]`scripts/lib/scene-layers.js:15``SCENE_LAYERS`
- [ ]`blender/osmassets/catalog.py:28``ROAD_LAYERS``catalog.py:56``MATERIALS`
- [ ]`blender/export_cesium.py:68` 等四张按材质名字符串匹配的覆盖表
- [ ]`build-area.js:74``normalizeAreaConfig()``config/examples/template.json`
- [ ] 改任何 `execFileSync` / `spawnSync` 调起的脚本或参数
- [ ]`SCENE_DONE` / `CESIUM_EXPORT_DONE` 的 stdout 标记
### 读代码复用思考指南
- [ ] 准备新增第二份或第三份图层、材质、配置字段枚举
- [ ] 修改三份重复的 `parseArgs` 之一:
`build-area.js:50``build-osm2streets-qgis.js:153``reimport-gpkg.js:93`
- [ ] 多个要素模块都要做同一件几何预处理,比如
`water.py:9``grass.py:9``scrub.py:8` 都先 `clip_polygon`
- [ ] 低层脚本想直接读取 `config/areas/*.json`,绕开派生配置
- [ ] 新增 helper 前没有先 `grep -rn` 找现有函数
### 读产物一致性指南
- [ ] 挪函数、拆模块、改导入,且声称产物不变
- [ ] 重排 `ROAD_LAYERS` / `MATERIALS`
- [ ] 改材质名或导出调色逻辑
- [ ] 改几何、采样、实例化、UV、材质构建
- [ ]`scripts/parity.js``scripts/glb-digest.js``blender/tools/scene_digest.py`
---
## 改值前的固定动作
```bash
grep -rn "要改的值" scripts blender config
```
本仓库跨 JS、Blender Python、浏览器 JS 和 JSON很多连接靠字符串或文件名约定。
例如 `scene-layers.js``catalog.py` 只靠 `id` 集合和顺序对账;
`generate_scene.py``export_cesium.py` 的材质覆盖目前靠材质名字符串,没有自动校验。
---
## 审查 AI 结果时
- 先看它有没有读到对应包的 index 和本目录指南
- 对任何"行为没变"的结论,要求说明是否需要 parity需要却没跑就是风险
- 对任何"可以合并重复"的建议,先判断重复是不是刻意边界:
三份 `parseArgs` 目前是可接受技术债JS/Python 图层颜色则是刻意不同步
- 对任何"加精度、加默认值、直接覆盖文件"的建议,回到真实代码注释验证;
`reimport-gpkg.js:152-156``reimport-gpkg.js:11-13` 都是反直觉约束
---
## 维护规则
- 发现新的跨层坑,优先补到相关指南,再补包级 spec
- 指南只写本项目已发生或代码已体现的约束,不写通用工程格言
- 每条新约束至少带两个真实路径或函数名,方便后续 grep 定位

85
.trellis/spec/index.md Normal file
View File

@@ -0,0 +1,85 @@
# Trellis 项目规范索引
> 本目录面向 AI 执行者,记录本仓库真实代码里的工程约束。
> 用法说明仍放在 README这里写的是**改代码前必须知道什么**。
---
## 项目边界
本仓库不是前端应用,而是 **OSM → QGIS/Blender/Cesium 的资产生成管线**
- `scripts/build-area.js:74``normalizeAreaConfig()` 归一化区域配置并调度阶段
- `scripts/lib/scene-layers.js:15``SCENE_LAYERS` 是 osm2streets 九个 2D 图层的 JS 侧事实源
- `blender/osmassets/catalog.py:28``ROAD_LAYERS` 是 Blender 侧道路图层与材质顺序事实源
- `scripts/lib/cesium-preview.js:1` 是无构建步骤的浏览器预览 IIFE
因此有效 spec layer 只有 `pipeline``blender``preview``config`
另有跨层思考指南 `guides`
---
## 先读哪个包
| 你要改的内容 | 先读 |
|---|---|
| `scripts/*.js`、构建阶段、QGIS/GDAL/Blender 子进程、GeoPackage 往返 | [pipeline](./pipeline/index.md) |
| `blender/**/*.py``osmassets` 模块、材质、几何、导出 | [blender](./blender/index.md) |
| `scripts/lib/cesium-preview.js` / `.css`、预览 HTML 注入 | [preview](./preview/index.md) |
| `config/areas/*.json``config/examples/template.json`、区域字段默认值 | [config](./config/index.md) |
| 跨语言、跨进程、声称纯重构或产物不变的改动 | [guides](./guides/index.md) |
跨层改动至少读两个包的 index再读 `guides/index.md`。例如:
- 加一个 osm2streets 图层:读 `pipeline/layer-registry.md` +
`blender/asset-generation.md` + `guides/artifact-parity-guide.md`
- 改材质名:读 `blender/module-structure.md` +
`blender/asset-generation.md` + `guides/cross-layer-thinking-guide.md`
- 加区域配置字段:读 `config/index.md` +
`pipeline/cli-and-stages.md`
---
## 四条全仓硬约束
1. **改值先 grep**
本项目跨 JS、Blender Python、浏览器 JS 和 JSONIDE 引用不可靠。
`SCENE_LAYERS``ROAD_LAYERS`、材质名、stage 标记或配置字段前,先:
```bash
grep -rn "要改的值" scripts blender config
```
2. **顺序可能是契约**
`catalog.py:16-18` 明确说 `ROAD_LAYERS` / `MATERIALS` 的顺序决定导出 GLB 的材质索引。
列表默认只能末尾追加;中间插入或重排必须跑 parity。
3. **外部工具产物先 staging 后覆盖**
`reimport-gpkg.js:11-13` 记录了 `ogr2ogr` 失败会留下 0 字节文件。
任何从外部工具生成文件再覆盖已有产物的代码,都要先写临时目录、校验、再拷回。
4. **纯重构必须证明产物一致**
`scripts/parity.js`、`scripts/glb-digest.js`、`blender/tools/scene_digest.py`
是结构摘要三件套;不要用二进制字节 diff 代替。
---
## 验证入口
- spec layer 扫描:
`python3 ./.trellis/scripts/get_context.py --mode packages`
- 纯 Python 测试:
`python3 -m unittest discover blender/tests`
- 纯重构产物一致性:
`node scripts/parity.js capture <label>`,再
`node scripts/parity.js compare <before> <after>`
---
## 维护本目录
- 每个包目录必须有 `index.md`
- 每个规范文件至少引用两个真实项目文件或函数,优先写 `path:line` + 标识符
- 文档语言保持中文;标识符、路径、命令和代码片段保持英文原样
- 不写任何占位提示或未来补写标记;规范文件必须是可执行的当前约束
- README 面向使用者spec 面向修改者;不要把命令教程整段复制到 spec

View File

@@ -0,0 +1,190 @@
# CLI 与构建阶段
> 适用:新增/修改构建阶段、CLI 参数、区域配置字段。
---
## 三个入口脚本
| 脚本 | 角色 | 入口方式 |
|---|---|---|
| `scripts/build-area.js` | **主入口**。读区域配置,按阶段调度 | `npm run build` / `build:area` |
| `scripts/build-osm2streets-qgis.js` | intermediates 阶段的实现 | 由 build-area 调起;`npm run build:qgis` 可单跑 |
| `scripts/reimport-gpkg.js` | reimport 阶段的实现 | 由 build-area 调起 |
`scripts/parity.js``scripts/glb-digest.js` 是校验工具,不属于构建链,见
[产物一致性指南](../guides/artifact-parity-guide.md)。
全部是 CommonJS`package.json``"type": "commonjs"`),无构建步骤、无 TypeScript、
零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。
---
## CLI 参数解析
三个脚本各有一份**完全相同**的 `parseArgs`
`build-area.js:50``build-osm2streets-qgis.js:153``reimport-gpkg.js:93`
```js
--kebab-case value { kebabCase: "value" }
--flag { flag: "true" } // 后面没值或紧跟另一个 --
```
两条必须知道的语义:
- **值永远是字符串**`--flag` 得到的是字符串 `"true"` 不是布尔 `true`。消费方要么
`Number(...)` 要么显式比较
- **不做校验**。未知参数被静默收集,缺失参数由下游的 `requireText` / `Number.isFinite`
报错
> 这份重复是已知的、**当前被接受的**技术债:三个脚本要能各自独立运行,抽公共模块的
> 收益还不抵引入一层依赖。改其中一份时**不要**顺手把另外两份重构掉——那是独立的决定,
> 且会扩大 diff。真要抽取三处一起改并跑 parity。
---
## 两层配置
```
config/areas/<id>.json 用户写的区域配置(面向人)
│ build-area.js: normalizeAreaConfig() —— 补默认值、推导全部输出路径
area内存中的归一化对象
│ writeDerivedConfig()
<areaDir>/_pipeline/osm2streets-qgis.config.json 派生配置(面向机器)
│ --config
build-osm2streets-qgis.js / reimport-gpkg.js
```
**低层脚本从不读区域配置**,只读派生配置。这条边界让低层脚本能被独立调试,也让
"输出路径怎么算出来的"只有一处答案(`normalizeAreaConfig``build-area.js:74`)。
派生配置**落在 `_pipeline/` 目录里而不是临时目录**——构建失败时它还在,可以直接拿去
复现(`writeDerivedConfig``build-area.js:189`)。
### 输出路径全部从 `id` 推导
`normalizeAreaConfig` 一次性算出 14 个输出路径(`build-area.js:87-102`),规则统一是
`<outputRoot>/<id>/<fileStem>.<ext>``fileStem` 默认等于 `id`
每一项都可以被 `outputs.*` 单独覆盖,写法固定:
```js
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
```
**加新产物就加这一行**,不要在阶段函数里现拼路径。
### 缺失值:区分"必填"和"有默认"
| 场景 | 写法 | 出处 |
|---|---|---|
| 必填,缺了直接死 | `requireText(raw.id, "id")` | `build-area.js:143` |
| 有默认值 | `raw.qgisApp \|\| "/Applications/QGIS.app"` | `:107` |
| 有默认值且 `false`/`0` 合法 | `raw.stages?.blender ?? true` | `:114` |
| 兼容旧字段名 | `raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8` | `:121` |
**`??``||` 不能混用**`arrowMergeTriangles``??`,因为 `false` 是合法值,
`||` 会把关掉的开关重新打开。
第三列的三级 fallback 是刻意的向后兼容:旧配置把 QGIS 旋钮平铺在顶层,新配置收进
`qgis: {}`。加新旋钮时**只写两级**`raw.qgis?.x ?? 默认值`),不要制造新的平铺别名。
---
## 五个阶段
| 阶段 | 做什么 | 读 | 写 |
|---|---|---|---|
| `intermediates` | OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/``.gpkg``.qgz``-preview.png` |
| `reimport` | GeoPackage → GeoJSON**反向** | `.gpkg` | `osm2streets_web_out/` |
| `blender` | OSM + GeoJSON → 场景 | `.osm``osm2streets_web_out/` | `.blend``.png` |
| `cesium` | 场景 → GLB + 元数据 + 预览页 | `.blend` | `.glb``.json`、预览 HTML 及其静态资源 |
| `preview` | 只补生成预览页 | `.glb``.json` | 预览 HTML 及其静态资源 |
调度是顶层的五个 `if``build-area.js:32-46`),顺序固定,**阶段之间不传内存状态,
只通过磁盘产物耦合**。这就是单跑某个阶段能work 的原因。
`cesium` 阶段结束时会直接调 `writeCesiumPreview(area)``build-area.js:285`),所以
`preview` 只在"已有 GLB、只想重生成 HTML"时才需要单独跑。
### 别名
`resolveStages``build-area.js:157`)接受一张别名表,同一个阶段有多个叫法
`qgis`/`osm2streets`/`geojson``intermediates``gpkg``reimport`
`scene``blender``glb``cesium``html`/`cesiumPreview``preview`)。
未知阶段名**抛错并列出合法值**`:181`),不静默忽略。
### `all` 不含 `reimport`
```js
// 'reimport' is deliberately absent from 'all': it is a recovery step for
// hand-edited GeoPackages, never part of a full build. build-area.js:159-160
all: ["intermediates", "blender", "cesium"],
```
`preview` 同样不在 `all` 里——`cesium` 已经包含它。
### `intermediates` 与 `reimport` 互斥
在任何阶段执行**之前**就检查并抛错(`build-area.js:19-26`
> intermediates 从 OSM 重建 GeoPackage正好会抹掉 reimport 要读回的手工修改。
这是**显式拒绝而不是警告**——两者同时开,无论谁先跑,另一个的工作都白做。
`normalizeAreaConfig``stages.reimport``stages.preview` 硬编码为 `false`
`build-area.js:117-118`**不能从配置文件打开**,只能靠 `--stages` 显式请求。
恢复动作和补丁动作都不该被一份配置文件变成默认行为。
---
## 加一个新阶段
1. `normalizeAreaConfig``stages` 里加一项(默认值想清楚是 `true` 还是硬编码
`false`
2. `resolveStages``aliases` 里注册名字(以及别名)
3. 决定要不要进 `all`——**恢复类/补丁类动作不进**
4. 顶层加一个 `if (stages.x) doX(area)`,位置按数据依赖排
5.`doX(area)`:先 `ensureFile` 校验依赖产物,`mkdirSync` 建目录,
`console.log("Stage: x")`,再 `runCommand`
6. 若与已有阶段存在"互相覆盖"关系,在顶层加互斥检查
7. 若产出新文件,在 `normalizeAreaConfig``outputs` 里加路径
---
## 输出约定
- 开头三行固定打印 area / config / output 路径(`build-area.js:29-31`
- 每个阶段进入时打印 `Stage: <name>`
- 结尾 `console.log("Done.")`
- 外部工具的输出透传,不加工
parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SCENE_DONE` /
`CESIUM_EXPORT_DONE`**改动这些打印等于改动 parity 契约**。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 在阶段函数里现拼输出路径 | 路径规则出现第二份定义 |
| 低层脚本直接读 `config/areas/*.json` | 打破两层配置边界 |
| 布尔配置用 `\|\|` 兜底 | `false` 被翻转成默认值 |
| 让 `reimport` / `preview` 能从配置文件默认开启 | 恢复动作变成常规行为 |
| 新阶段忘了 `ensureFile` 前置校验 | 单跑时报底层堆栈而非人话 |
| 改 stage 的 stdout 标记 | 静默破坏 parity 契约 |
| 顺手把三份 `parseArgs` 合并 | 扩大 diff且三个脚本的独立性是刻意的 |
---
## 相关
- [外部工具调用](./external-tools.md):阶段内部如何调 QGIS/Blender
- [图层表](./layer-registry.md)intermediates 与 reimport 共同维护的九个图层
- [区域配置](../config/index.md):字段全表
- README「主流程」节面向使用者的命令示例**不要**复制到这里)

View File

@@ -0,0 +1,200 @@
# 外部工具调用
> 适用:任何调用 QGIS / GDAL / Blender 子进程的代码。
> 这一层是管线里**唯一**能启动外部进程的地方,也是踩过坑最多的地方——下面每条约束
> 都对应一次实际的数据损坏或静默错误。
---
## QGIS 工具链
### 可执行文件路径
一律从配置的 `qgisApp` 推导,不写死绝对路径、不依赖 `PATH`
```js
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
const qgisPython = path.join(qgisMacOS, "python3.12"); // build-osm2streets-qgis.js:24
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr"); // :25
const ogrinfo = path.join(qgisMacOS, "ogrinfo"); // reimport-gpkg.js:33
```
**启动前必须 `existsSync` 校验并抛出带路径的错误**
`build-osm2streets-qgis.js:59-63``reimport-gpkg.js:37-41`)。让它在第一步就失败,
而不是在 `execFileSync` 里抛一个没有上下文的 ENOENT。
### GDAL 环境变量(必须)
```js
function qgisEnv() { // build-osm2streets-qgis.js:231
return {
PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"),
GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"),
};
}
```
`reimport-gpkg.js:132` 有一份等价实现(`gdalEnv()`)。**每次 `execFileSync` 都要带上**
写法固定为 `env: { ...process.env, ...qgisEnv() }`
漏掉不会立刻崩——GDAL 会退回内置的残缺数据,坐标系解析结果**静默出错**。
### 在 QGIS 的 Python 里跑脚本
`normalize-lane-arrows.py` 依赖 `osgeo.ogr`,只能用 QGIS 自带的解释器。除了
`qgisEnv()` 还要额外注入三项(`build-osm2streets-qgis.js:1287-1305`
| 变量 | 值 | 作用 |
|---|---|---|
| `QT_QPA_PLATFORM` | `offscreen` | 无头环境下不尝试连显示服务 |
| `PYTHONHOME` | `<qgisApp>/Contents/Frameworks` | 指向 QGIS 的 Python 运行时 |
| `PYTHONPATH` | `<...>/Resources/python` + `/plugins` | 找得到 `osgeo` 与插件 |
新增 QGIS-Python 脚本时照抄这套环境,不要只带 `qgisEnv()`
---
## `ogr2ogr` 的三个陷阱
### 1. 不要设 `COORDINATE_PRECISION`
`reimport-gpkg.js:152-156` 有一段专门的注释说明:
> 默认行为已经能完整往返双精度。**显式设置反而会触发 GDAL 的精度裁剪那一遍**
> 把在给定分辨率下collapse 的顶点丢掉——实测在 7 个 lane-arrow 多边形上丢了 28 个点。
看到有人"为了输出干净"加上这个参数,删掉它。
### 2. 导出失败会留下 0 字节文件
`ogr2ogr` 遇到不存在的图层**退出码非零,但已经创建了一个空文件**。直接写目标目录
就会用空文件覆盖掉好数据,而且看起来像成功。
所以 reimport 的落盘是三段式(`reimport-gpkg.js:11-13, 61-91`
```
1. 全部导出到 mkdtemp 的 staging 目录
2. 逐个 JSON.parse + 校验 type === "FeatureCollection" && Array.isArray(features)
3. 全部通过后,才逐个 copyFileSync 到 outDir
```
任一图层失败 → 整批不落盘。**任何"从外部工具产出文件再覆盖已有数据"的新代码都照这个
模式写。**
补充两点:
-`copyFileSync` **不用** `rename`staging 目录可能在另一个文件系统上
`reimport-gpkg.js:72-73`
- `finally``rmSync(stagingDir, {recursive: true, force: true})`,失败路径也要清理
### 3. 建包与追加是两组参数
第一个图层创建 GeoPackage其余追加`build-osm2streets-qgis.js:108-111`
```js
SCENE_LAYERS.forEach((layer, index) => {
importLayer(gpkgPath, ..., layer.id, index > 0, ogrEnv); // update = index > 0
});
```
`importLayer``:1306`)在 `update` 为真时补 `-update -overwrite`。重建前先
`unlinkSync` 掉旧的 gpkg`:104-106`),不要依赖 `-overwrite` 清理整个文件。
---
## 前置校验的顺序
`build-osm2streets-qgis.js:41-70` 的开头是一段密集的校验,顺序是刻意的:
1. **数值参数**先验(`Number.isFinite` + 范围),错的配置立刻死
2. **输入文件**存在性
3. **外部可执行文件**存在性
4. 全部通过后才 `mkdirSync` 建输出目录
原则:**在做任何有副作用的事情之前,把能验的都验完**。不要先建目录再发现 QGIS 装错了。
数值校验用 `Number.isFinite` 而不是 `!isNaN`——后者对 `Infinity` 返回 false
`Infinity` 是个合法的 `Number()` 结果。
---
## Blender 调用
### 两种调用姿势
| 阶段 | 参数 | 出处 |
|---|---|---|
| `blender` | `--background --factory-startup --python generate_scene.py --` | `build-area.js:242-247` |
| `cesium` | `--background --python export_cesium.py --` | `build-area.js:276-280` |
**`--factory-startup` 只在 generate 阶段用**:它屏蔽用户的 preferences 和 addon
保证场景生成不受本机 Blender 配置影响。export 阶段不带,因为它要读已经建好的 `.blend`
`--` 之后才是脚本自己的参数Blender 不解析它们。脚本侧用
`sys.argv[sys.argv.index("--") + 1:]` 取。
可执行文件路径同样从配置推导:
`path.join(area.blenderApp, "Contents", "MacOS", "Blender")``build-area.js:291`)。
### 调用前的资源校验
`ensureFile()``build-area.js:295`)在每个阶段开头把依赖逐个验一遍,带 label
```js
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(area.outputs.blend, "Blend scene"); // cesium 阶段依赖上一阶段产物
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
```
阶段间依赖靠这个显式表达,**不靠隐式的执行顺序**。`--stages cesium` 单跑时,缺
`.blend` 会得到一句人话错误而不是 Blender 的堆栈。
---
## 子进程失败处理
统一走 `runCommand``build-area.js:301-311`
```js
const result = spawnSync(command, commandArgs, { stdio: "inherit" });
if (result.error) throw result.error;
if (result.status !== 0) {
const signal = result.signal ? ` signal=${result.signal}` : "";
throw new Error(`Stage '${stage}' failed with status=${result.status}${signal}`);
}
```
三个要点:
- **`stdio: "inherit"`**:外部工具的输出直接透传,不缓冲、不吞。这条管线的调试
高度依赖 QGIS/Blender 自己打的日志
- **`result.error``result.status` 分别检查**前者是启动失败ENOENT 等),
后者是运行失败,混在一起会丢信息
- **带上 `signal`**Blender 被 OOM killer 干掉时 `status` 是 null只有 `signal`
能说明发生了什么
`build-osm2streets-qgis.js` / `reimport-gpkg.js` 内部用 `execFileSync`(同步、非零
自动抛),也一律 `stdio: "inherit"`
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 给 `ogr2ogr``COORDINATE_PRECISION` | 静默丢顶点 |
| 外部工具产物直接写目标目录 | 失败时用 0 字节文件覆盖好数据 |
| `execFileSync` 不带 `qgisEnv()` | 坐标系静默出错 |
| `stdio: "pipe"` 或吞掉输出 | 失去唯一的调试信息来源 |
| 只判 `status !== 0`,不看 `result.error` / `signal` | 启动失败和被信号杀死都变成同一句错 |
| 用 `rename` 从临时目录搬文件 | 跨文件系统时 EXDEV |
| 依赖 `PATH` 里的 `ogr2ogr` | 抓到系统 GDAL版本与 QGIS 不匹配 |
| 先建目录/删文件再校验参数 | 配置写错也会破坏已有输出 |
---
## 相关
- [CLI 与阶段](./cli-and-stages.md):这些调用被哪个阶段发起
- [图层表](./layer-registry.md):进出 GeoPackage 的九个图层从哪来
- [区域配置](../config/index.md)`qgisApp` / `blenderApp` 的配置位置

View File

@@ -0,0 +1,97 @@
# PipelineNode 构建管线
> 覆盖 `scripts/*.js` 与 `scripts/lib/scene-layers.js`。
> 运行时:宿主机 NodeCommonJS无构建步骤
> 这是管线里**唯一**能启动外部进程的层。
---
## 先读哪一篇
| 你要做的事 | 读 |
|---|---|
| 改九个 osm2streets 图层(增/删/改顺序/改色) | [图层表](./layer-registry.md) ← **最容易出静默错误** |
| 调 QGIS / GDAL / Blender 子进程 | [外部工具调用](./external-tools.md) |
| 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) |
| 改预览页生成 | [../preview/](../preview/index.md) |
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |
---
## 数据流全景
```
config/areas/<id>.json
▼ build-area.js — normalizeAreaConfig() 推导全部输出路径
_pipeline/osm2streets-qgis.config.json (派生配置)
├─[intermediates]─▶ build-osm2streets-qgis.js
│ osm2streets-js-node 解析 .osm
│ → splitLayers() 拆成九个图层
│ → normalize-lane-arrows.pyQGIS Python
│ → osm2streets_web_out/*.geojson
│ → osm2streets_scene.geojson + _scene_style.json
│ → ogr2ogr 导入 <id>.gpkg
│ → QGIS 生成 .qgz + -preview.png
├─[reimport]──────▶ reimport-gpkg.js (反向,与 intermediates 互斥)
│ ogr2ogr 从 .gpkg 导出 → 校验 → 覆写 *.geojson
│ → 重建 scene.geojson + scene_style.json
├─[blender]───────▶ Blender + blender/generate_scene.py
│ 读 .osm + osm2streets_web_out/
│ → <id>.blend + <id>.png
├─[cesium]────────▶ Blender + blender/export_cesium.py
│ 读 .blend → <id>.glb + <id>.json
│ → 并自动执行 preview
└─[preview]───────▶ 生成 <id>-cesium-preview.html
+ 拷贝 lib/cesium-preview.{js,css}
+ 车辆巡航路线与模型
```
**阶段之间只通过磁盘产物耦合**不传内存状态。这是单跑任意阶段能work 的前提。
---
## 三条贯穿全层的约定
1. **单一事实源优先于同步**
九个图层的定义在 `lib/scene-layers.js`,四个派生函数覆盖了全部合法用法。看到第二处
枚举这些图层,就是 bug 温床。详见 [图层表](./layer-registry.md)。
2. **有副作用之前先把能验的都验完**
数值参数 → 输入文件 → 外部可执行文件 → 才 `mkdirSync`
`build-osm2streets-qgis.js:41-70`
3. **外部工具的产物先落 staging校验通过才覆盖**
`ogr2ogr` 失败会留 0 字节文件。详见 [外部工具调用](./external-tools.md)。
---
## 文件速查
| 文件 | 行数 | 职责 |
|---|---|---|
| `build-area.js` | 774 | 主入口配置归一化、阶段调度、Cesium 预览页与车辆巡航生成 |
| `build-osm2streets-qgis.js` | 1468 | intermediatesosm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 |
| `reimport-gpkg.js` | 179 | reimportGeoPackage → GeoJSON 反向导出 |
| `lib/scene-layers.js` | 164 | 九个图层的单一事实源 + 四个派生函数 |
| `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) |
| `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) |
| `parity.js` | 270 | 产物一致性校验驱动 |
| `glb-digest.js` | 121 | GLB 结构摘要 |
---
## 技术选型现状
- **CommonJS无构建、无 TypeScript、无 lint 配置**。保持现状;引入工具链是独立决定,
不要夹带在功能改动里
- **零运行时依赖**`osm2streets-js-node` 是唯一 dependency。加依赖前先确认标准库
真的做不到
- **同步 API 优先**`execFileSync` / `spawnSync` / `readFileSync`)。这是一次性跑完
的批处理工具,不是服务,异步只会增加错误处理复杂度
- **macOS 专用路径假设**`.app/Contents/MacOS/...`)。跨平台不在当前范围内

View File

@@ -0,0 +1,164 @@
# 图层表:跨语言的单一事实源
> 适用:改动 osm2streets 九个渲染图层的任何一方——新增图层、删图层、调顺序、调
> 颜色、调高度。**动手前必读**,这里的错误不会报错,只会让产物静默错栈。
---
## 一句话
九个图层在 **JS 和 Python 各存一份表**,两份**故意只同步"集合与顺序"、不同步颜色**
一致性靠运行时的 `catalog.check_layers()` 用产物文件对账。
---
## 两份表分别管什么
| | JS 侧 | Python 侧 |
|---|---|---|
| 位置 | `scripts/lib/scene-layers.js:15` `SCENE_LAYERS` | `blender/osmassets/catalog.py:28` `ROAD_LAYERS` |
| 服务于 | 2D 调试链路GeoJSON 拆分、GeoPackage 导入、QGIS 工程符号 | 3D 场景链路Blender 材质与几何高度 |
| 关键字段 | `id``splitKey``zIndex``title``fill`/`outline`sRGB hex | `id``material``color`(线性 RGB`z`(米) |
| 消费点 | `build-osm2streets-qgis.js:91,98,109,1315``reimport-gpkg.js:53,63,77` | `generate_scene.py:759,844` |
`id` 是两侧唯一的连接键,同时也是 GeoJSON 文件名的 stem`layerFile()`
`<id>.geojson`,见 `scene-layers.js:103`)。
## 这份表从前复制了四遍
`scene-layers.js:3-13` 的注释写明了它存在的理由:同一批图层的顺序曾同时躺在
merged-scene 的 z_index 表、场景样式 JSON、生成的 QGIS 工程、以及 README 的手工重建
片段里。加一个图层要同步改四处,漏一处**不报错**,只是下游 Blender/Cesium 里的场景
悄悄错栈。
`catalog.py:3-7` 记录的是 Python 侧的同一个病:九个图层的绘制顺序在 JS、Blender 高度
在一个 `layer_z` dict、颜色在一个 `road_mats` dict——两种语言三份拷贝手工对齐。
**推论**:看到任何地方开始第二次枚举这九个图层,那就是 bug 的温床,改成从这两份表
之一派生。
## 为什么颜色刻意不同步
`catalog.py:11-15` 明确列为"deliberate non-goal"
- `scene-layers.js``fill` 是给 **QGIS 2D 调试地图**用的 sRGB hex
- `catalog.py``color` 是给 **Blender 3D 场景**用的线性 RGB
- 两套值是**分别调出来的**,不存在换算关系
所以 `check_layers()` 只校验图层的**集合与顺序**——那是必须一致的部分——**不碰调色板**。
> 不要"顺手统一"两边的颜色。那不是清理重复,是把两个独立的设计意图合并成一个错的。
## 对账机制
桥梁是产物文件 `osm2streets_scene_style.json`(两侧常量都叫 `SCENE_STYLE_FILE`
`scene-layers.js:101``catalog.py:49`
```
JS 侧 sceneStyle() ──写──▶ osm2streets_scene_style.json ──读──▶ catalog.check_layers()
scene-layers.js:126 (落在 geojson 输出目录) catalog.py:162
```
写入点:`build-osm2streets-qgis.js:100`intermediates 阶段)、
`reimport-gpkg.js:79`reimport 阶段)。
读取点:`generate_scene.py:842`,每次构建场景时执行。
`check_layers()` 报三类问题(`catalog.py:183-194`
1. style 里有、`ROAD_LAYERS` 里没有 → 该图层**到不了 3D 场景**
2. `ROAD_LAYERS` 里有、style 里没有 → **不会有 GeoJSON 产出**给它
3. 集合相同但顺序不同 → 打印两侧的实际顺序
**这是 warn 不是 fail**`catalog.py:168-169` 写明理由):过期或缺失的输出目录不该
阻断一次重建。所以——
> 构建日志里的 `Layer catalog warning:` 不是噪音。它是这套双表设计**唯一**的自动
> 报警,被忽略就等于没有。
## 顺序是承重的
`catalog.py:16-18`
- **材质创建顺序固定了导出 GLB 里的材质索引**
- 图层顺序固定了 mesh 创建顺序
所以 `ROAD_LAYERS``MATERIALS`**list 不是 dict****追加是唯一安全的编辑**。
在中间插入一个图层会平移其后所有材质索引——GLB 结构变了parity 校验会红,
Cesium 侧引用的材质会错位。
JS 侧的 `zIndex` 同样兼作绘制顺序(`scene-layers.js:12`**最小值先画、位于栈底**。
它同时是写进每个 feature 的 `z_index` 属性(`mergeScene()``scene-layers.js:117-119`)。
## 派生函数:只加派生,不要加第二份枚举
`scene-layers.js` 导出的四个派生函数是这份表的全部合法用法:
| 函数 | 位置 | 用途 |
|---|---|---|
| `layerFile(layer)` | `:103` | `<id>.geojson` 文件名 |
| `mergeScene(getCollection)` | `:109` | 合成 `osm2streets_scene.geojson`,逐 feature 打上 `render_layer` / `z_index` |
| `sceneStyle()` | `:126` | 生成对账用的 style JSON |
| `qgisRgba(hex, alpha)` | `:143` | hex → QGIS 要的 `"r,g,b,a"` 字符串 |
`mergeScene` 收的是**回调**而不是数组,这样 build 阶段(从内存的 split 取)和
reimport 阶段(从磁盘读回)能共用同一套合并逻辑(`scene-layers.js:107-108`)。
新增第三种数据来源时沿用这个模式,不要复制合并循环。
`outline: null` 表示无描边QGIS 侧由 `qgisRgba` 转成全透明(`scene-layers.js:13-14,145`)。
Python 侧同理:`road_material_specs()``catalog.py:156`)把 `ROAD_LAYERS` 转成
`MATERIALS` 形状的规格,`generate_scene.py:759``zip` 与图层配对——保持这条派生链,
不要在 `generate_scene.py` 里另起一份材质名列表。
---
## 改动清单
### 新增一个图层
1. `scene-layers.js:SCENE_LAYERS` **末尾追加**`id``splitKey``zIndex`(大于现有
最大值)、`title``fill``outline``outlineWidth`
2. 确认 osm2streets 的拆分结果里确实有 `splitKey` 对应的键
`build-osm2streets-qgis.js:92``split[layer.splitKey]`
3. `catalog.py:ROAD_LAYERS` **末尾追加**`id`(与第 1 步一致)、`material`(新名字)、
`color`(线性 RGB独立调`z`(米,高于前一层避免 z-fighting
4. 跑一次 `intermediates` + `blender`,确认日志里**没有** `Layer catalog warning:`
5. 该图层的 GeoPackage 导入、QGIS 符号、场景合并、reimport 全部自动跟上,**无需**再
`reimport-gpkg.js` 或 QGIS 工程生成代码
### 删除一个图层
两侧同时删。只删一侧的话 `check_layers()` 会 warn但构建**照常出产物**——一份少了
该图层的产物。
### 调整顺序
`zIndex` 的同时必须把 `ROAD_LAYERS` 的**元素位置**也调成一致。注意这会移动材质
索引,属于会改变产物的变更,**必须跑 parity 校验**,见
[产物一致性指南](../guides/artifact-parity-guide.md)。
### 只调颜色
改一侧即可,不要同步到另一侧(见上文"为什么颜色刻意不同步")。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 在 `generate_scene.py` / QGIS 生成代码里硬编码图层名列表 | 回到"复制四份"的旧病 |
| 从 `scene-layers.js``fill` 换算 Blender 的 `color` | 抹掉两套独立调过的配色 |
| 在 `ROAD_LAYERS` / `MATERIALS` **中间**插入条目 | GLB 材质索引整体平移 |
| 把 `ROAD_LAYERS` / `MATERIALS` 改成 dict | 顺序语义丢失,见 `catalog.py:16-18` |
| 把 `check_layers()` 从 warn 改成 raise | 输出目录过期就无法重建 |
| 忽略 `Layer catalog warning:` | 双表设计唯一的报警失效 |
---
## 相关
- [外部工具调用](./external-tools.md):图层如何进出 GeoPackage
- [CLI 与阶段](./cli-and-stages.md):哪个阶段写、哪个阶段读这些文件
- [Blender 资产生成](../blender/asset-generation.md)`MATERIALS` 的其余部分
- [产物一致性指南](../guides/artifact-parity-guide.md):改动顺序后如何验证

View File

@@ -0,0 +1,219 @@
# PreviewCesium 预览层
> 覆盖 `scripts/lib/cesium-preview.js`672 行)与 `cesium-preview.css`230 行)。
> 运行时:浏览器。全仓唯一的 DOM 环境。
---
## 定位
预览层是**验证性的,不是产物本身**。它加载 `cesium` 阶段导出的 `.glb` + `.json`
用来确认资产在真实 Cesium 里的样子。改这一层**不会**改变 Blender/GLB 主资产。
车辆巡航同理——README 里写明它是"用于验证高精度巡航可用性的预览层功能"。
---
## 没有构建步骤
```
scripts/lib/cesium-preview.js ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.js
scripts/lib/cesium-preview.css ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.css
build-area.js:328-335
<area>-cesium-preview.html ─── 模板字符串生成 ────▶ 同目录
build-area.js:697
```
所以:**没有打包、没有转译、没有 npm 依赖、没有模块系统**。浏览器直接吃。
写代码时只能用目标浏览器原生支持的语法,`Cesium` 从 CDN 全局引入。
整个文件是一个 IIFE + `"use strict"``cesium-preview.js:1-2`)。
---
## 参数注入
JS 不硬编码任何文件名,全部从 HTML 注入的全局对象读:
```js
const config = window.OSM_ASSET_PREVIEW_CONFIG || {}; // :4
// config.areaId / .glbName / .metadataName / .routeName / .vehicleModelName
```
生成侧在 `build-area.js:697 cesiumPreviewHtml()`,注入时**必须转义**
| 场景 | 用 |
|---|---|
| HTML 文本/属性 | `escapeHtml()``build-area.js:759` |
| `<script>` 里的 JSON | `escapeScriptJson()``:767` |
`|| {}` 的兜底不能删——它让 JS 在没有配置块时也不至于在第一行就崩。
**加一个新的可配置项**`cesiumPreviewHtml()` 里加进注入的 JSONJS 侧从 `config` 读,
两边都要动。
---
## 加载流程
`main()``:30-52`)的顺序是刻意的:
```
setLoadingMessage("Loading scene")
→ fetchJson(metadata) 必需,失败即终止
→ fetchOptionalJson(route) 可选,失败降级
→ scenePlacement(metadata)
→ createViewer()
setLoadingMessage("Loading model")
→ loadSceneAssets() 逐个资产加载,失败收集不中断
→ addVehicleCruises() / createCameraPresets()
→ buildAssetToggles() / bindRuntimeControls() / startDiagnostics()
→ cameras.overview()
→ baseStatus = summaryText(...)
setLoadingMessage("Preparing view")
→ await waitForStableFrames() 等画面稳定
→ document.body.classList.add("scene-ready") ← CSS 靠这个类收起遮罩
→ window.osmPreview = {...}
```
### 三档失败语义
这一层的错误处理分得很清楚,**新增加载逻辑时要先想清楚落在哪一档**
| 档 | 做法 | 出处 |
|---|---|---|
| **必需** | `fetchJson` 直接抛,预览起不来 | `:55-62` |
| **可选** | `fetchOptionalJson` 捕获 → `console.warn` → 返回 `null` | `:63-73` |
| **部分** | 逐条收集失败,汇总到诊断面板,其余照常显示 | `:163-165` |
两段注释把理由写清楚了:
> The route file is an extra on top of the scene, not a precondition for it.
> A missing or unreadable route costs the cruise controls, not the preview.
> One broken entry in metadata.assets should not blank the whole preview, so
> failures are collected and surfaced in the diagnostics panel instead.
### `scene-ready` 是加载态的唯一开关
`waitForStableFrames()``:107`)等若干帧稳定后才加 `scene-ready`CSS 据此
收起遮罩。**不要改成定时器或 `load` 事件**——材质编译完成之前画面是花的。
### `window.osmPreview` 是唯一对外句柄
```js
// Handle for the browser console and for headless checks: everything else
// in here is closed over by the IIFE and unreachable from outside. :50-51
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
```
调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。
---
## Viewer 配置:一切都关掉
`createViewer()``:74-97`)把 Cesium 的默认 UI 和地球全部关闭:
```js
animation, timeline, baseLayerPicker, geocoder,
navigationHelpButton, sceneModePicker, infoBox,
selectionIndicator, baseLayer false
globe.show = false 不显示地球
skyAtmosphere / skyBox / sun / moon false
backgroundColor = globe.baseColor = "#d9e0e2" PREVIEW_BACKGROUND
```
理由:这是**看单个园区资产**的预览,不是地图应用。留着地球和大气会干扰对
材质与几何的判断,也让背景色不可控。
`depthTestAgainstTerrain = false` —— 没有地形,开着只会让模型被裁。
保留的只有 `homeButton``fullscreenButton`
---
## DOM 与状态
### DOM 句柄集中在顶部
`:5-20` 一次性取完全部元素引用,**不在函数里现查**。新增控件时加在这一批里。
### status 的两类消息
```js
// Status carries two kinds of message: the scene summary, which is what the
// panel should read whenever nothing else is going on, and transient notes
// from a control the user just touched. Keep the summary so the transient
// note can be replaced instead of destroying it. :21-24
let baseStatus = "";
```
`baseStatus` 存场景摘要,瞬时提示用完要能回到它。**加新的瞬时提示时不要直接覆写
`baseStatus`**。
### 诊断读数跟渲染循环,不跟定时器
```js
// Camera-dependent readouts have to track the camera, so refresh off the
// render loop rather than a fixed timer, throttled to stay off the hot path. :589-590
```
相机相关的读数必须跟着渲染循环刷新并**做节流**。用 `setInterval` 会在相机快速移动时
读到过期值,不节流会拖慢帧率。
### 单资产 vs 多资产的开关
```js
// A single-asset scene keeps the plain "Scene" checkbox; a multi-asset one
// gets a child checkbox per model with "Scene" acting as the master. :194-195
```
`buildAssetToggles()` 按资产数量决定 UI 形态,`syncSceneMaster()` 维护主从关系。
---
## 坐标系
GLB 停留在**局部 ENU 坐标系**X 东、Y 北、Z 上),靠伴生 JSON 的放置信息配合
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆到地球上
`blender/export_cesium.py:8-9`)。
`scenePlacement(metadata)``:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。**
---
## 本地预览必须走 HTTP
```bash
cd outputs/<area-id>
python3 -m http.server 8765
# → http://localhost:8765/<area-id>-cesium-preview.html
```
`file://` 会被浏览器的同源策略拦掉 `fetch`,页面停在加载遮罩上。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 引入需要打包/转译的语法或 npm 依赖 | 没有构建步骤,直接跑不起来 |
| 在 JS 里硬编码 `.glb` / `.json` 文件名 | 换区域就失效,绕过 config 注入 |
| 注入 HTML 时不转义 | 区域名带特殊字符就破页面 |
| 把可选资源当必需资源加载 | 缺一个路线文件整个预览打不开 |
| 单个资产加载失败就中断全部 | 一条坏 metadata 让预览全白 |
| 用定时器代替 `waitForStableFrames` | 遮罩在材质编译完成前就收起,画面是花的 |
| 相机读数用 `setInterval` | 快速移动时读到过期值 |
| 诊断刷新不节流 | 拖慢帧率 |
| 再开一个全局变量 | 已有 `window.osmPreview` |
| 用 `file://` 打开 | fetch 被拦,卡在加载中 |
---
## 相关
- [CLI 与阶段](../pipeline/cli-and-stages.md)`cesium` / `preview` 阶段如何生成这些文件
- [资产生成](../blender/asset-generation.md)GLB 里的材质为什么要单独调色
- README「实验车辆巡航」节面向使用者的说明