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:
1 parent
8bc8ca19fc
commit
1f09e7b1d9
5 files changed
+166
-11
No files matched your search
@@ -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)] + \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user