From 662f8d908bbb1649d8e47393784e1ff6550a2e41 Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 21 Jul 2026 17:39:07 +0800 Subject: [PATCH] Add crosswalk and intersection surface rendering --- README.md | 4 +- scripts/build-osm2streets-qgis.js | 386 +++++++++++++++++++++++++++++- 2 files changed, 380 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 14c3f2b..5614c8d 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,16 @@ node scripts/build-osm2streets-qgis.js --arrow-scale 0.8 GeoPackage 内会生成: - `road_surface` +- `intersection_surface` - `sidewalks` - `sidewalk_corners` - `lane_separators` - `center_lines` - `vehicle_stop_lines` - `lane_arrows_webscale` +- `crosswalks` -QGIS 工程的绘制顺序已经固定为:路面在底,箭头、停止线、中心线在上。 +QGIS 工程的绘制顺序已经固定为:路段和路口路面在底,车道线、斑马线、停止线和箭头在上。 ## 注意 diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index a78099e..5fdf19e 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -48,6 +48,7 @@ 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); @@ -58,30 +59,35 @@ 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); +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); 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(split.laneArrows.features[0] || split.roadSurface.features[0]), + featureBounds(previewFeature), previewPad, )); fs.writeFileSync(qgisScript, makeQgisScript({ @@ -223,6 +229,56 @@ function getOsmBounds(xmlText) { 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 { @@ -301,19 +357,33 @@ function emptyCollection() { return { type: "FeatureCollection", features: [] }; } -function splitLayers(dir, arrowScaleValue) { +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: 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; @@ -321,19 +391,315 @@ function splitLayers(dir, arrowScaleValue) { 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; - 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)); + 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 = [...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)); + }); + 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 crossingClusters(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); + } + 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 stopCenter = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters) + ? candidateA + : candidateB; + const coords = rectangleMeters(stopCenter, acrossUnit, roadUnit, crosswalkWidth + 0.8, 0.45, meters); + return { + type: "Feature", + properties: { + type: "vehicle stop line", + source: "crosswalk", + crossing_node_id: node.id, + highway: way?.tags.highway || null, + }, + geometry: { type: "Polygon", coordinates: [coords] }, + }; +} + +function pointDistanceMeters([lon, lat], point, meters) { + return Math.hypot((lon - point.lon) * meters.lon, (lat - point.lat) * meters.lat); +} + +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); @@ -429,14 +795,16 @@ 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", "sidewalks", "sidewalk_corners", "lane_separators", "center_lines", "vehicle_stop_lines", "lane_arrows"] +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()