Files
osmWorkflow/scripts/reimport-gpkg.js
que01 59628cba82 feat: add reimport stage for hand-edited GeoPackages
QGIS 手工修正后的回导流程此前是 README 里的三段 shell:一个硬编码
area-id 和 ogr2ogr 绝对路径的 for 循环、一段内联 node heredoc、再加一次
npm run build。改为一个 reimport 阶段:

  npm run build -- --config config/areas/<area-id>.json \
    --stages reimport,blender,cesium

scripts/reimport-gpkg.js 先把全部图层导出到临时目录并逐个校验,全部通过
才写回 osm2streets_web_out/。ogr2ogr 对不存在的图层退出码非 0 但仍会留下
0 字节文件,原先逐图层 mv 会静默用空图层覆盖好数据。

intermediates 与 reimport 同时指定直接报错——前者用 OSM 重建 GeoPackage,
正好抹掉后者要读回的手工修改。reimport 不含在 all 中。

同时新增 scripts/lib/scene-layers.js 作为 9 个渲染图层的唯一定义源。此前
该表在合并场景、场景样式 JSON、生成的 QGIS 工程、README 手工流程中各有
一份副本,改一处漏其余会导致图层叠放顺序出错并流入 Blender/Cesium。

验证:同一 OSM 输入下,改动前后 intermediates 产物逐字节一致
(scene_style.json、.qgz 符号定义、9 个图层几何与属性);reimport 对
未修改图层无损回导。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:21:59 +08:00

180 lines
5.7 KiB
JavaScript
Executable File

#!/usr/bin/env node
"use strict";
// Reverse of the intermediates stage: pull manually-edited layers back out of
// <area>.gpkg into osm2streets_web_out/*.geojson and rebuild the merged scene.
//
// Use after hand-fixing geometry in QGIS. Re-running intermediates would
// regenerate the GeoPackage from OSM and throw those edits away.
//
// Every layer is exported to a staging directory and parsed before anything in
// outDir is touched: ogr2ogr exits non-zero on a missing layer but still leaves
// a zero-byte file behind, so a partial export must not reach the output tree.
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const {
SCENE_LAYERS,
SCENE_FILE,
SCENE_STYLE_FILE,
layerFile,
mergeScene,
sceneStyle,
} = require("./lib/scene-layers");
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 outDir = path.resolve(requireText(config.outDir, "outDir"));
const gpkgPath = path.resolve(requireText(config.gpkg, "gpkg"));
for (const exe of [ogr2ogr, ogrinfo]) {
if (!fs.existsSync(exe)) {
throw new Error(`QGIS executable not found: ${exe}`);
}
}
if (!fs.existsSync(gpkgPath)) {
throw new Error(`GeoPackage not found: ${gpkgPath}\nRun the intermediates stage first.`);
}
if (!fs.existsSync(outDir)) {
throw new Error(`GeoJSON output directory not found: ${outDir}`);
}
console.log(`Reimport: ${gpkgPath}`);
console.log(`Target: ${outDir}`);
const present = gpkgLayers();
const missing = SCENE_LAYERS.filter((layer) => !present.has(layer.id)).map((layer) => layer.id);
if (missing.length) {
throw new Error(
`GeoPackage is missing ${missing.length} layer(s): ${missing.join(", ")}\n` +
`Present: ${[...present].join(", ") || "(none)"}`,
);
}
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "osm2streets-reimport-"));
try {
const staged = SCENE_LAYERS.map((layer) => {
const stagedPath = path.join(stagingDir, layerFile(layer));
exportLayer(layer.id, stagedPath);
const collection = readCollection(stagedPath, layer.id);
console.log(`${layer.id}\tfeatures=${collection.features.length}`);
return { layer, stagedPath, collection };
});
for (const item of staged) {
// Copy rather than rename: the staging dir may be on another filesystem.
fs.copyFileSync(item.stagedPath, path.join(outDir, layerFile(item.layer)));
}
const byId = new Map(staged.map((item) => [item.layer.id, item.collection]));
const scene = mergeScene((layer) => byId.get(layer.id));
fs.writeFileSync(path.join(outDir, SCENE_FILE), JSON.stringify(scene));
fs.writeFileSync(
path.join(outDir, SCENE_STYLE_FILE),
JSON.stringify(sceneStyle(), null, 2),
);
console.log(`${path.join(outDir, SCENE_FILE)}\tfeatures=${scene.features.length}`);
const empty = staged.filter((item) => item.collection.features.length === 0);
if (empty.length) {
console.warn(`Warning: empty layer(s): ${empty.map((item) => item.layer.id).join(", ")}`);
}
} finally {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
out[key] = "true";
} else {
out[key] = next;
i += 1;
}
}
return out;
}
function loadConfig(cliArgs) {
const base = {};
if (cliArgs.config) {
const file = path.resolve(cliArgs.config);
if (!fs.existsSync(file)) {
throw new Error(`Config file not found: ${file}`);
}
Object.assign(base, JSON.parse(fs.readFileSync(file, "utf8")));
}
for (const key of ["qgisApp", "outDir", "gpkg"]) {
if (cliArgs[key] !== undefined) base[key] = cliArgs[key];
}
return base;
}
function requireText(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing config key: ${key}`);
}
return value;
}
function gdalEnv() {
return {
PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"),
GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"),
};
}
function gpkgLayers() {
const output = execFileSync(ogrinfo, ["-q", gpkgPath], {
encoding: "utf8",
env: { ...process.env, ...gdalEnv() },
});
const names = new Set();
for (const line of output.split("\n")) {
const match = /^\s*\d+:\s+(\S+)/.exec(line);
if (match) names.add(match[1]);
}
return names;
}
function exportLayer(layerName, destination) {
// No COORDINATE_PRECISION here on purpose: the default already round-trips
// full double precision, and setting it explicitly makes GDAL run its
// precision-reduction pass, which drops vertices that collapse at the given
// resolution (measured: 28 points lost across 7 lane-arrow polygons).
execFileSync(ogr2ogr, [
"-f", "GeoJSON",
destination,
gpkgPath,
layerName,
], {
stdio: "inherit",
env: { ...process.env, ...gdalEnv() },
});
}
function readCollection(file, layerName) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
throw new Error(`Layer '${layerName}' did not export valid GeoJSON: ${error.message}`);
}
if (parsed.type !== "FeatureCollection" || !Array.isArray(parsed.features)) {
throw new Error(`Layer '${layerName}' did not export a FeatureCollection`);
}
return parsed;
}