fix(extract): cut_edges() bildet keine Schleifen mehr, Straßen kreuzen das Netz statt es zu überlagern

Fixrunde 1 zu Task 11, zwei von der Koordination gefundene und am echten Netz nachgemessene
Fehler, beide durch die Senkung von MIN_CENTERLINE (15 m) auf MIN_EDGE (5 m) erst möglich:

1. cut_edges() hängte beide Enden eines Schnittstücks über attach() an - dessen 8-m-Umkreis fand
   bei einem Reststück zwischen 5 und 8 m Länge für beide Enden denselben ursprünglichen Knoten
   und machte aus dem geraden Stummel eine Schleife. section.py hält jeden Knoten ab Grad 3 für
   eine Kreuzung; an einem Sackgassenende wären dort Kreuzungsfläche, Zebrastreifen und
   Haltelinie entstanden. Die neue _cut_end() sucht nicht mehr im Umkreis, sondern nur noch nach
   exakter Übereinstimmung mit einem der beiden ursprünglichen Kantenenden.

2. apply_street() hängte die Jefferson Avenue nur mit einem attach()-Paar an ihre beiden Enden.
   Am echten Netz liegt ihr Nordende 23,2 m von jedem Knoten entfernt, aber die Straße kreuzt auf
   dem Weg zu ihrem Südende (6,6 m von Knoten 561) die 11th Street mittendrin, ohne dass dort ein
   Knoten entsteht - zwei Fahrbahnen hätten sich unverbunden überlagert. Die neue insert_edge()
   bricht jede gekreuzte Kante am Kreuzungspunkt auf und teilt sich dort einen Knoten mit der
   neuen Straße (mit attach() für die Knotenwahl, wie gehabt); apply_street() benutzt sie jetzt
   statt des attach()-Paars.

Am echten Netz (1.537 Knoten, 1.790 Kanten vor Flamingo Park) bestätigt: Jefferson Avenue bricht
jetzt korrekt an der 11th Street auf (neuer Knoten, Grad 4) und hängt am Südende weiterhin an
Knoten 561 (Grad 3 -> 4); die Meldezeile und die Sperrflächen-Warnung blieben wörtlich. Die
Schleifenzahl im Gesamtnetz sank von 196 auf 193 (die Park-Anlage schneidet alte Schleifen im
Parkgebiet weg), keine der verbliebenen 193 entstand neu - jede hat ein Vor-Park-Gegenstück im
5-m-Umkreis. Keine Kante unter 1 m, weder vorher noch nachher.

Neue Tests: test_roads_cut.py (Schleifenregression), test_roads_insert.py (drei Szenarien:
Kreuzung in der Mitte, Anhängen an einen Grad-3-Knoten, Kreuzungspunkt nahe einem vorhandenen
Knoten), test_park_street.py (Knotengrad an beiden Kreuzungen der Park-Zufahrtsstraße).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 17:26:42 +02:00
1 parent 8bc8ca19fc
commit 1f09e7b1d9
5 files changed
+166 -11

No files matched your search

+7 -7
View File
@@ -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)] + \
+79 -2
View File
@@ -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:
+15 -2
View File
@@ -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)
+12
View File
@@ -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"
+53
View File
@@ -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