Files
osmWorkflow/.trellis/spec/pipeline/external-tools.md

201 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 外部工具调用
> 适用:任何调用 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 --factory-startup --python export_cesium.py --` | `build-area.js:276-280` |
**两个后台阶段都使用 `--factory-startup`**:它屏蔽用户的 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` 的配置位置