From dc22543e9ad0b9943aeb06eb1954a607eda0983c Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 21 Jul 2026 17:03:27 +0800 Subject: [PATCH] Initial osm2streets QGIS workflow --- .gitignore | 3 + README.md | 129 ++++++++ config/default.json | 21 ++ config/examples/template.json | 21 ++ config/hanyang-block.json | 21 ++ docs/changelog.md | 10 + docs/input-cases.md | 87 +++++ package-lock.json | 21 ++ package.json | 12 + scripts/build-osm2streets-qgis.js | 521 ++++++++++++++++++++++++++++++ 10 files changed, 846 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/default.json create mode 100644 config/examples/template.json create mode 100644 config/hanyang-block.json create mode 100644 docs/changelog.md create mode 100644 docs/input-cases.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/build-osm2streets-qgis.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e73d995 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules/ +outputs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..14c3f2b --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# osm2streets QGIS workflow + +把 Overpass/OSM XML 转成接近 osm2streets web 风格的 QGIS 工程。 + +当前默认输入输出在 [config/default.json](/Users/que01/osm2streets-qgis-workflow/config/default.json) 里配置: + +- 输入 OSM XML:`/Users/que01/Downloads/osm.xml` +- 中间 GeoJSON:`/Users/que01/Downloads/osm2streets_web_out/` +- GeoPackage:`/Users/que01/Downloads/osm2streets_webstyle.gpkg` +- QGIS 工程:`/Users/que01/Downloads/osm2streets_webstyle.qgz` +- 预览图:`/Users/que01/Downloads/osm2streets_webstyle_preview.png` + +已验证的输入案例记录在 [docs/input-cases.md](/Users/que01/osm2streets-qgis-workflow/docs/input-cases.md),变更记录在 [docs/changelog.md](/Users/que01/osm2streets-qgis-workflow/docs/changelog.md)。目前包括: + +- `/Users/que01/Downloads/osm.xml` +- `/Users/que01/Desktop/汉阳区区块.osm` + +## 环境 + +需要: + +- macOS QGIS:默认 `/Applications/QGIS.app` +- Node.js / npm + +首次使用: + +```bash +cd /Users/que01/osm2streets-qgis-workflow +npm install +``` + +## 运行 + +使用默认配置: + +```bash +cd /Users/que01/osm2streets-qgis-workflow +npm run build +``` + +使用另一套配置: + +```bash +node scripts/build-osm2streets-qgis.js --config /path/to/config.json +``` + +汉阳区区块案例: + +```bash +node scripts/build-osm2streets-qgis.js --config config/hanyang-block.json +``` + +创建新区域配置可以从模板复制: + +```bash +cp config/examples/template.json config/my-area.json +``` + +命令行参数可以覆盖配置文件: + +```bash +node scripts/build-osm2streets-qgis.js \ + --input /path/to/osm.xml \ + --out-dir /path/to/out \ + --gpkg /path/to/osm2streets_webstyle.gpkg \ + --project /path/to/osm2streets_webstyle.qgz \ + --preview /path/to/osm2streets_webstyle_preview.png +``` + +调整箭头大小: + +```bash +node scripts/build-osm2streets-qgis.js --arrow-scale 0.8 +``` + +当前调好的箭头比例是 `0.8`。如果 QGIS 里仍偏大,试 `0.6`;偏小则试 `1.0`。 + +## 配置项 + +默认配置文件: + +```json +{ + "qgisApp": "/Applications/QGIS.app", + "input": "/Users/que01/Downloads/osm.xml", + "outDir": "/Users/que01/Downloads/osm2streets_web_out", + "gpkg": "/Users/que01/Downloads/osm2streets_webstyle.gpkg", + "project": "/Users/que01/Downloads/osm2streets_webstyle.qgz", + "preview": "/Users/que01/Downloads/osm2streets_webstyle_preview.png", + "arrowScale": 0.8, + "clipPad": 0.002, + "canvasPad": 0.001, + "previewPad": 0.0007, + "canvasExtent": null, + "previewExtent": null +} +``` + +常用项: + +- `input`:OSM XML 输入。 +- `outDir`:osm2streets GeoJSON 中间产物目录。 +- `gpkg`:最终 GeoPackage。 +- `project`:最终 QGIS 工程。 +- `preview`:预览 PNG。 +- `qgisApp`:QGIS.app 路径。 +- `arrowScale`:方向箭头几何缩放,当前推荐 `0.8`。 +- `canvasExtent`:QGIS 打开后的初始范围,格式 `"xmin,ymin,xmax,ymax"`;为 `null` 时自动按 OSM bbox 扩展。 +- `previewExtent`:预览 PNG 范围,格式同上;为 `null` 时自动选第一个箭头附近。 + +## 输出图层 + +GeoPackage 内会生成: + +- `road_surface` +- `sidewalks` +- `sidewalk_corners` +- `lane_separators` +- `center_lines` +- `vehicle_stop_lines` +- `lane_arrows_webscale` + +QGIS 工程的绘制顺序已经固定为:路面在底,箭头、停止线、中心线在上。 + +## 注意 + +这套流程复用 osm2streets 的几何输出,再用 QGIS 符号化模拟 web 效果。它不会完全等同 osm2streets web renderer,但能稳定得到路面、人行道、停止线、中心线和方向箭头。 + +脚本已经兼容 OSM XML 节点坐标的双引号和单引号属性。Overpass 导出的 XML 和 JOSM 导出的 `.osm` 都已验证过。 diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..6077cd6 --- /dev/null +++ b/config/default.json @@ -0,0 +1,21 @@ +{ + "qgisApp": "/Applications/QGIS.app", + "input": "/Users/que01/Downloads/osm.xml", + "outDir": "/Users/que01/Downloads/osm2streets_web_out", + "gpkg": "/Users/que01/Downloads/osm2streets_webstyle.gpkg", + "project": "/Users/que01/Downloads/osm2streets_webstyle.qgz", + "preview": "/Users/que01/Downloads/osm2streets_webstyle_preview.png", + "arrowScale": 0.8, + "clipPad": 0.002, + "canvasPad": 0.001, + "previewPad": 0.0007, + "canvasExtent": null, + "previewExtent": null, + "osm2streets": { + "debug_each_step": false, + "dual_carriageway_experiment": false, + "sidepath_zipping_experiment": false, + "inferred_sidewalks": true, + "osm2lanes": true + } +} diff --git a/config/examples/template.json b/config/examples/template.json new file mode 100644 index 0000000..6f60635 --- /dev/null +++ b/config/examples/template.json @@ -0,0 +1,21 @@ +{ + "qgisApp": "/Applications/QGIS.app", + "input": "/absolute/path/to/input.osm", + "outDir": "/absolute/path/to/output/out", + "gpkg": "/absolute/path/to/output/name.gpkg", + "project": "/absolute/path/to/output/name.qgz", + "preview": "/absolute/path/to/output/name_preview.png", + "arrowScale": 0.8, + "clipPad": 0.002, + "canvasPad": 0.001, + "previewPad": 0.0007, + "canvasExtent": null, + "previewExtent": null, + "osm2streets": { + "debug_each_step": false, + "dual_carriageway_experiment": false, + "sidepath_zipping_experiment": false, + "inferred_sidewalks": true, + "osm2lanes": true + } +} diff --git a/config/hanyang-block.json b/config/hanyang-block.json new file mode 100644 index 0000000..f9ccca8 --- /dev/null +++ b/config/hanyang-block.json @@ -0,0 +1,21 @@ +{ + "qgisApp": "/Applications/QGIS.app", + "input": "/Users/que01/Desktop/汉阳区区块.osm", + "outDir": "/Users/que01/Downloads/hanyang_osm2streets/out", + "gpkg": "/Users/que01/Downloads/hanyang_osm2streets/hanyang_osm2streets.gpkg", + "project": "/Users/que01/Downloads/hanyang_osm2streets/hanyang_osm2streets.qgz", + "preview": "/Users/que01/Downloads/hanyang_osm2streets/hanyang_osm2streets_preview.png", + "arrowScale": 0.8, + "clipPad": 0.002, + "canvasPad": 0.001, + "previewPad": 0.0007, + "canvasExtent": null, + "previewExtent": null, + "osm2streets": { + "debug_each_step": false, + "dual_carriageway_experiment": false, + "sidepath_zipping_experiment": false, + "inferred_sidewalks": true, + "osm2lanes": true + } +} diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..5f1f4ce --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,10 @@ +# Changelog + +## 2026-07-17 + +- Added configurable input/output paths through JSON config files. +- Added [config/default.json](/Users/que01/osm2streets-qgis-workflow/config/default.json) for `/Users/que01/Downloads/osm.xml`. +- Added [config/hanyang-block.json](/Users/que01/osm2streets-qgis-workflow/config/hanyang-block.json) for `/Users/que01/Desktop/汉阳区区块.osm`. +- Added [config/examples/template.json](/Users/que01/osm2streets-qgis-workflow/config/examples/template.json) for new areas. +- Updated OSM node coordinate parsing to support both single-quoted and double-quoted XML attributes. +- Verified QGIS outputs for both a smaller Overpass-style XML input and a larger JOSM-generated `.osm` input. diff --git a/docs/input-cases.md b/docs/input-cases.md new file mode 100644 index 0000000..0fc6ce4 --- /dev/null +++ b/docs/input-cases.md @@ -0,0 +1,87 @@ +# Input Cases + +这个工具目前验证过两类 OSM XML 输入。 + +## Case 1: Overpass/OSM XML + +配置: + +- [config/default.json](/Users/que01/osm2streets-qgis-workflow/config/default.json) + +输入: + +- `/Users/que01/Downloads/osm.xml` + +特点: + +- XML 属性使用双引号,例如 `lat="30.x" lon="114.x"`。 +- 数据规模较小,适合调样式。 +- 已生成路面、人行道、中心线、停止线、方向箭头。 + +最近一次验证规模: + +- nodes: 1368 +- ways: 100 +- relations: 9 +- road_surface: 133 +- sidewalks: 102 +- lane_arrows_webscale: 1565 + +## Case 2: JOSM OSM XML + +配置: + +- [config/hanyang-block.json](/Users/que01/osm2streets-qgis-workflow/config/hanyang-block.json) + +输入: + +- `/Users/que01/Desktop/汉阳区区块.osm` + +特点: + +- XML 属性使用单引号,例如 `lat='30.x' lon='114.x'`。 +- 文件头包含 `generator='JOSM'`。 +- 数据规模更大,osm2streets 会输出较多拓扑裁切提示,例如 `trimmed into oblivion`、`layers don't match`。 +- 这些提示不一定阻止产物生成,但如果 QGIS 里出现明显断口,优先检查原始 OSM 路网连接、裁剪边界和 `layer` 标签。 + +最近一次验证规模: + +- nodes: 6360 +- ways: 696 +- relations: 59 +- road_surface: 1894 +- sidewalks: 1379 +- lane_arrows_webscale: 19390 + +## 建新配置的建议 + +从模板复制: + +```bash +cp /Users/que01/osm2streets-qgis-workflow/config/examples/template.json \ + /Users/que01/osm2streets-qgis-workflow/config/my-area.json +``` + +至少修改: + +- `input` +- `outDir` +- `gpkg` +- `project` +- `preview` + +运行: + +```bash +cd /Users/que01/osm2streets-qgis-workflow +node scripts/build-osm2streets-qgis.js --config config/my-area.json +``` + +## 兼容性说明 + +脚本的 OSM bbox 解析已兼容: + +- `lat="..." lon="..."` +- `lat='...' lon='...'` + +osm2streets 本身仍要求输入是标准 OSM XML。若输入来自其它 GIS 格式,先转换成 OSM XML 或改造脚本入口。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a8a1224 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "osm2streets-qgis-workflow", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "osm2streets-qgis-workflow", + "version": "0.1.0", + "dependencies": { + "osm2streets-js-node": "0.1.4" + } + }, + "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", + "integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==", + "license": "Apache-2.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0acdff --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "osm2streets-qgis-workflow", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "build": "node scripts/build-osm2streets-qgis.js" + }, + "dependencies": { + "osm2streets-js-node": "0.1.4" + } +} diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js new file mode 100755 index 0000000..a78099e --- /dev/null +++ b/scripts/build-osm2streets-qgis.js @@ -0,0 +1,521 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execFileSync } = require("child_process"); +const { JsStreetNetwork } = require("osm2streets-js-node"); + +const repoRoot = path.resolve(__dirname, ".."); +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 inputPath = path.resolve(config.input); +const outDir = path.resolve(config.outDir); +const gpkgPath = path.resolve(config.gpkg); +const projectPath = path.resolve(config.project); +const previewPath = path.resolve(config.preview); +const arrowScale = Number(config.arrowScale); +const clipPad = Number(config.clipPad); +const canvasPad = Number(config.canvasPad); +const previewPad = Number(config.previewPad); + +if (!Number.isFinite(arrowScale) || arrowScale <= 0) { + throw new Error(`Invalid arrowScale: ${config.arrowScale}`); +} +for (const [key, value] of [["clipPad", clipPad], ["canvasPad", canvasPad], ["previewPad", previewPad]]) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Invalid ${key}: ${config[key]}`); + } +} +if (!fs.existsSync(inputPath)) { + throw new Error(`Input OSM XML not found: ${inputPath}`); +} +for (const exe of [ogr2ogr, qgisPython]) { + if (!fs.existsSync(exe)) { + throw new Error(`QGIS executable not found: ${exe}`); + } +} + +fs.mkdirSync(outDir, { recursive: true }); +fs.mkdirSync(path.dirname(gpkgPath), { recursive: true }); +fs.mkdirSync(path.dirname(projectPath), { recursive: true }); +fs.mkdirSync(path.dirname(previewPath), { recursive: true }); + +const xml = fs.readFileSync(inputPath, "utf8"); +const bbox = getOsmBounds(xml); +const clip = makeClipPolygon(bbox, clipPad); + +const network = new JsStreetNetwork(xml, JSON.stringify(clip), config.osm2streets); + +writeGeoJson(outDir, "plain.geojson", network.toGeojsonPlain()); +writeGeoJson(outDir, "lane_polygons.geojson", network.toLanePolygonsGeojson()); +writeGeoJson(outDir, "lane_markings.geojson", network.toLaneMarkingsGeojson()); +writeGeoJson(outDir, "intersection_markings.geojson", network.toIntersectionMarkingsGeojson()); +fs.writeFileSync(path.join(outDir, "network.json"), network.toJson()); + +const split = splitLayers(outDir, arrowScale); +writeJson(path.join(outDir, "road_surface.geojson"), split.roadSurface); +writeJson(path.join(outDir, "sidewalks.geojson"), split.sidewalks); +writeJson(path.join(outDir, "lane_separators.geojson"), split.laneSeparators); +writeJson(path.join(outDir, "center_lines.geojson"), split.centerLines); +writeJson(path.join(outDir, "vehicle_stop_lines.geojson"), split.vehicleStopLines); +writeJson(path.join(outDir, "lane_arrows_webscale.geojson"), split.laneArrows); +writeJson(path.join(outDir, "sidewalk_corners.geojson"), split.sidewalkCorners); + +if (fs.existsSync(gpkgPath)) { + fs.unlinkSync(gpkgPath); +} +const ogrEnv = qgisEnv(); +importLayer(gpkgPath, path.join(outDir, "road_surface.geojson"), "road_surface", false, ogrEnv); +importLayer(gpkgPath, path.join(outDir, "sidewalks.geojson"), "sidewalks", true, ogrEnv); +importLayer(gpkgPath, path.join(outDir, "sidewalk_corners.geojson"), "sidewalk_corners", true, ogrEnv); +importLayer(gpkgPath, path.join(outDir, "lane_separators.geojson"), "lane_separators", true, ogrEnv); +importLayer(gpkgPath, path.join(outDir, "center_lines.geojson"), "center_lines", true, ogrEnv); +importLayer(gpkgPath, path.join(outDir, "vehicle_stop_lines.geojson"), "vehicle_stop_lines", true, ogrEnv); +importLayer(gpkgPath, path.join(outDir, "lane_arrows_webscale.geojson"), "lane_arrows_webscale", true, ogrEnv); + +const qgisScript = path.join(outDir, "_create_qgis_project.py"); +const defaultPreviewExtent = extentString(expandBounds( + featureBounds(split.laneArrows.features[0] || split.roadSurface.features[0]), + previewPad, +)); +fs.writeFileSync(qgisScript, makeQgisScript({ + qgisMacOS, + gpkgPath, + projectPath, + previewPath, + canvasExtent: config.canvasExtent || extentString(expandBounds(bbox, canvasPad)), + previewExtent: config.previewExtent || defaultPreviewExtent, +})); + +execFileSync(qgisPython, [qgisScript], { + stdio: "inherit", + env: { + ...process.env, + ...qgisEnv(), + 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(":"), + }, +}); +fixCanvas(projectPath, config.canvasExtent || extentString(expandBounds(bbox, canvasPad))); + +console.log(`Config: ${configPath}`); +console.log(`GeoPackage: ${gpkgPath}`); +console.log(`QGIS project: ${projectPath}`); +console.log(`Preview PNG: ${previewPath}`); + +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(file, cliArgs) { + if (!fs.existsSync(file)) { + throw new Error(`Config file not found: ${file}`); + } + const base = JSON.parse(fs.readFileSync(file, "utf8")); + const overrides = {}; + const mapping = { + qgisApp: "qgisApp", + input: "input", + outDir: "outDir", + gpkg: "gpkg", + project: "project", + preview: "preview", + arrowScale: "arrowScale", + clipPad: "clipPad", + pad: "clipPad", + canvasPad: "canvasPad", + previewPad: "previewPad", + canvasExtent: "canvasExtent", + previewExtent: "previewExtent", + }; + for (const [argKey, configKey] of Object.entries(mapping)) { + if (cliArgs[argKey] !== undefined) { + overrides[configKey] = cliArgs[argKey]; + } + } + if (process.env.QGIS_APP && overrides.qgisApp === undefined) { + overrides.qgisApp = process.env.QGIS_APP; + } + const merged = deepMerge(base, overrides); + const required = ["qgisApp", "input", "outDir", "gpkg", "project", "preview", "osm2streets"]; + for (const key of required) { + if (merged[key] === undefined || merged[key] === null || merged[key] === "") { + throw new Error(`Missing config key: ${key}`); + } + } + return merged; +} + +function deepMerge(base, overrides) { + const out = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + base[key] && + typeof base[key] === "object" && + !Array.isArray(base[key]) + ) { + out[key] = deepMerge(base[key], value); + } else { + out[key] = value; + } + } + 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) { + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const match of xmlText.matchAll(/]*)>/g)) { + const attrs = match[1]; + const latMatch = attrs.match(/\blat=(["'])(.*?)\1/); + const lonMatch = attrs.match(/\blon=(["'])(.*?)\1/); + if (!latMatch || !lonMatch) continue; + const lat = Number(latMatch[2]); + const lon = Number(lonMatch[2]); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + minLon = Math.min(minLon, lon); + maxLon = Math.max(maxLon, lon); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + } + if (!Number.isFinite(minLon)) { + throw new Error("No OSM node coordinates found"); + } + return { minLon, minLat, maxLon, maxLat }; +} + +function makeClipPolygon(bbox, pad) { + const b = expandBounds(bbox, pad); + return { + type: "FeatureCollection", + features: [{ + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [[ + [b.minLon, b.minLat], + [b.maxLon, b.minLat], + [b.maxLon, b.maxLat], + [b.minLon, b.maxLat], + [b.minLon, b.minLat], + ]], + }, + }], + }; +} + +function expandBounds(bbox, pad) { + return { + minLon: bbox.minLon - pad, + minLat: bbox.minLat - pad, + maxLon: bbox.maxLon + pad, + maxLat: bbox.maxLat + pad, + }; +} + +function centeredExtent(bbox, width, height) { + const cx = (bbox.minLon + bbox.maxLon) / 2; + const cy = (bbox.minLat + bbox.maxLat) / 2; + return { + minLon: cx - width / 2, + minLat: cy - height / 2, + maxLon: cx + width / 2, + maxLat: cy + height / 2, + }; +} + +function extentString(bbox) { + return `${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`; +} + +function featureBounds(feature) { + if (!feature?.geometry?.coordinates) { + throw new Error("No feature available for preview extent"); + } + const coords = []; + collectCoords(feature.geometry.coordinates, coords); + if (!coords.length) { + throw new Error("Preview feature has no coordinates"); + } + return { + minLon: Math.min(...coords.map((p) => p[0])), + minLat: Math.min(...coords.map((p) => p[1])), + maxLon: Math.max(...coords.map((p) => p[0])), + maxLat: Math.max(...coords.map((p) => p[1])), + }; +} + +function writeGeoJson(dir, name, content) { + const file = path.join(dir, name); + fs.writeFileSync(file, content); + const count = JSON.parse(content).features?.length ?? 0; + console.log(`${file}\tfeatures=${count}`); +} + +function writeJson(file, value) { + fs.writeFileSync(file, JSON.stringify(value)); + console.log(`${file}\tfeatures=${value.features.length}`); +} + +function emptyCollection() { + return { type: "FeatureCollection", features: [] }; +} + +function splitLayers(dir, arrowScaleValue) { + const lanePolygons = JSON.parse(fs.readFileSync(path.join(dir, "lane_polygons.geojson"), "utf8")); + const markings = JSON.parse(fs.readFileSync(path.join(dir, "lane_markings.geojson"), "utf8")); + const intersections = JSON.parse(fs.readFileSync(path.join(dir, "intersection_markings.geojson"), "utf8")); + const out = { + roadSurface: emptyCollection(), + sidewalks: emptyCollection(), + laneSeparators: emptyCollection(), + centerLines: emptyCollection(), + vehicleStopLines: emptyCollection(), + laneArrows: emptyCollection(), + sidewalkCorners: intersections, + }; + + for (const feature of lanePolygons.features || []) { + const type = feature.properties?.type; + if (type === "Sidewalk" || type === "Footway") { + out.sidewalks.features.push(feature); + } else { + out.roadSurface.features.push(feature); + } + } + + for (const feature of markings.features || []) { + const type = feature.properties?.type; + if (type === "lane separator") out.laneSeparators.features.push(feature); + if (type === "center line") out.centerLines.features.push(feature); + if (type === "vehicle stop line") out.vehicleStopLines.features.push(feature); + if (type === "lane arrow") out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue)); + } + return out; +} + +function scaleFeature(feature, scale) { + const coords = []; + collectCoords(feature.geometry.coordinates, coords); + if (!coords.length) return feature; + const xs = coords.map((p) => p[0]); + const ys = coords.map((p) => p[1]); + const cx = (Math.min(...xs) + Math.max(...xs)) / 2; + const cy = (Math.min(...ys) + Math.max(...ys)) / 2; + return { + type: "Feature", + properties: { ...feature.properties, render_scale: scale }, + geometry: { + ...feature.geometry, + coordinates: scaleCoords(feature.geometry.coordinates, cx, cy, scale), + }, + }; +} + +function collectCoords(obj, acc) { + if (Array.isArray(obj) && obj.length >= 2 && typeof obj[0] === "number" && typeof obj[1] === "number") { + acc.push([obj[0], obj[1]]); + } else if (Array.isArray(obj)) { + for (const item of obj) collectCoords(item, acc); + } +} + +function scaleCoords(obj, cx, cy, scale) { + if (Array.isArray(obj) && obj.length >= 2 && typeof obj[0] === "number" && typeof obj[1] === "number") { + return [cx + (obj[0] - cx) * scale, cy + (obj[1] - cy) * scale, ...obj.slice(2)]; + } + if (Array.isArray(obj)) { + return obj.map((item) => scaleCoords(item, cx, cy, scale)); + } + return obj; +} + +function importLayer(gpkg, source, layerName, update, env) { + const args = ["-f", "GPKG"]; + if (update) args.push("-update", "-overwrite"); + args.push(gpkg, source, "-nln", layerName); + execFileSync(ogr2ogr, args, { stdio: "inherit", env: { ...process.env, ...env } }); +} + +function makeQgisScript(options) { + return ` +from pathlib import Path + +from qgis.PyQt.QtCore import QSize +from qgis.PyQt.QtGui import QColor, QImage, QPainter +from qgis.core import ( + QgsApplication, + QgsCoordinateReferenceSystem, + QgsFillSymbol, + QgsMapRendererCustomPainterJob, + QgsMapSettings, + QgsProject, + QgsRectangle, + QgsSingleSymbolRenderer, + QgsVectorLayer, +) + +QGIS_PREFIX = ${JSON.stringify(options.qgisMacOS)} +GPKG = ${JSON.stringify(options.gpkgPath)} +PROJECT_PATH = ${JSON.stringify(options.projectPath)} +PREVIEW_PATH = ${JSON.stringify(options.previewPath)} +PREVIEW_EXTENT = [${options.previewExtent.split(",").map(Number).join(", ")}] + +def fill_symbol(color, outline="0,0,0,0", outline_width="0"): + return QgsFillSymbol.createSimple({ + "color": color, + "outline_color": outline, + "outline_width": outline_width, + "outline_width_unit": "MM", + "joinstyle": "round", + }) + +def make_layer(layer_name, title, color, outline="0,0,0,0", outline_width="0"): + layer = QgsVectorLayer(f"{GPKG}|layername={layer_name}", title, "ogr") + if not layer.isValid(): + raise RuntimeError(f"Invalid layer: {title}") + layer.setRenderer(QgsSingleSymbolRenderer(fill_symbol(color, outline, outline_width))) + return layer + +QgsApplication.setPrefixPath(QGIS_PREFIX, True) +app = QgsApplication([], False) +app.initQgis() + +project = QgsProject.instance() +project.clear() +project.setFileName(PROJECT_PATH) +project.setCrs(QgsCoordinateReferenceSystem("EPSG:4326")) +project.setPresetHomePath(str(Path(PROJECT_PATH).parent)) + +layers = { + "road_surface": make_layer("road_surface", "osm2streets road surface", "43,43,40,255", "30,30,28,255", "0.04"), + "sidewalks": make_layer("sidewalks", "osm2streets sidewalks", "190,190,182,255", "156,156,148,255", "0.025"), + "sidewalk_corners": make_layer("sidewalk_corners", "osm2streets sidewalk corners", "190,190,182,255", "156,156,148,255", "0.025"), + "lane_separators": make_layer("lane_separators", "osm2streets lane separators", "238,238,230,255"), + "center_lines": make_layer("center_lines", "osm2streets center lines", "245,190,42,255"), + "vehicle_stop_lines": make_layer("vehicle_stop_lines", "osm2streets vehicle stop lines", "255,255,246,255"), + "lane_arrows": make_layer("lane_arrows_webscale", "osm2streets lane arrows", "255,255,246,255", "43,43,40,200", "0.015"), +} +draw_order = ["road_surface", "sidewalks", "sidewalk_corners", "lane_separators", "center_lines", "vehicle_stop_lines", "lane_arrows"] +for key in draw_order: + project.addMapLayer(layers[key], False) +root = project.layerTreeRoot() +for key in draw_order: + root.insertLayer(0, layers[key]) +if not project.write(PROJECT_PATH): + raise RuntimeError(f"Failed to write {PROJECT_PATH}") + +settings = QgsMapSettings() +settings.setLayers([layers[key] for key in reversed(draw_order)]) +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.fill(settings.backgroundColor().rgba()) +painter = QPainter(image) +job = QgsMapRendererCustomPainterJob(settings, painter) +job.start() +job.waitForFinished() +painter.end() +image.save(PREVIEW_PATH) + +print(PROJECT_PATH) +print(PREVIEW_PATH) +app.exitQgis() +`; +} + +function fixCanvas(projectFile, extentCsv) { + const fixScript = path.join(os.tmpdir(), `osm2streets_fix_canvas_${process.pid}.py`); + const script = ` +from pathlib import Path +import shutil +import zipfile +import xml.etree.ElementTree as ET + +project_path = Path(${JSON.stringify(projectFile)}) +extent_values = [${extentCsv.split(",").map(Number).join(", ")}] +work_dir = Path(${JSON.stringify(path.join(os.tmpdir(), `osm2streets_qgz_fix_${process.pid}`))}) +if work_dir.exists(): + shutil.rmtree(work_dir) +work_dir.mkdir(parents=True) +with zipfile.ZipFile(project_path, "r") as zin: + zin.extractall(work_dir) +qgs_files = list(work_dir.glob("*.qgs")) +if not qgs_files: + raise RuntimeError("No .qgs file found inside project") +qgs_path = qgs_files[0] +tree = ET.parse(qgs_path) +root = tree.getroot() +old_canvas = root.find("mapcanvas") +if old_canvas is not None: + root.remove(old_canvas) +project_crs = root.find("projectCrs/spatialrefsys") +canvas = ET.Element("mapcanvas", {"name": "theMapCanvas", "annotationsVisible": "1"}) +ET.SubElement(canvas, "units").text = "degrees" +extent = ET.SubElement(canvas, "extent") +for key, value in zip(["xmin", "ymin", "xmax", "ymax"], extent_values): + ET.SubElement(extent, key).text = str(value) +ET.SubElement(canvas, "rotation").text = "0" +dest = ET.SubElement(canvas, "destinationsrs") +if project_crs is not None: + dest.append(ET.fromstring(ET.tostring(project_crs, encoding="unicode"))) +ET.SubElement(canvas, "rendermaptile").text = "0" +ET.SubElement(canvas, "expressionContextScope") +layer_tree = root.find("layer-tree-group") +insert_at = list(root).index(layer_tree) + 1 if layer_tree is not None else 1 +root.insert(insert_at, canvas) +ET.indent(tree, space=" ") +tree.write(qgs_path, encoding="UTF-8", xml_declaration=True) +tmp = project_path.with_suffix(".qgz.tmp") +with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for f in sorted(work_dir.iterdir()): + zout.write(f, f.name) +tmp.replace(project_path) +shutil.rmtree(work_dir) +`; + fs.writeFileSync(fixScript, script); + execFileSync("python3", [fixScript], { stdio: "inherit" }); + fs.unlinkSync(fixScript); +}