# 外部工具调用 > 适用:任何调用 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` | `/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 调用 ### macOS Blender 4.5 的 Metal 启动兼容 #### 1. Scope / Trigger `export_cesium.py` 在 macOS 的 Blender 4.5.12 后台启动时,可能在 Python 脚本加载前的 Metal 扩展探测中崩溃;这不是场景或道路数据错误。 #### 2. Signatures Cesium 阶段的调用参数必须包含: ```text --background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py -- ... ``` #### 3. Contracts `--debug-gpu-force-workarounds` 是 Blender 的官方 CLI 参数。它只约束导出进程的 GPU 扩展探测,不改变 `.blend`、GeoJSON 或导出脚本的输入输出契约。 #### 4. Validation & Error Matrix | 情况 | 结果 | |---|---| | 缺少该参数且启动时崩在 Metal 初始化 | 不应归因于道路数据;补齐参数后重跑 Cesium 阶段 | | 参数存在且 `CESIUM_EXPORT_DONE` / stage manifest 写出 | 继续 GLB digest 与预览验证 | #### 5. Good / Base / Bad Cases - Good: 保留 `--factory-startup`,并在 Cesium 导出加入 workaround。 - Base: Blender 场景阶段未受影响时,不额外改变其启动参数。 - Bad: 为绕过启动崩溃删除 `--factory-startup`,这会重新引入本机偏好和 addon 的不确定性。 #### 6. Tests Required - `npm run test:build-stages` 断言导出参数仍包含 workaround。 - 对目标区域运行 `--stages blender,cesium,preview`,并用 `glb-digest.js` 解析输出。 #### 7. Wrong vs Correct Wrong: ```text --background --python blender/export_cesium.py ``` Correct: ```text --background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py ``` ### 两种调用姿势 | 阶段 | 参数 | 出处 | |---|---|---| | `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` 的配置位置