#!/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 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, 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); 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, 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) { // 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, 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 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: intersections, 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 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 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 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"), "intersection_surface": make_layer("intersection_surface", "osm2streets intersection 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"), "crosswalks": make_layer("crosswalks", "osm2streets crosswalks", "255,255,246,255"), "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", "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); }