#!/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 normalizeLaneArrowsScript = path.join(repoRoot, "scripts", "normalize-lane-arrows.py"); 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 arrowMergeTriangles = config.arrowMergeTriangles !== false; const arrowOutlineSimplifyMeters = Number(config.arrowOutlineSimplifyMeters ?? 0.05); const intersectionCornerSourceMaxDimensionMeters = Number(config.intersectionCornerSourceMaxDimensionMeters ?? 2.6); const clipPad = Number(config.clipPad); const canvasPad = Number(config.canvasPad); const previewPad = Number(config.previewPad); const layerPrefix = config.layerPrefix || "osm2streets"; if (!Number.isFinite(arrowScale) || arrowScale <= 0) { throw new Error(`Invalid arrowScale: ${config.arrowScale}`); } if (!Number.isFinite(arrowOutlineSimplifyMeters) || arrowOutlineSimplifyMeters < 0) { throw new Error(`Invalid arrowOutlineSimplifyMeters: ${config.arrowOutlineSimplifyMeters}`); } if (!Number.isFinite(intersectionCornerSourceMaxDimensionMeters) || intersectionCornerSourceMaxDimensionMeters <= 0) { throw new Error(`Invalid intersectionCornerSourceMaxDimensionMeters: ${config.intersectionCornerSourceMaxDimensionMeters}`); } 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}`); } } if (!fs.existsSync(normalizeLaneArrowsScript)) { throw new Error(`Lane-arrow normalizer not found: ${normalizeLaneArrowsScript}`); } 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 osm = parseOsm(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, intersectionCornerSourceMaxDimensionMeters, osm, ); writeJson(path.join(outDir, "road_surface.geojson"), split.roadSurface); writeJson(path.join(outDir, "intersection_surface.geojson"), split.intersectionSurface); 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); if (arrowMergeTriangles) { normalizeLaneArrows(path.join(outDir, "lane_arrows_webscale.geojson"), arrowOutlineSimplifyMeters); split.laneArrows = JSON.parse(fs.readFileSync(path.join(outDir, "lane_arrows_webscale.geojson"), "utf8")); } writeJson(path.join(outDir, "sidewalk_corners.geojson"), split.sidewalkCorners); writeJson(path.join(outDir, "crosswalks.geojson"), split.crosswalks); writeJson(path.join(outDir, "osm2streets_scene.geojson"), mergedScene(split)); fs.writeFileSync( path.join(outDir, "osm2streets_scene_style.json"), JSON.stringify(sceneStyle(), null, 2), ); 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, "intersection_surface.geojson"), "intersection_surface", true, 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); importLayer(gpkgPath, path.join(outDir, "crosswalks.geojson"), "crosswalks", true, ogrEnv); const qgisScript = path.join(outDir, "_create_qgis_project.py"); const previewFeature = split.crosswalks.features[0] || split.laneArrows.features[0] || split.roadSurface.features[0]; const defaultPreviewExtent = extentString(expandBounds( featureBounds(previewFeature), previewPad, )); fs.writeFileSync(qgisScript, makeQgisScript({ qgisMacOS, gpkgPath, projectPath, previewPath, layerPrefix, 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", arrowMergeTriangles: "arrowMergeTriangles", arrowOutlineSimplifyMeters: "arrowOutlineSimplifyMeters", intersectionCornerSourceMaxDimensionMeters: "intersectionCornerSourceMaxDimensionMeters", 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) { // OSM exports may contain distant relation members (for example subway // nodes) that are not part of the requested map extent. Prefer the explicit // bounds element when present and only fall back to node extents for files // that do not provide one. const boundsMatch = xmlText.match(/]*)\/?\s*>/); if (boundsMatch) { const attrs = parseAttrs(boundsMatch[1]); const values = { minLon: Number(attrs.minlon), minLat: Number(attrs.minlat), maxLon: Number(attrs.maxlon), maxLat: Number(attrs.maxlat), }; if (Object.values(values).every(Number.isFinite)) { return values; } } 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 parseOsm(xmlText) { const nodes = new Map(); const ways = new Map(); for (const match of xmlText.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { const attrs = parseAttrs(match[1]); const id = Number(attrs.id); const lat = Number(attrs.lat); const lon = Number(attrs.lon); if (!Number.isFinite(id) || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; nodes.set(id, { id, lat, lon, tags: parseTags(match[2] || "") }); } for (const match of xmlText.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { const attrs = parseAttrs(match[1]); const id = Number(attrs.id); if (!Number.isFinite(id)) continue; const body = match[2] || ""; const refs = [...body.matchAll(/]*)\/>/g)] .map((refMatch) => Number(parseAttrs(refMatch[1]).ref)) .filter(Number.isFinite); ways.set(id, { id, refs, tags: parseTags(body) }); } return { nodes, ways }; } function parseAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([\w:.-]+)=(["'])(.*?)\2/g)) { attrs[match[1]] = decodeXml(match[3]); } return attrs; } function parseTags(text) { const tags = {}; for (const match of text.matchAll(/]*)\/>/g)) { const attrs = parseAttrs(match[1]); if (attrs.k !== undefined) tags[attrs.k] = attrs.v ?? ""; } return tags; } function decodeXml(value) { return value .replaceAll(""", "\"") .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("&", "&"); } 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 mergedScene(split) { const layers = [ ["road_surface", 10, split.roadSurface], ["intersection_surface", 20, split.intersectionSurface], ["sidewalks", 30, split.sidewalks], ["sidewalk_corners", 40, split.sidewalkCorners], ["lane_separators", 50, split.laneSeparators], ["center_lines", 60, split.centerLines], ["crosswalks", 70, split.crosswalks], ["vehicle_stop_lines", 80, split.vehicleStopLines], ["lane_arrows_webscale", 90, split.laneArrows], ]; return { type: "FeatureCollection", features: layers.flatMap(([renderLayer, zIndex, collection]) => ( (collection.features || []).map((feature) => ({ ...feature, properties: { ...(feature.properties || {}), render_layer: renderLayer, z_index: zIndex, }, })) )), }; } function sceneStyle() { return { version: 1, geometry: "polygon", sortProperty: "z_index", layerProperty: "render_layer", layers: [ { id: "road_surface", zIndex: 10, fill: "#2b2b28", outline: "#1e1e1c", outlineWidth: 0.04 }, { id: "intersection_surface", zIndex: 20, fill: "#2b2b28", outline: "#1e1e1c", outlineWidth: 0.04 }, { id: "sidewalks", zIndex: 30, fill: "#bebeb6", outline: "#9c9c94", outlineWidth: 0.025 }, { id: "sidewalk_corners", zIndex: 40, fill: "#bebeb6", outline: "#9c9c94", outlineWidth: 0.025 }, { id: "lane_separators", zIndex: 50, fill: "#eeeee6", outline: null, outlineWidth: 0 }, { id: "center_lines", zIndex: 60, fill: "#f5be2a", outline: null, outlineWidth: 0 }, { id: "crosswalks", zIndex: 70, fill: "#fffff6", outline: null, outlineWidth: 0 }, { id: "vehicle_stop_lines", zIndex: 80, fill: "#fffff6", outline: null, outlineWidth: 0 }, { id: "lane_arrows_webscale", zIndex: 90, fill: "#fffff6", outline: "#2b2b28", outlineWidth: 0.015 }, ], }; } function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) { const plain = JSON.parse(fs.readFileSync(path.join(dir, "plain.geojson"), "utf8")); 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 network = JSON.parse(fs.readFileSync(path.join(dir, "network.json"), "utf8")); const crosswalkData = buildCrosswalks(osm); const serviceWayIds = new Set([...osm.ways.values()] .filter((way) => way.tags.highway === "service") .map((way) => way.id)); const out = { roadSurface: emptyCollection(), intersectionSurface: emptyCollection(), sidewalks: emptyCollection(), laneSeparators: emptyCollection(), centerLines: emptyCollection(), vehicleStopLines: crosswalkData.stopLines, laneArrows: emptyCollection(), sidewalkCorners: buildSidewalkCorners(intersections, plain, network, maxCornerDimensionMeters), crosswalks: crosswalkData.stripes, }; const serviceDrivingPolygons = []; for (const feature of plain.features || []) { if (feature.properties?.type === "intersection") { out.intersectionSurface.features.push(feature); } } 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); if (type === "Driving" && hasAnyWayId(feature.properties?.osm_way_ids, serviceWayIds)) { serviceDrivingPolygons.push({ bbox: featureBounds(feature), rings: polygonRings(feature.geometry), }); } } } for (const feature of markings.features || []) { const type = feature.properties?.type; const conflictsWithCrosswalk = isInAnyPolygon(feature, crosswalkData.zones, "intersects"); if (type === "lane separator" && !conflictsWithCrosswalk) out.laneSeparators.features.push(feature); if (type === "center line" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) out.centerLines.features.push(feature); if (type === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) { out.vehicleStopLines.features.push(feature); } if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) { out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue)); } } return out; } function hasAnyWayId(value, ids) { const values = Array.isArray(value) ? value : [value]; return values.some((id) => ids.has(Number(id))); } function filteredSidewalkCorners(intersections, maxDimensionMeters) { const out = emptyCollection(); for (const feature of intersections.features || []) { if (feature.properties?.type !== "sidewalk corner") continue; const dimension = maxFeatureDimensionMeters(feature); if (dimension === null || dimension > maxDimensionMeters) continue; out.features.push(feature); } return out; } function buildSidewalkCorners(intersections, plain, network, maxDimensionMeters) { const out = filteredSidewalkCorners(intersections, maxDimensionMeters); const missing = synthesizeMissingSidewalkCorners(out, plain, network, maxDimensionMeters); out.features.push(...missing); const caps = synthesizeTJunctionSidewalkCaps(out, plain, network, maxDimensionMeters); out.features.push(...caps); return out; } function synthesizeMissingSidewalkCorners(existing, plain, network, maxDimensionMeters) { const roadFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "road") .map((feature) => [Number(feature.properties.id), feature])); const intersectionFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "intersection") .map((feature) => [Number(feature.properties.id), feature])); const roads = new Map((network.roads || []).map(([id, road]) => [Number(id), road])); const intersections = new Map((network.intersections || []).map(([id, intersection]) => [Number(id), intersection])); const existingByIntersection = assignCornersToIntersections(existing.features || [], intersectionFeatures); const synthesized = []; for (const [intersectionId, intersection] of intersections.entries()) { const intersectionFeature = intersectionFeatures.get(intersectionId); if (!intersectionFeature || intersection.roads.length < 2) continue; const edges = buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature); if (!edges.length) continue; const qualifyingPairs = qualifyingCornerPairs(edges); if (!qualifyingPairs.length) continue; const current = existingByIntersection.get(intersectionId) || []; const currentCenters = current.map((entry) => entry.center); const missing = []; for (const [one, two] of qualifyingPairs) { const candidate = synthesizeCornerFeature(one, two, intersectionFeature, maxDimensionMeters); if (!candidate) continue; const candidateCenter = featureCenter(candidate); if (!candidateCenter || !pointInPolygon(candidateCenter, intersectionFeature.geometry.coordinates)) continue; const dimension = maxFeatureDimensionMeters(candidate); if (dimension === null || dimension > maxDimensionMeters) continue; if (polygonAreaMeters2(candidate) < 0.4) continue; if (currentCenters.some((point) => pointDistance(point, candidateCenter) <= 0.6)) continue; missing.push(candidate); } if (missing.length !== 1) continue; synthesized.push(missing[0]); } return synthesized; } function synthesizeTJunctionSidewalkCaps(existing, plain, network, maxDimensionMeters) { const roadFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "road") .map((feature) => [Number(feature.properties.id), feature])); const intersectionFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "intersection") .map((feature) => [Number(feature.properties.id), feature])); const roads = new Map((network.roads || []).map(([id, road]) => [Number(id), road])); const intersections = new Map((network.intersections || []).map(([id, intersection]) => [Number(id), intersection])); const existingByIntersection = assignCornersToIntersections(existing.features || [], intersectionFeatures); const synthesized = []; for (const [intersectionId, intersection] of intersections.entries()) { const intersectionFeature = intersectionFeatures.get(intersectionId); if (!intersectionFeature) continue; if (intersectionFeature.properties?.intersection_kind !== "Intersection") continue; if (new Set(intersection.roads || []).size !== 3) continue; const edges = buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature); if (!edges.length) continue; const current = existingByIntersection.get(intersectionId) || []; const smallCurrent = current.filter((entry) => { const dimension = maxFeatureDimensionMeters(entry.feature); return Number.isFinite(dimension) && dimension <= maxDimensionMeters; }); if (smallCurrent.length !== 2) continue; const candidates = []; for (const [one, two] of qualifyingCornerPairs(edges)) { const candidate = synthesizeCornerFeature(one, two, intersectionFeature, 100); if (!candidate) continue; const center = featureCenter(candidate); const dimension = maxFeatureDimensionMeters(candidate); const area = polygonAreaMeters2(candidate); if (!center || !Number.isFinite(dimension) || !Number.isFinite(area)) continue; candidates.push({ feature: candidate, center, dimension, area }); } // For a true T-junction, the remaining large candidate is the sidewalk "cap" // opposite the side street, not another curb-return corner. const caps = candidates.filter(({ dimension, area, center }) => ( dimension > maxDimensionMeters && dimension <= 12 && area >= 6 && area <= 20 && pointInPolygon(center, intersectionFeature.geometry.coordinates) && !smallCurrent.some((entry) => pointDistance(entry.center, center) <= 1) )); if (caps.length !== 1) continue; caps[0].feature.properties.source = "fallback_t_cap"; synthesized.push(caps[0].feature); } return synthesized; } function assignCornersToIntersections(features, intersectionFeatures) { const out = new Map(); for (const feature of features) { const point = featureCenter(feature); if (!point) continue; for (const [intersectionId, intersectionFeature] of intersectionFeatures.entries()) { if (!pointInPolygon(point, intersectionFeature.geometry.coordinates)) continue; const bucket = out.get(intersectionId) || []; bucket.push({ feature, center: point, }); out.set(intersectionId, bucket); break; } } return out; } function buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature) { const edges = []; for (const roadId of intersection.roads || []) { const road = roads.get(Number(roadId)); const roadFeature = roadFeatures.get(Number(roadId)); if (!road || !roadFeature) return []; const geometry = roadEndpointGeometry(road, roadFeature, intersectionFeature, intersection.id); if (!geometry) return []; const first = road.dst_i === intersection.id ? makeRoadEdge(road, geometry, "right") : makeRoadEdge(road, geometry, "left"); const second = road.dst_i === intersection.id ? makeRoadEdge(road, geometry, "left") : makeRoadEdge(road, geometry, "right"); if (!first || !second) return []; edges.push(first, second); } return edges; } function qualifyingCornerPairs(edges) { if (!edges.length) return []; const loop = [...edges, edges[0]]; const pairs = []; for (let i = 0; i < loop.length - 1; i += 1) { const one = loop[i]; const two = loop[i + 1]; if (one.roadId === two.roadId) continue; if (!isWalkableOuterLane(one.laneType) || !isWalkableOuterLane(two.laneType)) continue; if (one.laneCount === 1 || two.laneCount === 1) continue; pairs.push([one, two]); } return pairs; } function isWalkableOuterLane(type) { return type === "Sidewalk" || type === "Shoulder"; } function roadEndpointGeometry(road, roadFeature, intersectionFeature, intersectionId) { const ring = normalizedRing(roadFeature.geometry.coordinates?.[0]); if (ring.length !== 4) return null; const shortEdges = shortEdgePairs(ring); if (!shortEdges) return null; const intersectionCenter = ringCenter(intersectionFeature.geometry.coordinates[0]); const candidates = shortEdges.map(([a, b]) => { const near = [ring[a], ring[b]]; return { pair: [a, b], center: midpoint(near[0], near[1]), distance: pointDistanceMeters(midpoint(near[0], near[1]), intersectionCenter, metersForLat(intersectionCenter[1])), }; }); candidates.sort((a, b) => a.distance - b.distance); const nearPair = candidates[0].pair; const farPair = candidates[1].pair; const nearPoints = nearPair.map((idx) => ring[idx]); const farPoints = farPair.map((idx) => ring[idx]); const nearCenter = midpoint(nearPoints[0], nearPoints[1]); const farCenter = midpoint(farPoints[0], farPoints[1]); const roadDirection = road.src_i === intersectionId ? normalizeLonLatVector([farCenter[0] - nearCenter[0], farCenter[1] - nearCenter[1]], nearCenter[1]) : normalizeLonLatVector([nearCenter[0] - farCenter[0], nearCenter[1] - farCenter[1]], nearCenter[1]); if (!roadDirection) return null; const correspondences = nearPair.map((idx) => { const farIdx = farPair.find((candidate) => circularIndexDistance(idx, candidate, ring.length) === 1); return farIdx === undefined ? null : [ring[idx], ring[farIdx]]; }); if (correspondences.some((pair) => !pair)) return null; const classified = correspondences.map(([nearPoint, farPoint]) => ({ near: nearPoint, far: farPoint, cross: signedSide(roadDirection, nearCenter, nearPoint, nearCenter[1]), })).sort((a, b) => a.cross - b.cross); return { nearCenter, nearLeft: classified[1].near, farLeft: classified[1].far, nearRight: classified[0].near, farRight: classified[0].far, }; } function shortEdgePairs(ring) { const lengths = ring.map((point, index) => lineLengthMeters(point, ring[(index + 1) % ring.length])); const optionA = lengths[0] + lengths[2]; const optionB = lengths[1] + lengths[3]; if (!Number.isFinite(optionA) || !Number.isFinite(optionB)) return null; return optionA <= optionB ? [[0, 1], [2, 3]] : [[1, 2], [3, 0]]; } function circularIndexDistance(a, b, size) { const distance = Math.abs(a - b); return Math.min(distance, size - distance); } function makeRoadEdge(road, geometry, side) { const lane = side === "left" ? road.lane_specs_ltr?.[0] : road.lane_specs_ltr?.[road.lane_specs_ltr.length - 1]; if (!lane) return null; const outerNear = side === "left" ? geometry.nearLeft : geometry.nearRight; const outerFar = side === "left" ? geometry.farLeft : geometry.farRight; const oppositeNear = side === "left" ? geometry.nearRight : geometry.nearLeft; const oppositeFar = side === "left" ? geometry.farRight : geometry.farLeft; const widthMeters = Number(lane.width) / 10000; const innerNear = moveTowards(outerNear, oppositeNear, widthMeters); const innerFar = moveTowards(outerFar, oppositeFar, widthMeters); return { roadId: road.id, laneType: lane.lt, laneCount: road.lane_specs_ltr?.length || 0, outerNear, innerNear, innerFar, }; } function synthesizeCornerFeature(one, two, intersectionFeature, maxDimensionMeters) { const ring = normalizedRing(intersectionFeature.geometry.coordinates?.[0]); const slice = shorterRingSliceBetween(ring, one.outerNear, two.outerNear); if (!slice || slice.length < 2) return null; const meetPoint = lineIntersection(one.innerFar, one.innerNear, two.innerFar, two.innerNear); const points = dedupeSequentialPoints([ ...slice, two.innerNear, ...(meetPoint && pointInPolygon(meetPoint, intersectionFeature.geometry.coordinates) ? [meetPoint] : []), one.innerNear, slice[0], ]); if (points.length < 4) return null; const feature = { type: "Feature", properties: { type: "sidewalk corner", source: "fallback", }, geometry: { type: "Polygon", coordinates: [points], }, }; const dimension = maxFeatureDimensionMeters(feature); if (dimension === null || dimension > maxDimensionMeters) return null; return feature; } function shorterRingSliceBetween(ring, start, end) { if (!ring.length) return null; const startIndex = nearestRingPointIndex(ring, start, 0.8); const endIndex = nearestRingPointIndex(ring, end, 0.8); if (startIndex === null || endIndex === null) return null; if (startIndex === endIndex) return [ring[startIndex]]; const forward = walkRing(ring, startIndex, endIndex, 1); const backward = walkRing(ring, startIndex, endIndex, -1); return pathLengthMeters(forward) <= pathLengthMeters(backward) ? forward : backward; } function walkRing(ring, startIndex, endIndex, direction) { const out = [ring[startIndex]]; let index = startIndex; while (index !== endIndex) { index = (index + direction + ring.length) % ring.length; out.push(ring[index]); } return out; } function pathLengthMeters(points) { let total = 0; for (let i = 1; i < points.length; i += 1) total += lineLengthMeters(points[i - 1], points[i]); return total; } function lineIntersection(a1, a2, b1, b2) { const originLat = (a1[1] + a2[1] + b1[1] + b2[1]) / 4; const meters = metersForLat(originLat); const ax1 = 0; const ay1 = 0; const ax2 = (a2[0] - a1[0]) * meters.lon; const ay2 = (a2[1] - a1[1]) * meters.lat; const bx1 = (b1[0] - a1[0]) * meters.lon; const by1 = (b1[1] - a1[1]) * meters.lat; const bx2 = (b2[0] - a1[0]) * meters.lon; const by2 = (b2[1] - a1[1]) * meters.lat; const denominator = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1); if (Math.abs(denominator) < 1e-9) return null; const ua = ((bx2 - bx1) * (ay1 - by1) - (by2 - by1) * (ax1 - bx1)) / denominator; return [ a1[0] + ((ax1 + ua * (ax2 - ax1)) / meters.lon), a1[1] + ((ay1 + ua * (ay2 - ay1)) / meters.lat), ]; } function normalizedRing(ring) { if (!Array.isArray(ring) || ring.length < 4) return []; const out = ring.map((point) => [point[0], point[1]]); if (pointDistance(out[0], out[out.length - 1]) <= 0.02) out.pop(); return out; } function nearestRingPointIndex(ring, point, maxDistanceMeters) { let bestIndex = null; let bestDistance = Infinity; for (let i = 0; i < ring.length; i += 1) { const distance = pointDistance(ring[i], point); if (distance < bestDistance) { bestDistance = distance; bestIndex = i; } } return bestDistance <= maxDistanceMeters ? bestIndex : null; } function nearestRingPoint(ring, point, maxDistanceMeters) { const normalized = normalizedRing(ring); const index = nearestRingPointIndex(normalized, point, maxDistanceMeters); return index === null ? null : normalized[index]; } function dedupeSequentialPoints(points, toleranceMeters = 0.02) { const out = []; for (const point of points) { if (!out.length || pointDistance(out[out.length - 1], point) > toleranceMeters) out.push(point); } if (out.length >= 2 && pointDistance(out[0], out[out.length - 1]) > toleranceMeters) out.push(out[0]); return out; } function dedupePointList(points, toleranceMeters) { const out = []; for (const point of points) { if (out.some((other) => pointDistance(point, other) <= toleranceMeters)) continue; out.push(point); } return out; } function midpoint(a, b) { return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; } function moveTowards(from, to, distanceMeters) { const meters = metersForLat((from[1] + to[1]) / 2); const unit = normalizeMetersVector([to[0] - from[0], to[1] - from[1]], meters); if (!unit) return from; return addMeters(from, unit, distanceMeters, meters); } function normalizeLonLatVector([dxLon, dyLat], lat) { return normalizeMetersVector([dxLon, dyLat], metersForLat(lat)); } function signedSide(direction, origin, point, lat) { const meters = metersForLat(lat); const dx = (point[0] - origin[0]) * meters.lon; const dy = (point[1] - origin[1]) * meters.lat; return direction[0] * dy - direction[1] * dx; } function pointDistance(a, b) { return lineLengthMeters(a, b); } function lineLengthMeters(a, b) { const meters = metersForLat((a[1] + b[1]) / 2); return Math.hypot((a[0] - b[0]) * meters.lon, (a[1] - b[1]) * meters.lat); } function ringCenter(ring) { const points = normalizedRing(ring); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2]; } function polygonAreaMeters2(feature) { const ring = normalizedRing(feature.geometry?.coordinates?.[0]); if (ring.length < 3) return 0; const meters = metersForLat(ring.reduce((sum, point) => sum + point[1], 0) / ring.length); let area = 0; for (let i = 0; i < ring.length; i += 1) { const a = ring[i]; const b = ring[(i + 1) % ring.length]; area += (a[0] * meters.lon) * (b[1] * meters.lat) - (b[0] * meters.lon) * (a[1] * meters.lat); } return Math.abs(area) / 2; } function maxFeatureDimensionMeters(feature) { const points = []; collectCoords(feature.geometry?.coordinates, points); if (points.length === 0) return null; const lat = points.reduce((sum, point) => sum + point[1], 0) / points.length; const meters = metersForLat(lat); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const width = (Math.max(...xs) - Math.min(...xs)) * meters.lon; const height = (Math.max(...ys) - Math.min(...ys)) * meters.lat; return Math.max(width, height); } function polygonRings(geometry) { if (!geometry?.coordinates) return []; if (geometry.type === "Polygon") return [geometry.coordinates]; if (geometry.type === "MultiPolygon") return geometry.coordinates; return []; } function isInAnyPolygon(feature, polygons, mode = "point") { if (!polygons.length) return false; const featureBbox = featureBounds(feature); const points = mode === "intersects" ? featurePoints(feature) : [representativePoint(feature)].filter(Boolean); if (!points.length) return false; return polygons.some(({ bbox, rings }) => ( bboxesOverlap(featureBbox, bbox, 0.00001) && points.some((point) => ( point[0] >= bbox.minLon - 0.00001 && point[0] <= bbox.maxLon + 0.00001 && point[1] >= bbox.minLat - 0.00001 && point[1] <= bbox.maxLat + 0.00001 && rings.some((polygon) => pointInPolygon(point, polygon)) )) )); } function featurePoints(feature) { const coords = []; collectCoords(feature.geometry?.coordinates, coords); if (!coords.length) return []; return [ coords[0], coords[Math.floor(coords.length / 2)], coords[coords.length - 1], ...bboxCorners(featureBounds(feature)), ]; } function bboxCorners(bbox) { return [ [bbox.minLon, bbox.minLat], [bbox.maxLon, bbox.minLat], [bbox.maxLon, bbox.maxLat], [bbox.minLon, bbox.maxLat], ]; } function bboxesOverlap(a, b, pad = 0) { return a.minLon <= b.maxLon + pad && a.maxLon >= b.minLon - pad && a.minLat <= b.maxLat + pad && a.maxLat >= b.minLat - pad; } function representativePoint(feature) { const coords = []; collectCoords(feature.geometry?.coordinates, coords); if (!coords.length) return null; return coords[Math.floor(coords.length / 2)]; } function featureCenter(feature) { const coords = []; collectCoords(feature.geometry?.coordinates, coords); if (!coords.length) return null; const xs = coords.map((point) => point[0]); const ys = coords.map((point) => point[1]); return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2]; } function pointInPolygon(point, rings) { if (!rings?.length || !pointInRing(point, rings[0])) return false; return !rings.slice(1).some((ring) => pointInRing(point, ring)); } function pointInRing([x, y], ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const xi = ring[i][0]; const yi = ring[i][1]; const xj = ring[j][0]; const yj = ring[j][1]; const intersect = ((yi > y) !== (yj > y)) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } function buildCrosswalks(osm) { const crossingNodes = markedCrossingNodes(osm); const clusterCenters = crossingClusters(crossingNodes); const stripes = emptyCollection(); const stopLines = emptyCollection(); const zones = []; const stopLineExclusionZones = []; for (const node of crossingNodes) { const way = findCrossingWay(osm, node.id); const vector = crossingVector(osm, way, node.id); if (!vector) continue; const crosswalk = crosswalkGeometry(node, way, vector, clusterCenters.get(node.id)); if (!crosswalk) continue; stripes.features.push(...crosswalk.stripes); if (crosswalk.stopLine) stopLines.features.push(crosswalk.stopLine); zones.push({ bbox: featureBounds(crosswalk.zone), rings: polygonRings(crosswalk.zone.geometry), }); stopLineExclusionZones.push({ bbox: featureBounds(crosswalk.stopLineExclusionZone), rings: polygonRings(crosswalk.stopLineExclusionZone.geometry), }); } return { stripes, stopLines, zones, stopLineExclusionZones }; } function markedCrossingNodes(osm) { return [...osm.nodes.values()].filter((node) => { if (node.tags.highway !== "crossing") return false; const markings = node.tags["crossing:markings"]; return !(markings && ["no", "none", "unmarked"].includes(markings)); }); } function crossingNodeClusters(nodes) { const clusters = []; for (const node of nodes) { let cluster = clusters.find((candidate) => { const center = clusterCenter(candidate); return distanceMeters(node, center) <= 45; }); if (!cluster) { cluster = []; clusters.push(cluster); } cluster.push(node); } return clusters; } function crossingClusters(nodes) { const clusters = crossingNodeClusters(nodes); const centers = new Map(); for (const cluster of clusters) { if (cluster.length < 2) continue; const center = clusterCenter(cluster); for (const node of cluster) centers.set(node.id, center); } return centers; } function clusterCenter(nodes) { return { lon: nodes.reduce((sum, node) => sum + node.lon, 0) / nodes.length, lat: nodes.reduce((sum, node) => sum + node.lat, 0) / nodes.length, }; } function distanceMeters(a, b) { const meters = metersForLat((a.lat + b.lat) / 2); return Math.hypot((a.lon - b.lon) * meters.lon, (a.lat - b.lat) * meters.lat); } function findCrossingWay(osm, nodeId) { const candidates = [...osm.ways.values()] .filter((way) => way.tags.highway && way.refs.includes(nodeId)); return candidates.find((way) => way.tags.highway !== "service") || candidates[0] || null; } function crossingVector(osm, way, nodeId) { if (!way) return null; const index = way.refs.indexOf(nodeId); const prev = osm.nodes.get(way.refs[index - 1]); const next = osm.nodes.get(way.refs[index + 1]); const current = osm.nodes.get(nodeId); if (prev && next) return [next.lon - prev.lon, next.lat - prev.lat]; if (prev && current) return [current.lon - prev.lon, current.lat - prev.lat]; if (next && current) return [next.lon - current.lon, next.lat - current.lat]; return null; } function crosswalkGeometry(node, way, vector, intersectionCenter) { const meters = metersForLat(node.lat); const stripeLength = crosswalkLengthMeters(way); const stripeWidth = 0.45; const gap = 0.45; const count = 6; const total = count * stripeWidth + (count - 1) * gap; const roadUnit = normalizeMetersVector(vector, meters); if (!roadUnit) return null; const acrossUnit = [-roadUnit[1], roadUnit[0]]; const center = [node.lon, node.lat]; const zoneCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 0.8, total + 0.8, meters); const stopLineExclusionCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 5, total + 1.5, meters); const zone = { type: "Feature", properties: { type: "crosswalk zone", crossing_node_id: node.id, }, geometry: { type: "Polygon", coordinates: [zoneCoords] }, }; const stopLineExclusionZone = { type: "Feature", properties: { type: "crosswalk stop line exclusion zone", crossing_node_id: node.id, }, geometry: { type: "Polygon", coordinates: [stopLineExclusionCoords] }, }; const stripes = []; for (let i = 0; i < count; i += 1) { const offset = -total / 2 + stripeWidth / 2 + i * (stripeWidth + gap); const stripeCenter = addMeters(center, acrossUnit, offset, meters); const coords = rectangleMeters(stripeCenter, roadUnit, acrossUnit, stripeLength, stripeWidth, meters); stripes.push({ type: "Feature", properties: { type: "crosswalk stripe", crossing_node_id: node.id, highway: way?.tags.highway || null, }, geometry: { type: "Polygon", coordinates: [coords] }, }); } return { stripes, zone, stopLineExclusionZone, stopLine: syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters), }; } function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) { if (!intersectionCenter) return null; const offset = stripeLength / 2 + 1.2; const candidateA = addMeters([node.lon, node.lat], roadUnit, offset, meters); const candidateB = addMeters([node.lon, node.lat], roadUnit, -offset, meters); const stopSide = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters) ? 1 : -1; const stopCenterOnRoad = stopSide === 1 ? candidateA : candidateB; const placement = stopLineLanePlacement(way, stripeLength, stopSide); const stopCenter = addMeters(stopCenterOnRoad, acrossUnit, placement.acrossOffset, meters); const coords = rectangleMeters(stopCenter, acrossUnit, roadUnit, placement.length, 0.45, meters); return { type: "Feature", properties: { type: "vehicle stop line", source: "crosswalk", crossing_node_id: node.id, highway: way?.tags.highway || null, stop_side: stopSide === 1 ? "with_way_outside" : "against_way_outside", }, geometry: { type: "Polygon", coordinates: [coords] }, }; } function pointDistanceMeters(pointA, point, meters) { const [lon, lat] = Array.isArray(pointA) ? pointA : [pointA.lon, pointA.lat]; const px = Array.isArray(point) ? point[0] : point.lon; const py = Array.isArray(point) ? point[1] : point.lat; return Math.hypot((lon - px) * meters.lon, (lat - py) * meters.lat); } function stopLineLanePlacement(way, roadWidth, stopSide) { if (isOneway(way)) { return { length: Math.max(2.8, roadWidth - 1.4), acrossOffset: 0 }; } const halfWidth = roadWidth / 2; const innerGap = 0.15; const outerGap = 0.8; const laneWidth = Math.max(2.4, halfWidth - innerGap - outerGap); // China uses right-hand traffic. If the stop line is on the +roadUnit side, // the approaching traffic direction is -roadUnit, whose right side is +acrossUnit. const laneSide = stopSide === 1 ? 1 : -1; return { length: laneWidth, acrossOffset: laneSide * (innerGap + laneWidth / 2), }; } function isOneway(way) { const value = String(way?.tags.oneway || "").toLowerCase(); return ["yes", "true", "1"].includes(value); } function crosswalkLengthMeters(way) { const lanes = Number(way?.tags.lanes); if (Number.isFinite(lanes) && lanes > 0) return Math.max(5, lanes * 3.2 + 1.2); if (way?.tags.highway === "secondary") return 8.5; if (way?.tags.highway === "service") return 4.5; return 7.5; } function metersForLat(lat) { return { lon: 111320 * Math.cos((lat * Math.PI) / 180), lat: 110540, }; } function normalizeMetersVector([dxLon, dyLat], meters) { const x = dxLon * meters.lon; const y = dyLat * meters.lat; const length = Math.hypot(x, y); if (!Number.isFinite(length) || length === 0) return null; return [x / length, y / length]; } function addMeters([lon, lat], [ux, uy], distance, meters) { return [lon + (ux * distance) / meters.lon, lat + (uy * distance) / meters.lat]; } function rectangleMeters(center, axisUnit, acrossUnit, axisWidth, acrossLength, meters) { const halfAxis = axisWidth / 2; const halfAcross = acrossLength / 2; const corners = [ [-halfAxis, -halfAcross], [halfAxis, -halfAcross], [halfAxis, halfAcross], [-halfAxis, halfAcross], [-halfAxis, -halfAcross], ]; return corners.map(([axis, across]) => { const x = axisUnit[0] * axis + acrossUnit[0] * across; const y = axisUnit[1] * axis + acrossUnit[1] * across; return [center[0] + x / meters.lon, center[1] + y / meters.lat]; }); } 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 normalizeLaneArrows(geojsonPath, outlineSimplifyMeters) { execFileSync(qgisPython, [ normalizeLaneArrowsScript, "--input", geojsonPath, "--outline-simplify-meters", String(outlineSimplifyMeters), ], { 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", "python", "plugins"), ].join(path.delimiter), }, }); } 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(", ")}] LAYER_PREFIX = ${JSON.stringify(options.layerPrefix || "osm2streets")} 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", f"{LAYER_PREFIX} road surface", "43,43,40,255", "30,30,28,255", "0.04"), "intersection_surface": make_layer("intersection_surface", f"{LAYER_PREFIX} intersection surface", "43,43,40,255", "30,30,28,255", "0.04"), "sidewalks": make_layer("sidewalks", f"{LAYER_PREFIX} sidewalks", "190,190,182,255", "156,156,148,255", "0.025"), "sidewalk_corners": make_layer("sidewalk_corners", f"{LAYER_PREFIX} sidewalk corners", "190,190,182,255", "156,156,148,255", "0.025"), "crosswalks": make_layer("crosswalks", f"{LAYER_PREFIX} crosswalks", "255,255,246,255"), "lane_separators": make_layer("lane_separators", f"{LAYER_PREFIX} lane separators", "238,238,230,255"), "center_lines": make_layer("center_lines", f"{LAYER_PREFIX} center lines", "245,190,42,255"), "vehicle_stop_lines": make_layer("vehicle_stop_lines", f"{LAYER_PREFIX} vehicle stop lines", "255,255,246,255"), "lane_arrows": make_layer("lane_arrows_webscale", f"{LAYER_PREFIX} lane arrows", "255,255,246,255", "43,43,40,200", "0.015"), } draw_order = ["road_surface", "intersection_surface", "sidewalks", "sidewalk_corners", "lane_separators", "center_lines", "crosswalks", "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); }