diff --git a/tools/extract/leonida/roads.py b/tools/extract/leonida/roads.py index 490b6e8..ad84beb 100644 --- a/tools/extract/leonida/roads.py +++ b/tools/extract/leonida/roads.py @@ -358,7 +358,11 @@ def insert_edge(graph: Graph, points: list[tuple[float, float]], width: float, k # 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. + # anhängt, damit an einer Kreuzung nicht zwei Knoten wenige Meter auseinander liegen. Kreuzt + # die neue Linie zwei verschiedene Kanten weniger als JUNCTION_RADIUS auseinander, verschmilzt + # attach() beide Punkte deshalb zu einem einzigen Knoten - genau die Regel, mit der schon + # _junction_clusters() beim Aufbau aus dem Skelett benachbarte Kreuzungspixel zusammenfasst: + # Alles innerhalb des Radius gilt als dieselbe Kreuzung, nicht als zwei dicht nebeneinander. crossings: list[tuple[float, float]] = [] for edge in graph.edges: line = LineString(edge.points) @@ -379,25 +383,36 @@ def insert_edge(graph: Graph, points: list[tuple[float, float]], width: float, k 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.""" + """Die Punkte, an denen sich zwei Linien berühren. + + Läuft die neue Linie ein Stück lang deckungsgleich mit einer Bestandskante und kreuzt sie + danach woanders wirklich, liefert intersection() eine GeometryCollection aus dieser + Überlappung (eine LineString) und dem echten Kreuzungspunkt (ein Point) - hier werden nur die + Punkte daraus behalten. Die Überlappung selbst bleibt unberührt: Wo genau zwei deckungsgleiche + Kanten sich trennen sollen, ist ein Datenfehler in den Eingabedaten, den diese Funktion nicht + heilen kann. + """ 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 [] + return [(g.x, g.y) for g in getattr(hit, "geoms", [hit]) if g.geom_type == "Point"] 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`.""" + Enden nicht auf denselben Knoten fallen (Regel d) - attach() sucht dafür je Ende in `out`. + + Die gefundenen Knoten-IDs ersetzen die eigenen Endpunkte in points: attach() darf einen bis zu + JUNCTION_RADIUS entfernten Knoten wiederverwenden, und ohne diesen Knick hinge die Kante an + einem Knoten, auf dem sie geometrisch gar nicht liegt - section.py baut seine Kreuzungsarme in + junction_arms() aus genau diesen Endpunkten, nicht aus dem Knotenpunkt. _contract_short() zieht + seit Task 4 aus demselben Grund die Kante zum verschobenen Knoten nach. + """ 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 + points = [out.nodes[a].point, *points[1:-1], out.nodes[b].point] out.edges.append(Edge(a=a, b=b, points=points, width=width, klass=klass)) diff --git a/tools/extract/tests/test_park_street.py b/tools/extract/tests/test_park_street.py index 1ce5c38..30de5c9 100644 --- a/tools/extract/tests/test_park_street.py +++ b/tools/extract/tests/test_park_street.py @@ -66,7 +66,9 @@ def test_alley_centerline_shrinks_to_its_stubs_and_the_street_gets_its_own(): 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" + # Genau Grad 4, nicht bloß "mindestens 3": an jeder der beiden Kreuzungen treffen sich + # zwei gespaltene Kanten (Querstraße und Jefferson Avenue), macht 2 + 2. + assert result.graph.degree(knoten) == 4, 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) diff --git a/tools/extract/tests/test_roads_insert.py b/tools/extract/tests/test_roads_insert.py index 73d1ddd..bf5fdb4 100644 --- a/tools/extract/tests/test_roads_insert.py +++ b/tools/extract/tests/test_roads_insert.py @@ -4,6 +4,14 @@ from __future__ import annotations from leonida.roads import Edge, Graph, Node, insert_edge +def _kanten_enden_auf_ihrem_knoten(graph: Graph) -> None: + """Invariante über den ganzen Graphen: jede Kante muss geometrisch auf den Knoten treffen, + an dem sie laut a/b hängt - sonst baut section.py ihre Kreuzungsarme am falschen Punkt.""" + for edge in graph.edges: + assert edge.points[0] == graph.nodes[edge.a].point + assert edge.points[-1] == graph.nodes[edge.b].point + + 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))], @@ -15,6 +23,7 @@ def test_kreuzung_in_der_mitte_bekommt_einen_knoten_mit_grad_4(): 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) + _kanten_enden_auf_ihrem_knoten(out) def t_kreuzung() -> Graph: @@ -33,6 +42,7 @@ def test_neues_ende_haengt_sich_an_einen_knoten_mit_grad_3(): 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" + _kanten_enden_auf_ihrem_knoten(out) def kreuz_mit_nahem_knoten() -> Graph: @@ -51,3 +61,59 @@ def test_kreuzungspunkt_nahe_einem_knoten_bekommt_keinen_zweiten(): # 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 + _kanten_enden_auf_ihrem_knoten(out) + + +def test_kanten_enden_immer_auf_ihrem_knoten(): + """Regressionswache (Fixrunde 2, Befund 1): attach() darf einen bis zu JUNCTION_RADIUS + entfernten Knoten wiederverwenden, aber dann muss die Kante zu ihm hinknicken. Mit der alten + Fassung endete hier die Kreuzungskante bei (50,50), obwohl sie an Knoten 2 - tatsächlich bei + (54,54) - hängt: 5,66 m Lücke, genau der Fehler, den section.py in junction_arms() nicht + verzeiht, weil dort mit edge.points gerechnet wird, nicht mit dem Knotenpunkt.""" + out = insert_edge(kreuz_mit_nahem_knoten(), [(50.0, 0.0), (50.0, 100.0)], 12.0, "wohnstrasse") + _kanten_enden_auf_ihrem_knoten(out) + + +def bestandskante() -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((200.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (200.0, 0.0)], 12.0, "wohnstrasse")]) + + +def test_kreuzung_nach_ueberlappung_geht_nicht_verloren(): + """Regressionswache (Fixrunde 2, Befund 2): läuft die neue Linie ein Stück lang deckungsgleich + mit einer Bestandskante und kreuzt sie danach an einer anderen Stelle, liefert intersection() + eine GeometryCollection aus der Überlappung (LineString) und dem echten Kreuzungspunkt + (Point) - _crossing_points() darf dabei nicht die ganze Fläche verwerfen, nur weil sie kein + reiner Point/MultiPoint ist, sonst bleibt die Bestandskante an einer echten Kreuzung ungeteilt. + """ + linie = [(0.0, 0.0), (50.0, 0.0), (50.0, -50.0), (150.0, -50.0), (150.0, 0.0), (150.0, 50.0)] + out = insert_edge(bestandskante(), linie, 6.0, "gasse") + knoten = next(k for k, n in enumerate(out.nodes) if n.point == (150.0, 0.0)) + assert out.degree(knoten) >= 3 + _kanten_enden_auf_ihrem_knoten(out) + + +def zwei_kreuzungen_nah_beieinander() -> Graph: + """Zwei parallele Kanten 5 m auseinander (unter JUNCTION_RADIUS) - beim Netzaufbau aus dem + Skelett gäbe es dort ohnehin nur einen Kreuzungsknoten, nie zwei so nah beieinander.""" + return Graph(nodes=[Node((0.0, 50.0)), Node((100.0, 50.0)), Node((0.0, 55.0)), Node((100.0, 55.0))], + edges=[Edge(0, 1, [(0.0, 50.0), (100.0, 50.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(0.0, 55.0), (100.0, 55.0)], 12.0, "wohnstrasse")]) + + +def test_kreuzungen_naeher_als_der_radius_werden_ein_knoten(): + """Festhalten, nicht ändern (Fixrunde 2, Punkt 3): Kreuzt die neue Linie zwei verschiedene + Kanten weniger als JUNCTION_RADIUS auseinander, verschmelzen beide zu einem Knoten - dieselbe + Regel wie beim Aufbau des Graphen aus dem Skelett (_junction_clusters()): Alles innerhalb des + Radius ist eine einzige Kreuzung, nicht zwei dicht nebeneinander. + """ + out = insert_edge(zwei_kreuzungen_nah_beieinander(), [(50.0, 0.0), (50.0, 100.0)], 12.0, "wohnstrasse") + berührt = {e.a for e in out.edges} | {e.b for e in out.edges} + kreuzungen = [k for k in berührt if out.degree(k) >= 3] + assert len(kreuzungen) == 1, "die beiden Kreuzungspunkte müssen auf denselben Knoten fallen" + # Beide Bestandskanten tragen je 2 bei (ihr eigener Zweiteil-Schnitt); von der neuen Linie + # bleiben nur die beiden äußeren Stücke (Nord- und Südanschluss), ihr 5-m-Mittelstück + # zwischen den beiden Kreuzungspunkten fällt nach Regel d weg (beide Enden auf demselben + # Knoten) - macht 2 + 2 + 2 = 6. + assert out.degree(kreuzungen[0]) == 6 + _kanten_enden_auf_ihrem_knoten(out)