fix(extract): insert_edge() knickt Kanten zu ihrem Knoten, verliert keine Kreuzung mehr bei Überlappung

Fixrunde 2 zu Task 11, zwei von der Koordination beim Bau von Gegenbeispielen gefundene Fehler in
insert_edge() aus Fixrunde 1, einer davon schon in den echten Daten nachweisbar:

1. attach() darf einen bis zu JUNCTION_RADIUS (8 m) entfernten Knoten wiederverwenden, aber
   _add_piece() übernahm dessen ID in a/b, ohne den zugehörigen Endpunkt in points auf die
   Knotenkoordinate zu ziehen - die Kante hing dadurch an einem Punkt, an dem sie geometrisch gar
   nicht lag. Am echten Netz endete die Jefferson Avenue so 6,65 m neben Knoten 561, an dem sie
   laut Graph hängt; section.py baut die Kreuzungsarme in junction_arms() aus genau diesen
   Endpunkten. Behoben wie _contract_short() es seit Task 4 vormacht: den betroffenen Endpunkt
   durch die Knotenkoordinate ersetzen, die Kante bekommt dadurch einen kurzen Knick zum Knoten.
   Neue Invariante über den ganzen Graphen (test_roads_insert.py): points[0] == nodes[a].point und
   points[-1] == nodes[b].point für jede Kante.

2. _crossing_points() kannte nur Point und MultiPoint. Überlappt die neue Linie eine Bestandskante
   ein Stück weit und kreuzt sie danach an einer anderen Stelle wirklich, liefert intersection()
   eine GeometryCollection aus der Überlappung (LineString) und dem echten Kreuzungspunkt (Point) -
   die wurde bisher komplett verworfen, samt Kreuzungspunkt. Jetzt werden aus jeder Geometrie
   (Point, MultiPoint oder GeometryCollection) nur die Point-Anteile behalten.

Festgehalten, nicht geändert: Kreuzt die neue Linie zwei verschiedene Kanten näher als
JUNCTION_RADIUS zueinander, verschmelzen beide zu einem Knoten - dieselbe Regel, mit der
_junction_clusters() beim Netzaufbau aus dem Skelett benachbarte Kreuzungspixel zusammenfasst.
Mit Fix 1 knickt die zweite beteiligte Kante jetzt sauber zu diesem gemeinsamen Knoten statt
knapp daneben zu enden. test_park_street.py zieht die Kreuzungsgrad-Zusicherung von ">= 3" auf
"== 4" an, weil an einer echten Kreuzung zweier gespaltener Straßen genau das gilt.

Am echten Netz nachgemessen: Die Jefferson Avenue endet jetzt exakt auf Knoten 561 (1551,67 |
332,67), nicht mehr 6,65 m daneben; die vier von insert_edge() neu geschriebenen Kanten haben
0,000000 m Lücke zu ihrem Knoten. Eine bereits vor Flamingo Park bestehende Rundungsdifferenz
zwischen unrundeten Knotenkoordinaten aus _junction_clusters() und den auf 2 Dezimalstellen
gerundeten Kantenpunkten aus clean() betrifft ~1.250 von 1.790 Kanten mit höchstens 7,1 mm
Abstand, unverändert vorher wie nachher - das ist keine Folge dieser beiden Fixe und liegt
außerhalb des heutigen Umfangs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 17:59:01 +02:00
1 parent 1f09e7b1d9
commit bab83bce5f
3 files changed
+93 -10

No files matched your search

+66
View File
@@ -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)