201 lines
7.5 KiB
Markdown
201 lines
7.5 KiB
Markdown
# 外部工具调用
|
||
|
||
> 适用:任何调用 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` 的配置位置
|