diff --git a/.trellis/spec/blender/index.md b/.trellis/spec/blender/index.md index 1ea6d84..59a7aba 100644 --- a/.trellis/spec/blender/index.md +++ b/.trellis/spec/blender/index.md @@ -61,7 +61,7 @@ | | `generate_scene.py` | `export_cesium.py` | |---|---|---| | 行数 | 895 | 647 | -| 调用 | `--background --factory-startup --python` | `--background --python` | +| 调用 | `--background --factory-startup --python` | `--background --factory-startup --python` | | 输入 | `--osm` + `--geojson`(可选) | `--blend` | | 输出 | `--output`(.blend)、`--render`(.png) | `--glb`、`--metadata`(.json) | | 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` | @@ -70,9 +70,9 @@ 两个 stdout 标记是 [parity 契约](../guides/artifact-parity-guide.md)的一部分 (`scripts/parity.js:121` 解析它们),**改动打印格式等于改动契约**。 -### `--factory-startup` 只在 generate 阶段用 +### 后台阶段使用 `--factory-startup` -它屏蔽本机 Blender 的 preferences 和 addon,保证场景生成不受用户配置影响。 +它屏蔽本机 Blender 的 preferences 和 addon,保证场景生成与 Cesium 导出不受用户配置影响。 副作用是脚本自己的目录不在 `sys.path` 上,所以两个入口开头都有那段 `sys.path.insert` 样板 + `# noqa: E402`——**不是可以整理掉的坏味道**。 diff --git a/.trellis/spec/pipeline/external-tools.md b/.trellis/spec/pipeline/external-tools.md index 207efff..8e53cb4 100644 --- a/.trellis/spec/pipeline/external-tools.md +++ b/.trellis/spec/pipeline/external-tools.md @@ -125,10 +125,10 @@ SCENE_LAYERS.forEach((layer, index) => { | 阶段 | 参数 | 出处 | |---|---|---| | `blender` | `--background --factory-startup --python generate_scene.py --` | `build-area.js:242-247` | -| `cesium` | `--background --python export_cesium.py --` | `build-area.js:276-280` | +| `cesium` | `--background --factory-startup --python export_cesium.py --` | `build-area.js:276-280` | -**`--factory-startup` 只在 generate 阶段用**:它屏蔽用户的 preferences 和 addon, -保证场景生成不受本机 Blender 配置影响。export 阶段不带,因为它要读已经建好的 `.blend`。 +**两个后台阶段都使用 `--factory-startup`**:它屏蔽用户的 preferences 和 addon, +保证场景生成与导出不受本机 Blender 配置影响。export 阶段仍会显式读取已经建好的 `.blend`。 `--` 之后才是脚本自己的参数,Blender 不解析它们。脚本侧用 `sys.argv[sys.argv.index("--") + 1:]` 取。 diff --git a/README.md b/README.md index 12ab47a..4a3a575 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,14 @@ 需要: -- macOS QGIS:默认 `/Applications/QGIS.app` -- Blender:默认 `/Applications/Blender.app` +- QGIS 与 GDAL 工具链 +- Blender - Node.js / npm +macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender +`/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为 +`/usr`),`blenderApp` 是可执行文件路径(通常为 `/usr/bin/blender`)。 + 首次使用: ```bash diff --git a/blender/generate_scene.py b/blender/generate_scene.py index 0fd6144..c0d6153 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -594,7 +594,8 @@ def clear_scene(): def configure_scene(): scene = bpy.context.scene - scene.render.engine = "BLENDER_EEVEE_NEXT" + engines = scene.render.bl_rna.properties["engine"].enum_items.keys() + scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE" scene.render.resolution_x = 1200 scene.render.resolution_y = 900 scene.render.resolution_percentage = 100 diff --git a/blender/tools/split_lowpoly_cars.py b/blender/tools/split_lowpoly_cars.py index 8c172bc..073edd7 100644 --- a/blender/tools/split_lowpoly_cars.py +++ b/blender/tools/split_lowpoly_cars.py @@ -209,7 +209,8 @@ def render_preview(objects, path): light.location = location light.rotation_euler = (0, 0, 0) light.rotation_euler = (target - light.location).to_track_quat("-Z", "Y").to_euler() - scene.render.engine = "BLENDER_EEVEE_NEXT" + engines = scene.render.bl_rna.properties["engine"].enum_items.keys() + scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE" scene.render.resolution_x = PREVIEW_SIZE scene.render.resolution_y = PREVIEW_SIZE scene.render.resolution_percentage = 100 diff --git a/package-lock.json b/package-lock.json index 4948f1f..c180020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ }, "node_modules/osm2streets-js-node": { "version": "0.1.4", - "resolved": "http://172.16.1.86:4873/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz", + "resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz", "integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==", "license": "Apache-2.0" } diff --git a/scripts/build-area.js b/scripts/build-area.js index 9bdfc01..5dbfab0 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); const { readAreaConfig } = require("./lib/area-config"); +const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths"); const { SCENE_LAYERS, SCENE_FILE, @@ -314,6 +315,7 @@ function exportCesium(area) { const startedAt = new Date(started).toISOString(); runCommand(blenderExecutable(area), [ "--background", + "--factory-startup", "--python", path.join(repoRoot, "blender", "export_cesium.py"), "--", @@ -440,7 +442,7 @@ function compressCesiumGlb(area) { } function blenderExecutable(area) { - return path.join(area.blenderApp, "Contents", "MacOS", "Blender"); + return resolveBlenderExecutable(area.blenderApp); } function ensureFile(file, label) { diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index 7695efe..47ce5ee 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -5,6 +5,7 @@ const path = require("path"); const os = require("os"); const { execFileSync } = require("child_process"); const { JsStreetNetwork } = require("osm2streets-js-node"); +const { qgisPaths } = require("./lib/tool-paths"); const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows"); const { SCENE_LAYERS, @@ -21,9 +22,9 @@ const args = parseArgs(process.argv.slice(2)); const configPath = path.resolve(args.config || path.join(repoRoot, "config", "default.json")); const config = loadConfig(configPath, args); const qgisApp = config.qgisApp; -const qgisMacOS = path.join(qgisApp, "Contents", "MacOS"); -const qgisPython = path.join(qgisMacOS, "python3.12"); -const ogr2ogr = path.join(qgisMacOS, "ogr2ogr"); +const qgis = qgisPaths(qgisApp); +const qgisPython = qgis.python; +const ogr2ogr = qgis.ogr2ogr; const normalizeLaneArrowsScript = path.join(repoRoot, "scripts", "normalize-lane-arrows.py"); const inputPath = path.resolve(config.input); const outDir = path.resolve(config.outDir); @@ -128,7 +129,7 @@ fs.writeFileSync( if (fs.existsSync(gpkgPath)) { fs.unlinkSync(gpkgPath); } -const ogrEnv = qgisEnv(); +const ogrEnv = qgis.env; // First layer creates the GeoPackage; the rest append into it. SCENE_LAYERS.forEach((layer, index) => { importLayer(gpkgPath, path.join(outDir, layerFile(layer)), layer.id, index > 0, ogrEnv); @@ -141,7 +142,7 @@ const defaultPreviewExtent = extentString(expandBounds( previewPad, )); fs.writeFileSync(qgisScript, makeQgisScript({ - qgisMacOS, + qgisPrefix: qgis.prefix, gpkgPath, projectPath, previewPath, @@ -154,17 +155,9 @@ execFileSync(qgisPython, [qgisScript], { stdio: "inherit", env: { ...process.env, - ...qgisEnv(), + ...qgis.env, QT_QPA_PLATFORM: "offscreen", - PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"), - PYTHONPATH: [ - path.join(qgisApp, "Contents", "Resources", "python"), - path.join(qgisApp, "Contents", "Resources", "python3.11", "site-packages"), - ].join(":"), - DYLD_LIBRARY_PATH: [ - qgisMacOS, - path.join(qgisApp, "Contents", "Frameworks"), - ].join(":"), + ...qgis.pythonEnv, }, }); fixCanvas(projectPath, config.canvasExtent || extentString(expandBounds(bbox, canvasPad))); @@ -252,13 +245,6 @@ function deepMerge(base, overrides) { return out; } -function qgisEnv() { - return { - PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"), - GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"), - }; -} - function getOsmBounds(xmlText) { // OSM exports may contain distant relation members (for example subway // nodes) that are not part of the requested map extent. Prefer the explicit @@ -1418,13 +1404,9 @@ function normalizeLaneArrows(geojsonPath, outlineSimplifyMeters) { stdio: "inherit", env: { ...process.env, - ...qgisEnv(), + ...qgis.env, QT_QPA_PLATFORM: "offscreen", - PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"), - PYTHONPATH: [ - path.join(qgisApp, "Contents", "Resources", "python"), - path.join(qgisApp, "Contents", "Resources", "python", "plugins"), - ].join(path.delimiter), + ...qgis.pythonEnv, }, }); } @@ -1464,7 +1446,7 @@ from qgis.core import ( QgsVectorLayer, ) -QGIS_PREFIX = ${JSON.stringify(options.qgisMacOS)} +QGIS_PREFIX = ${JSON.stringify(options.qgisPrefix)} GPKG = ${JSON.stringify(options.gpkgPath)} PROJECT_PATH = ${JSON.stringify(options.projectPath)} PREVIEW_PATH = ${JSON.stringify(options.previewPath)} @@ -1472,6 +1454,11 @@ PREVIEW_EXTENT = [${options.previewExtent.split(",").map(Number).join(", ")}] LAYER_PREFIX = ${JSON.stringify(options.layerPrefix || "osm2streets")} LAYER_SPECS = ${JSON.stringify(qgisLayerSpecs(), null, 4)} +try: + IMAGE_FORMAT = QImage.Format.Format_ARGB32_Premultiplied +except AttributeError: + IMAGE_FORMAT = QImage.Format_ARGB32_Premultiplied + def fill_symbol(color, outline="0,0,0,0", outline_width="0"): return QgsFillSymbol.createSimple({ "color": color, @@ -1523,7 +1510,7 @@ settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326")) settings.setExtent(QgsRectangle(*PREVIEW_EXTENT)) settings.setOutputSize(QSize(1600, 1100)) settings.setBackgroundColor(QColor(245, 245, 240)) -image = QImage(settings.outputSize(), QImage.Format_ARGB32_Premultiplied) +image = QImage(settings.outputSize(), IMAGE_FORMAT) image.fill(settings.backgroundColor().rgba()) painter = QPainter(image) job = QgsMapRendererCustomPainterJob(settings, painter) diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index e81ecc4..9281602 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -60,8 +60,8 @@ function normalizeAreaConfig(raw, options = {}) { id, input, outputRoot, - qgisApp: raw.qgisApp || "/Applications/QGIS.app", - blenderApp: raw.blenderApp || "/Applications/Blender.app", + qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"), + blenderApp: raw.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"), stages: { intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true, blender: raw.stages?.blender ?? true, diff --git a/scripts/lib/tool-paths.js b/scripts/lib/tool-paths.js new file mode 100644 index 0000000..ba3fb06 --- /dev/null +++ b/scripts/lib/tool-paths.js @@ -0,0 +1,58 @@ +"use strict"; + +const path = require("path"); + +function qgisPaths(qgisApp) { + if (process.platform === "darwin") { + const macosDir = path.join(qgisApp, "Contents", "MacOS"); + const resourcesDir = path.join(qgisApp, "Contents", "Resources"); + return { + python: path.join(macosDir, "python3.12"), + ogr2ogr: path.join(macosDir, "ogr2ogr"), + ogrinfo: path.join(macosDir, "ogrinfo"), + prefix: macosDir, + env: { + PROJ_LIB: path.join(resourcesDir, "qgis", "proj"), + GDAL_DATA: path.join(resourcesDir, "qgis", "gdal"), + }, + pythonEnv: { + PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"), + PYTHONPATH: [ + path.join(resourcesDir, "python"), + path.join(resourcesDir, "python3.11", "site-packages"), + ].join(path.delimiter), + DYLD_LIBRARY_PATH: [macosDir, path.join(qgisApp, "Contents", "Frameworks")].join(path.delimiter), + }, + }; + } + + const binDir = path.join(qgisApp, "bin"); + const shareDir = path.join(qgisApp, "share"); + return { + python: path.join(binDir, "python3"), + ogr2ogr: path.join(binDir, "ogr2ogr"), + ogrinfo: path.join(binDir, "ogrinfo"), + prefix: qgisApp, + env: { + PROJ_LIB: path.join(shareDir, "proj"), + GDAL_DATA: path.join(shareDir, "gdal"), + }, + pythonEnv: { + PYTHONPATH: [ + path.join(shareDir, "qgis", "python"), + path.join(shareDir, "qgis", "python", "plugins"), + ].join(path.delimiter), + }, + }; +} + +function blenderExecutable(blenderApp) { + return process.platform === "darwin" + ? path.join(blenderApp, "Contents", "MacOS", "Blender") + : blenderApp; +} + +module.exports = { + blenderExecutable, + qgisPaths, +}; diff --git a/scripts/parity.js b/scripts/parity.js index 55a5826..a13554c 100644 --- a/scripts/parity.js +++ b/scripts/parity.js @@ -18,6 +18,7 @@ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { spawnSync } = require("child_process"); +const { blenderExecutable } = require("./lib/tool-paths"); const repoRoot = path.resolve(__dirname, ".."); const baselineRoot = path.join(repoRoot, "outputs", "_refactor-baseline"); @@ -169,8 +170,8 @@ function fileStat(file) { } function blendDigest(config, blend, outFile) { - const blenderApp = config.blenderApp || "/Applications/Blender.app"; - const blender = path.join(blenderApp, "Contents", "MacOS", "Blender"); + const blenderApp = config.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"); + const blender = blenderExecutable(blenderApp); const run = spawnSync(blender, [ "--background", "--factory-startup", "--python", path.join(repoRoot, "blender", "tools", "scene_digest.py"), diff --git a/scripts/reimport-gpkg.js b/scripts/reimport-gpkg.js index 51bf92b..85c39ab 100755 --- a/scripts/reimport-gpkg.js +++ b/scripts/reimport-gpkg.js @@ -16,6 +16,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); const { execFileSync } = require("child_process"); +const { qgisPaths } = require("./lib/tool-paths"); const { SCENE_LAYERS, SCENE_FILE, @@ -27,10 +28,10 @@ const { const args = parseArgs(process.argv.slice(2)); const config = loadConfig(args); -const qgisApp = config.qgisApp || "/Applications/QGIS.app"; -const qgisMacOS = path.join(qgisApp, "Contents", "MacOS"); -const ogr2ogr = path.join(qgisMacOS, "ogr2ogr"); -const ogrinfo = path.join(qgisMacOS, "ogrinfo"); +const qgisApp = config.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"); +const qgis = qgisPaths(qgisApp); +const ogr2ogr = qgis.ogr2ogr; +const ogrinfo = qgis.ogrinfo; const outDir = path.resolve(requireText(config.outDir, "outDir")); const gpkgPath = path.resolve(requireText(config.gpkg, "gpkg")); @@ -130,10 +131,7 @@ function requireText(value, key) { } function gdalEnv() { - return { - PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"), - GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"), - }; + return qgis.env; } function gpkgLayers() {