diff --git a/tools/extract/leonida/park.py b/tools/extract/leonida/park.py index 9a93e9a..3c3b52f 100644 --- a/tools/extract/leonida/park.py +++ b/tools/extract/leonida/park.py @@ -17,7 +17,7 @@ from shapely.geometry import LineString, Point, Polygon from shapely.ops import unary_union from .heights import PALM_CLEARANCE, PALM_KERB, palm_points -from .roads import CLASS_WIDTH, Edge, Graph, attach, cut_edges +from .roads import CLASS_WIDTH, Graph, cut_edges, insert_edge class ParkError(ValueError): @@ -497,15 +497,15 @@ def apply_street(world: World, street: Street) -> World: kept.append((piece, bridge)) roads = kept + [(piece, False) for piece in _polygons(unary_union(joined), MIN_ROAD_AREA)] - # Die Gasse verschwindet auch aus dem Graphen, und die Jefferson Avenue kommt als Kante - # dazu - mit attach() an die Querstraße gehängt, damit dort eine echte Kreuzung entsteht - # statt eines freien Endes. Die Klasse folgt der Breite aus der Parkdatei wie bei jeder + # Die Gasse verschwindet auch aus dem Graphen, und die Jefferson Avenue kommt als Kante dazu. + # insert_edge() statt eines reinen attach()-Paars: Die Avenue endet nicht zwangsläufig an + # einer Kreuzung, sie kann eine Querstraße auch mittendrin schneiden (am echten Netz die + # 11th Street) - dort muss ein echter Knoten entstehen, sonst läge die neue Fahrbahn nur + # unverbunden über der alten. Die Klasse folgt der Breite aus der Parkdatei wie bei jeder # anderen Kante auch. graph = cut_edges(world.graph, cut) klass = min(CLASS_WIDTH, key=lambda name: abs(CLASS_WIDTH[name] - street.width)) - graph.edges.append(Edge(a=attach(graph, street.start), b=attach(graph, street.end), - points=[tuple(street.start), tuple(street.end)], - width=street.width, klass=klass)) + graph = insert_edge(graph, [tuple(street.start), tuple(street.end)], street.width, klass) freed = unary_union(removed + [cut]).difference(corridor) covered = [b[0] for b in buildings if b[0].intersects(freed)] + \ diff --git a/tools/extract/leonida/roads.py b/tools/extract/leonida/roads.py index 65ba76e..490b6e8 100644 --- a/tools/extract/leonida/roads.py +++ b/tools/extract/leonida/roads.py @@ -18,7 +18,8 @@ from dataclasses import dataclass, field import networkx as nx import numpy as np from scipy import ndimage as ndi -from shapely.geometry import LineString, Polygon +from shapely.geometry import LineString, Point, Polygon +from shapely.ops import substring from skimage.morphology import disk from .coords import SLICE @@ -302,11 +303,23 @@ def cut_edges(graph: Graph, cut: Polygon) -> Graph: if piece.geom_type != "LineString" or piece.length < MIN_EDGE: continue points = [(round(x, 2), round(y, 2)) for x, y in piece.coords] - out.edges.append(Edge(a=attach(out, points[0]), b=attach(out, points[-1]), + out.edges.append(Edge(a=_cut_end(out, points[0], edge), b=_cut_end(out, points[-1], edge), points=points, width=edge.width, klass=edge.klass)) return _renumber(out) +def _cut_end(graph: Graph, point: tuple[float, float], edge: Edge) -> int: + """Ein Ende eines Schnittstücks: der ursprüngliche Knoten, wenn das Stück dort endet, sonst ein + neues Sackgassenende. Nicht attach(): dessen 8-m-Umkreis fände bei einem Reststück unter 8 m für + beide Enden denselben Knoten und machte aus dem Stück eine Schleife.""" + for node in (edge.a, edge.b): + px, py = graph.nodes[node].point + if abs(px - point[0]) < 0.01 and abs(py - point[1]) < 0.01: + return node + graph.nodes.append(Node((float(point[0]), float(point[1])))) + return len(graph.nodes) - 1 + + def attach(graph: Graph, point: tuple[float, float], radius: float = JUNCTION_RADIUS) -> int: """Knoten-ID an dieser Stelle: der nächste innerhalb von radius, sonst ein neuer. @@ -324,6 +337,70 @@ def attach(graph: Graph, point: tuple[float, float], radius: float = JUNCTION_RA return len(graph.nodes) - 1 +# Kürzestes Teilstück, das insert_edge() beim Aufbrechen einer gekreuzten Kante stehen lässt; +# kürzer wäre praktisch derselbe Punkt und keine Fahrbahn. Dieselbe Grenze wie trimmed_line() in +# section.py für "die Kante liegt ganz in der Kreuzung". +INSERT_MIN = 1.0 + + +def insert_edge(graph: Graph, points: list[tuple[float, float]], width: float, klass: str) -> Graph: + """Eine von Hand ergänzte Straße so ins Netz einfügen, dass sie an ihren Kreuzungen Knoten bildet. + + attach() allein reicht nur, wenn ein Ende der neuen Straße neben einem vorhandenen Knoten + liegt. Die Jefferson Avenue lief am echten Netz mittendrin über die 11th Street hinweg, ohne + eines ihrer beiden Enden dort zu haben: attach() hätte die neue Fahrbahn einfach über die alte + gelegt, ohne dass an der Stelle eine Kreuzungsfläche, ein Eckradius, ein Zebrastreifen oder + eine Haltelinie entsteht. + """ + out = Graph(nodes=list(graph.nodes), edges=[]) + new_line = LineString(points) + + # Jede vorhandene Kante, die die neue Linie kreuzt, an den Kreuzungspunkten aufbrechen. + # attach() entscheidet je Punkt, ob dort ein neuer Knoten entsteht oder ein vorhandener in + # der Nähe genügt (Regel b) - dieselbe Funktion, die gleich auch die Enden der neuen Straße + # anhängt, damit an einer Kreuzung nicht zwei Knoten wenige Meter auseinander liegen. + crossings: list[tuple[float, float]] = [] + for edge in graph.edges: + line = LineString(edge.points) + found = _crossing_points(new_line, line) + if not found: + out.edges.append(edge) + continue + crossings += found + stops = sorted({0.0, line.length} | {line.project(Point(p)) for p in found}) + for start, end in zip(stops, stops[1:]): + _add_piece(out, line, start, end, edge.width, edge.klass) + + # Dieselben Kreuzungspunkte (jetzt echte Knoten) plus die beiden Enden der neuen Straße. + stops = sorted({0.0, new_line.length} | {new_line.project(Point(p)) for p in crossings}) + for start, end in zip(stops, stops[1:]): + _add_piece(out, new_line, start, end, width, klass) + return _renumber(out) + + +def _crossing_points(a: LineString, b: LineString) -> list[tuple[float, float]]: + """Die Punkte, an denen sich zwei Linien berühren - deckungsgleiche Teilstücke (kein Punkt, + sondern selbst eine Linie) sind ein Datenfehler und werden hier ignoriert, nicht aufgebrochen.""" + hit = a.intersection(b) + if hit.geom_type == "Point": + return [(hit.x, hit.y)] + if hit.geom_type == "MultiPoint": + return [(p.x, p.y) for p in hit.geoms] + return [] + + +def _add_piece(out: Graph, line: LineString, start: float, end: float, width: float, klass: str) -> None: + """Das Teilstück von `line` zwischen zwei Stationen anhängen, wenn es lang genug ist und seine + Enden nicht auf denselben Knoten fallen (Regel d) - attach() sucht dafür je Ende in `out`.""" + if end - start < INSERT_MIN: + return + points = [(round(x, 2), round(y, 2)) for x, y in substring(line, start, end).coords] + a, b = attach(out, points[0]), attach(out, points[-1]) + if a == b: + return + out.edges.append(Edge(a=a, b=b, points=points, width=width, klass=klass)) + + def _median_width(line: LineString, distance: np.ndarray) -> float: start, end = WIDTH_MARGIN, line.length - WIDTH_MARGIN if end <= start: diff --git a/tools/extract/tests/test_park_street.py b/tools/extract/tests/test_park_street.py index 2c75e5f..1ce5c38 100644 --- a/tools/extract/tests/test_park_street.py +++ b/tools/extract/tests/test_park_street.py @@ -51,14 +51,27 @@ def test_alley_centerline_shrinks_to_its_stubs_and_the_street_gets_its_own(): wählten ihre Straßenseite über den Listenindex und liefen an der Kreuzung sonst ins Leere - entfällt, sobald eine spätere Aufgabe die Seite aus der Geometrie bestimmt statt aus der Liste. Die beiden 11-m-Enden bleiben deshalb jetzt als eigene, kurze Kanten stehen. + + Die neue Straße kreuzt dabei selbst beide Querstraßen bei y=650 und y=330 (insert_edge(), + Fixrunde 1): an beiden Stellen muss ein echter Knoten mit Grad >= 3 entstehen, sonst läge die + neue Fahrbahn nur unverbunden über der alten - genau der Fehler, den ein reines attach() an + den beiden Enden nicht gesehen hätte, weil die Kreuzung nicht an einem Ende liegt, sondern + mittendrin. Die Straße selbst zerfällt deshalb jetzt in drei Stücke statt eines. """ result = apply_street(world(), STREET) lines = [LineString(e.points) for e in result.graph.edges] - assert sorted(round(line.length) for line in lines if line.length < 15) == [11, 11] + assert sorted(round(line.length) for line in lines if line.length < 15) == [10, 10, 11, 11] assert all(line.distance(Point(100, 500)) > 40 for line in lines) - assert any(e.points == [tuple(STREET.start), tuple(STREET.end)] for e in result.graph.edges) assert all(line.length >= MIN_EDGE for line in lines) + for x, y in ((150.0, 650.0), (150.0, 330.0)): + knoten = next(k for k, n in enumerate(result.graph.nodes) if n.point == (x, y)) + assert result.graph.degree(knoten) >= 3, f"Kreuzung bei ({x:g}, {y:g}) ohne echten Knoten" + + assert any(e.points == [(150.0, 660.0), (150.0, 650.0)] for e in result.graph.edges) + assert any(e.points == [(150.0, 650.0), (150.0, 330.0)] for e in result.graph.edges) + assert any(e.points == [(150.0, 330.0), (150.0, 320.0)] for e in result.graph.edges) + def test_freed_ground_becomes_urban_but_not_under_the_street_buildings_or_old_urban(): result = apply_street(world(), STREET) diff --git a/tools/extract/tests/test_roads_cut.py b/tools/extract/tests/test_roads_cut.py index c2d07d9..21d0106 100644 --- a/tools/extract/tests/test_roads_cut.py +++ b/tools/extract/tests/test_roads_cut.py @@ -39,6 +39,18 @@ def test_unberuehrte_kante_bleibt_wie_sie_ist(): assert out.edges[0].klass == "wohnstrasse" +def test_kurze_reststuecke_werden_keine_schleifen(): + # Genau das Fenster, das die Senkung von MIN_CENTERLINE (15 m) auf MIN_EDGE (5 m) geöffnet + # hat: ein 6-m-Rest liegt noch im 8-m-Anhängeradius seines eigenen alten Endpunkts. Ein + # Schnitt, der beide Enden über attach() sucht, würde dort denselben Knoten zweimal finden + # und aus dem geraden Stummel eine Schleife machen (Edge mit a == b). + out = cut_edges(strecke(), box(6, -5, 94, 5)) + assert len(out.edges) == 2 + assert all(e.a != e.b for e in out.edges), "ein gerader Stummel darf keine Schleife werden" + längen = sorted(round(LineString(e.points).length) for e in out.edges) + assert längen == [6, 6] + + def test_attach_findet_einen_nahen_knoten(): graph = strecke() assert attach(graph, (100.0, 3.0)) == 1, "3 m entfernt: derselbe Knoten" diff --git a/tools/extract/tests/test_roads_insert.py b/tools/extract/tests/test_roads_insert.py new file mode 100644 index 0000000..73d1ddd --- /dev/null +++ b/tools/extract/tests/test_roads_insert.py @@ -0,0 +1,53 @@ +"""Eine von Hand ergänzte Straße ins Netz einfügen, mit echten Knoten an ihren Kreuzungen.""" +from __future__ import annotations + +from leonida.roads import Edge, Graph, Node, insert_edge + + +def kreuz() -> Graph: + """Eine 100 m lange Straße bei y = 50, von (0,50) nach (100,50).""" + return Graph(nodes=[Node((0.0, 50.0)), Node((100.0, 50.0))], + edges=[Edge(0, 1, [(0.0, 50.0), (100.0, 50.0)], 12.0, "wohnstrasse")]) + + +def test_kreuzung_in_der_mitte_bekommt_einen_knoten_mit_grad_4(): + out = insert_edge(kreuz(), [(50.0, 0.0), (50.0, 100.0)], 12.0, "wohnstrasse") + knoten = next(k for k, n in enumerate(out.nodes) if n.point == (50.0, 50.0)) + assert out.degree(knoten) == 4 + assert not any(e.a == e.b for e in out.edges) + + +def t_kreuzung() -> Graph: + """Ein T bei (50,0): die Strecke (0,0)-(100,0) durchgehend, ein Ast nach Norden - Grad 3.""" + return Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((50.0, 0.0)), Node((50.0, 50.0))], + edges=[Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], 12.0, "wohnstrasse"), + Edge(2, 1, [(50.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(50.0, 0.0), (50.0, 50.0)], 12.0, "wohnstrasse")]) + + +def test_neues_ende_haengt_sich_an_einen_knoten_mit_grad_3(): + graph = t_kreuzung() + assert graph.degree(2) == 3 + # (54,6) liegt 7,2 m von Knoten 2 entfernt (< JUNCTION_RADIUS), aber auf keiner der drei + # vorhandenen Kanten - reine Anhänge-Nähe, keine Kreuzung. + out = insert_edge(graph, [(54.0, 6.0), (94.0, 46.0)], 6.0, "gasse") + assert out.degree(2) == 4 + assert len(out.nodes) == 5, "nur das ferne Ende der neuen Gasse ist ein neuer Knoten" + + +def kreuz_mit_nahem_knoten() -> Graph: + """kreuz(), dazu ein Sackgassenfuss 5,7 m neben der kommenden Kreuzung bei (50,50).""" + return Graph(nodes=[Node((0.0, 50.0)), Node((100.0, 50.0)), Node((54.0, 54.0)), Node((54.0, 90.0))], + edges=[Edge(0, 1, [(0.0, 50.0), (100.0, 50.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(54.0, 54.0), (54.0, 90.0)], 6.0, "gasse")]) + + +def test_kreuzungspunkt_nahe_einem_knoten_bekommt_keinen_zweiten(): + """Regel b: ein Kreuzungspunkt im 8-m-Umkreis eines vorhandenen Knotens bekommt keinen + eigenen neuen Knoten - sonst lägen zwei Knoten nur wenige Meter auseinander.""" + out = insert_edge(kreuz_mit_nahem_knoten(), [(50.0, 0.0), (50.0, 100.0)], 12.0, "wohnstrasse") + assert not any(n.point == (50.0, 50.0) for n in out.nodes), "kein zweiter Knoten 5,7 m daneben" + assert len(out.nodes) == 6 # 4 vorhandene + 2 neue Sackgassen + # Am wiederverwendeten Knoten (54,54) treffen sich jetzt der alte Sackgassenfuss (Grad 1) und + # die neue Kreuzung selbst (zwei gekreuzte Straßen, je zweimal gespalten: Grad 4) - macht 5. + assert out.degree(2) == 5