Initial osm2streets QGIS workflow
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
outputs/
|
||||||
129
README.md
Normal file
129
README.md
Normal file
@@ -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` 都已验证过。
|
||||||
21
config/default.json
Normal file
21
config/default.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
config/examples/template.json
Normal file
21
config/examples/template.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
config/hanyang-block.json
Normal file
21
config/hanyang-block.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
10
docs/changelog.md
Normal file
10
docs/changelog.md
Normal file
@@ -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.
|
||||||
87
docs/input-cases.md
Normal file
87
docs/input-cases.md
Normal file
@@ -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 或改造脚本入口。
|
||||||
21
package-lock.json
generated
Normal file
21
package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
package.json
Normal file
12
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
521
scripts/build-osm2streets-qgis.js
Executable file
521
scripts/build-osm2streets-qgis.js
Executable file
@@ -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(/<node\b([^>]*)>/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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user