diff --git a/README.md b/README.md index 7c29678..1a3f58e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ cp config/examples/template.json config/areas/my-area.json }, "qgis": { "arrowScale": 0.8, + "arrowMergeTriangles": true, + "arrowOutlineSimplifyMeters": 0.05, + "intersectionCornerSourceMaxDimensionMeters": 2.6, "clipPad": 0.002, "canvasPad": 0.001, "previewPad": 0.0007, @@ -92,6 +95,13 @@ cp config/examples/template.json config/areas/my-area.json } ``` +QGIS road-layer knobs: + +- `arrowScale`: scales osm2streets lane-arrow polygons before export. +- `arrowMergeTriangles`: merges each osm2streets lane-arrow triangle mesh into one valid polygon. This preserves the original arrow shape and turn direction while removing renderer gaps along shared triangle edges. +- `arrowOutlineSimplifyMeters`: removes sub-decimeter kinks from the merged arrow exterior. The default `0.05` removes the two malformed tail vertices without changing the arrow head; the remaining tail edge is aligned perpendicular to the shaft. +- `intersectionCornerSourceMaxDimensionMeters`: keeps only small osm2streets `sidewalk corner` polygons. Large intersection-marking polygons are not treated as sidewalk because they can cover the drivable junction. + `scripts/build-area.js` 会按 `id` 自动推导默认输出路径。确实需要定制时,可以增加 `outputs` 覆盖: ```json diff --git a/config/areas/nantaizi-lake-innovation-valley.json b/config/areas/nantaizi-lake-innovation-valley.json index f838173..1a1a21c 100644 --- a/config/areas/nantaizi-lake-innovation-valley.json +++ b/config/areas/nantaizi-lake-innovation-valley.json @@ -11,6 +11,9 @@ }, "qgis": { "arrowScale": 0.8, + "arrowMergeTriangles": true, + "arrowOutlineSimplifyMeters": 0.05, + "intersectionCornerSourceMaxDimensionMeters": 2.6, "clipPad": 0.002, "canvasPad": 0.001, "previewPad": 0.0007, diff --git a/config/examples/template.json b/config/examples/template.json index 705b839..325b00e 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -11,6 +11,9 @@ }, "qgis": { "arrowScale": 0.8, + "arrowMergeTriangles": true, + "arrowOutlineSimplifyMeters": 0.05, + "intersectionCornerSourceMaxDimensionMeters": 2.6, "clipPad": 0.002, "canvasPad": 0.001, "previewPad": 0.0007, diff --git a/scripts/build-area.js b/scripts/build-area.js index 16d538b..33f10ce 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -101,6 +101,9 @@ function normalizeAreaConfig(raw) { }, qgis: { arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8, + arrowMergeTriangles: raw.qgis?.arrowMergeTriangles ?? raw.arrowMergeTriangles ?? true, + arrowOutlineSimplifyMeters: raw.qgis?.arrowOutlineSimplifyMeters ?? raw.arrowOutlineSimplifyMeters ?? 0.05, + intersectionCornerSourceMaxDimensionMeters: raw.qgis?.intersectionCornerSourceMaxDimensionMeters ?? raw.intersectionCornerSourceMaxDimensionMeters ?? 2.6, clipPad: raw.qgis?.clipPad ?? raw.clipPad ?? 0.002, canvasPad: raw.qgis?.canvasPad ?? raw.canvasPad ?? 0.001, previewPad: raw.qgis?.previewPad ?? raw.previewPad ?? 0.0007, @@ -175,6 +178,9 @@ function buildIntermediates(area) { project: area.outputs.qgisProject, preview: area.outputs.qgisPreview, arrowScale: area.qgis.arrowScale, + arrowMergeTriangles: area.qgis.arrowMergeTriangles, + arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters, + intersectionCornerSourceMaxDimensionMeters: area.qgis.intersectionCornerSourceMaxDimensionMeters, clipPad: area.qgis.clipPad, canvasPad: area.qgis.canvasPad, previewPad: area.qgis.previewPad, diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index 3c72921..0d2c79e 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -14,12 +14,16 @@ 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); @@ -28,6 +32,12 @@ 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]}`); @@ -41,6 +51,9 @@ for (const exe of [ogr2ogr, qgisPython]) { 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 }); @@ -60,7 +73,12 @@ 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); +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); @@ -68,6 +86,10 @@ 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)); @@ -161,6 +183,9 @@ function loadConfig(file, cliArgs) { project: "project", preview: "preview", arrowScale: "arrowScale", + arrowMergeTriangles: "arrowMergeTriangles", + arrowOutlineSimplifyMeters: "arrowOutlineSimplifyMeters", + intersectionCornerSourceMaxDimensionMeters: "intersectionCornerSourceMaxDimensionMeters", clipPad: "clipPad", pad: "clipPad", canvasPad: "canvasPad", @@ -428,7 +453,7 @@ function sceneStyle() { }; } -function splitLayers(dir, arrowScaleValue, osm) { +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")); @@ -445,7 +470,7 @@ function splitLayers(dir, arrowScaleValue, osm) { centerLines: emptyCollection(), vehicleStopLines: crosswalkData.stopLines, laneArrows: emptyCollection(), - sidewalkCorners: intersections, + sidewalkCorners: filteredSidewalkCorners(intersections, maxCornerDimensionMeters), crosswalks: crosswalkData.stripes, }; const serviceDrivingPolygons = []; @@ -455,7 +480,6 @@ function splitLayers(dir, arrowScaleValue, osm) { out.intersectionSurface.features.push(feature); } } - for (const feature of lanePolygons.features || []) { const type = feature.properties?.type; if (type === "Sidewalk" || type === "Footway") { @@ -479,7 +503,9 @@ function splitLayers(dir, arrowScaleValue, osm) { 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)); + if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) { + out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue)); + } } return out; @@ -490,6 +516,30 @@ function hasAnyWayId(value, ids) { 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 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]; @@ -846,6 +896,26 @@ function scaleCoords(obj, 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"); diff --git a/scripts/normalize-lane-arrows.py b/scripts/normalize-lane-arrows.py new file mode 100644 index 0000000..ce53f78 --- /dev/null +++ b/scripts/normalize-lane-arrows.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Normalize malformed lane-arrow meshes emitted by osm2streets.""" + +import argparse +import json +import math +from pathlib import Path + +from osgeo import ogr + + +METERS_PER_DEGREE = 111320.0 +ogr.UseExceptions() + + +def cli_args(): + parser = argparse.ArgumentParser( + description="Normalize osm2streets triangulated lane-arrow polygons." + ) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--outline-simplify-meters", required=True, type=float) + return parser.parse_args() + + +def point_segment_distance(point, start, end, latitude): + meters_lon = METERS_PER_DEGREE * math.cos(math.radians(latitude)) + px = (point[0] - start[0]) * meters_lon + py = (point[1] - start[1]) * METERS_PER_DEGREE + bx = (end[0] - start[0]) * meters_lon + by = (end[1] - start[1]) * METERS_PER_DEGREE + length_squared = bx * bx + by * by + if length_squared == 0: + return math.hypot(px, py) + projection = max(0.0, min(1.0, (px * bx + py * by) / length_squared)) + return math.hypot(px - projection * bx, py - projection * by) + + +def simplify_ring(points, tolerance, latitude): + points = list(points) + changed = True + while changed and len(points) > 3: + changed = False + for index, point in enumerate(points): + previous = points[index - 1] + following = points[(index + 1) % len(points)] + if point_segment_distance(point, previous, following, latitude) <= tolerance: + points.pop(index) + changed = True + break + return points + + +def tail_edge_candidate(points): + best = None + for index in range(len(points)): + start = points[index] + end = points[(index + 1) % len(points)] + previous = points[index - 1] + following = points[(index + 2) % len(points)] + before = [start[0] - previous[0], start[1] - previous[1]] + after = [following[0] - end[0], following[1] - end[1]] + edge = [end[0] - start[0], end[1] - start[1]] + before_length = math.hypot(*before) + after_length = math.hypot(*after) + edge_length = math.hypot(*edge) + if min(before_length, after_length, edge_length) == 0: + continue + alignment = ( + before[0] * after[0] + before[1] * after[1] + ) / (before_length * after_length) + if before_length <= edge_length or after_length <= edge_length or alignment >= -0.9: + continue + score = -alignment * min(before_length, after_length) / edge_length + if best is None or score > best[0]: + best = (score, index, before, after, before_length, after_length) + return best + + +def square_arrow_tail(points, latitude): + # A normalized straight arrow has seven exterior vertices. Other arrow + # silhouettes are left untouched because their tail cannot be inferred safely. + if len(points) != 7: + return points + meters_lon = METERS_PER_DEGREE * math.cos(math.radians(latitude)) + origin = points[0] + local = [ + [ + (point[0] - origin[0]) * meters_lon, + (point[1] - origin[1]) * METERS_PER_DEGREE, + ] + for point in points + ] + candidate = tail_edge_candidate(local) + if candidate is None: + return points + _, index, before, after, before_length, after_length = candidate + axis = [ + before[0] / before_length - after[0] / after_length, + before[1] / before_length - after[1] / after_length, + ] + axis_length = math.hypot(*axis) + if axis_length == 0: + return points + axis = [axis[0] / axis_length, axis[1] / axis_length] + end_index = (index + 1) % len(local) + midpoint = [ + (local[index][0] + local[end_index][0]) / 2.0, + (local[index][1] + local[end_index][1]) / 2.0, + ] + for point_index in (index, end_index): + offset = [ + local[point_index][0] - midpoint[0], + local[point_index][1] - midpoint[1], + ] + projection = offset[0] * axis[0] + offset[1] * axis[1] + local[point_index][0] -= projection * axis[0] + local[point_index][1] -= projection * axis[1] + points[point_index] = [ + origin[0] + local[point_index][0] / meters_lon, + origin[1] + local[point_index][1] / METERS_PER_DEGREE, + ] + return points + + +def normalize_polygon(geometry, tolerance): + if geometry.GetGeometryName() == "MULTIPOLYGON": + geometry = geometry.UnionCascaded() + if geometry is None or geometry.GetGeometryName() != "POLYGON": + raise ValueError("triangle merge did not produce a Polygon") + + source_ring = geometry.GetGeometryRef(0) + points = [source_ring.GetPoint(i)[:2] for i in range(source_ring.GetPointCount() - 1)] + if len(points) <= 3: + raise ValueError("arrow exterior has too few points") + latitude = sum(point[1] for point in points) / len(points) + points = simplify_ring(points, tolerance, latitude) + points = square_arrow_tail(points, latitude) + + normalized = ogr.Geometry(ogr.wkbPolygon) + outer = ogr.Geometry(ogr.wkbLinearRing) + for point in points + [points[0]]: + outer.AddPoint_2D(*point) + normalized.AddGeometry(outer) + for index in range(1, geometry.GetGeometryCount()): + normalized.AddGeometry(geometry.GetGeometryRef(index)) + if normalized.IsEmpty() or not normalized.IsValid(): + raise ValueError("normalized arrow geometry is invalid") + return normalized + + +def normalize_file(input_path, tolerance): + with input_path.open("r", encoding="utf-8") as handle: + collection = json.load(handle) + + for index, feature in enumerate(collection.get("features", [])): + geometry = ogr.CreateGeometryFromJson(json.dumps(feature.get("geometry", {}))) + if geometry is None or geometry.IsEmpty(): + raise ValueError(f"feature {index} has no usable geometry") + try: + normalized = normalize_polygon(geometry, tolerance) + except ValueError as error: + raise ValueError(f"feature {index}: {error}") from error + feature["geometry"] = json.loads( + normalized.ExportToJson(options=["COORDINATE_PRECISION=15"]) + ) + + temp_path = input_path.with_name(f".{input_path.name}.tmp") + with temp_path.open("w", encoding="utf-8") as handle: + json.dump(collection, handle, ensure_ascii=False, separators=(",", ":")) + handle.write("\n") + temp_path.replace(input_path) + + +def main(): + args = cli_args() + if args.outline_simplify_meters < 0: + raise ValueError("--outline-simplify-meters must be non-negative") + normalize_file(args.input.resolve(), args.outline_simplify_meters) + + +if __name__ == "__main__": + main()