Initialize Trellis project guidelines
This commit is contained in:
190
.trellis/spec/pipeline/cli-and-stages.md
Normal file
190
.trellis/spec/pipeline/cli-and-stages.md
Normal 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「主流程」节:面向使用者的命令示例(**不要**复制到这里)
|
||||
200
.trellis/spec/pipeline/external-tools.md
Normal file
200
.trellis/spec/pipeline/external-tools.md
Normal 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` 的配置位置
|
||||
97
.trellis/spec/pipeline/index.md
Normal file
97
.trellis/spec/pipeline/index.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Pipeline:Node 构建管线
|
||||
|
||||
> 覆盖 `scripts/*.js` 与 `scripts/lib/scene-layers.js`。
|
||||
> 运行时:宿主机 Node(CommonJS,无构建步骤)。
|
||||
> 这是管线里**唯一**能启动外部进程的层。
|
||||
|
||||
---
|
||||
|
||||
## 先读哪一篇
|
||||
|
||||
| 你要做的事 | 读 |
|
||||
|---|---|
|
||||
| 改九个 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.py(QGIS 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 | intermediates:osm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 |
|
||||
| `reimport-gpkg.js` | 179 | reimport:GeoPackage → 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/...`)。跨平台不在当前范围内
|
||||
164
.trellis/spec/pipeline/layer-registry.md
Normal file
164
.trellis/spec/pipeline/layer-registry.md
Normal 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):改动顺序后如何验证
|
||||
Reference in New Issue
Block a user