feat(extract): Querschnitt je Klasse und Fahrbahnband

Der Korridor, den Task 4 misst, ist auf der Karte eine einzige graue
Fläche - Fahrbahn und Gehweg tragen dort dieselbe Farbe. Der Querschnitt
teilt sie deshalb nur auf, statt sie zu vergrößern: Fahrbahn plus zweimal
Gehweg ergibt für jede Klasse genau die gemessene Breite, und der
Stadtgrundriss bleibt unberührt. Die Maße (Fahrspur 3,5 m, Parkspur
2,0-2,5 m, Gehweg 2,5-3,0 m) stehen im Entwurf; eine Assertion auf
Modulebene hält die Aufteilung für alle vier Klassen fest.

Das Fahrbahnband entsteht aus der Mittellinie: An jedem Kreuzungsende
(Grad >= 3) weicht sie um die halbe Fahrbahn der breitesten anliegenden
Kante plus 2 m zurück, freie Enden bleiben stehen. Eine Kante, die ganz
in den Kreuzungsflächen verschwindet, liefert kein Band - das füllt Task 6.

Testkorrektur: test_kante_ganz_innerhalb_der_kreuzung_liefert_kein_band
hatte an beiden "Kreuzungsknoten" nur je zwei Kanten, also Grad 2 statt
Grad 3 - keine Kreuzung nach der Definition, die trimmed_line() selbst
verwendet. Gegen die fertige Implementierung geprüft, lieferte sie eine
echte Linie statt None. Je ein zweiter Arm an beiden Knoten (wie schon in
test_kontraktion_zieht_alle_anliegenden_kanten_mit für denselben
3-m-Kreuzungsabstand) behebt das.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 02:12:16 +02:00
1 parent 2d8f4e730c
commit f91b906827
2 files changed
+141

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
"""Querschnitt je Straßenklasse und das Fahrbahnband, das daraus entsteht.
Die Maße stehen im Entwurf (docs/superpowers/specs/2026-09-16-strassen-design.md) und stammen
aus dem amerikanischen Regelwerk: Fahrspur 3,5 m, Längsparkspur 2,0-2,5 m, Gehweg 2,5-3,0 m.
Gemessen wird auf der Karte der ganze graue Korridor - Fahrbahn und Gehweg sind dort dasselbe
Grau. Die Aufteilung nimmt dem Korridor deshalb nichts weg und gibt ihm nichts dazu: Fahrbahn
plus zweimal Gehweg ist genau die gemessene Breite. Der Stadtgrundriss bleibt damit unberührt.
"""
from __future__ import annotations
from dataclasses import dataclass
from shapely.geometry import LineString, Point
from shapely.ops import substring, unary_union
from .roads import CLASS_WIDTH, Graph
LANE = 3.5
# Wie weit das Band vor dem Kreuzungsmittelpunkt endet: halbe Fahrbahn der breitesten
# anliegenden Kante plus etwas Luft, damit die Kreuzungsfläche die Lücke sicher füllt.
SETBACK_EXTRA = 2.0
# Breitester Gehweg. Weil die Gehwegfläche am Korridor beschnitten wird, ergibt sich die
# schmalere Variante von selbst: bei 12 m Korridor und 7 m Fahrbahn bleiben genau 2,5 m.
WALK_MAX = 3.0
@dataclass(frozen=True)
class Section:
corridor: float
carriageway: float
walk: float
lanes: int
parking: float
kerb: bool
SECTION = {
# Hinterhofgasse: eine Fahrgasse, kein Bordstein, kein Gehweg, keine Markierung.
"gasse": Section(corridor=6.0, carriageway=6.0, walk=0.0, lanes=1, parking=0.0, kerb=False),
"wohnstrasse": Section(corridor=12.0, carriageway=7.0, walk=2.5, lanes=2, parking=0.0, kerb=True),
"hauptstrasse": Section(corridor=18.0, carriageway=12.0, walk=3.0, lanes=2, parking=2.5, kerb=True),
"boulevard": Section(corridor=24.0, carriageway=18.0, walk=3.0, lanes=4, parking=2.0, kerb=True),
}
assert all(s.carriageway + 2 * s.walk == s.corridor == CLASS_WIDTH[n] for n, s in SECTION.items())
def setback(graph: Graph, node: int) -> float:
return max(SECTION[graph.edges[k].klass].carriageway / 2 for k in graph.incident(node)) + SETBACK_EXTRA
def trimmed_line(graph: Graph, k: int):
"""Die Mittellinie zwischen den Kreuzungsrändern. Freie Enden bleiben, wo sie sind."""
edge = graph.edges[k]
line = LineString(edge.points)
start = setback(graph, edge.a) if graph.degree(edge.a) >= 3 else 0.0
end = line.length - (setback(graph, edge.b) if graph.degree(edge.b) >= 3 else 0.0)
if end - start < 1.0:
return None # die Kante liegt ganz in den Kreuzungen; die füllen sie mit
return substring(line, start, end)
def ribbon(graph: Graph, k: int):
"""Das Fahrbahnband einer Kante: Mittellinie auf Fahrbahnbreite gepuffert.
join_style mitre mit knappem Limit: Eine Straße mit einem Knick soll eine gerade Außenkante
behalten statt einer abgerundeten, aber bei einem sehr spitzen Knick würde eine unbegrenzte
Gehrung einen meterlangen Dorn erzeugen.
"""
line = trimmed_line(graph, k)
if line is None:
return None
return line.buffer(SECTION[graph.edges[k].klass].carriageway / 2,
cap_style="flat", join_style="mitre", mitre_limit=1.5)
+68
View File
@@ -0,0 +1,68 @@
"""Querschnitte und Fahrbahnbänder."""
from __future__ import annotations
from leonida.roads import CLASS_WIDTH, Edge, Graph, Node
from leonida.section import LANE, SECTION, ribbon, setback, trimmed_line
def strecke(klass: str, length: float = 100.0) -> Graph:
return Graph(nodes=[Node((0.0, 0.0)), Node((length, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (length, 0.0)], CLASS_WIDTH[klass], klass)])
def test_fahrbahn_und_zwei_gehwege_ergeben_den_korridor():
for name, section in SECTION.items():
assert section.carriageway + 2 * section.walk == section.corridor, name
assert section.corridor == CLASS_WIDTH[name], name
def test_spuren_und_parkspuren_fuellen_die_fahrbahn():
for name, section in SECTION.items():
if name == "gasse":
continue # eine Fahrgasse ohne Spureinteilung, breiter als eine Regelspur
assert section.lanes * LANE + 2 * section.parking == section.carriageway, name
def test_nur_die_gasse_hat_keinen_bordstein():
assert [name for name, s in SECTION.items() if not s.kerb] == ["gasse"]
def test_band_ist_so_breit_wie_die_fahrbahn():
band = ribbon(strecke("hauptstrasse"), 0)
x0, y0, x1, y1 = band.bounds
assert round(y1 - y0, 6) == SECTION["hauptstrasse"].carriageway
assert round(x1 - x0, 6) == 100.0, "freie Enden werden nicht zurückgeschnitten"
def test_an_einer_kreuzung_wird_zurueckgeschnitten():
graph = Graph(
nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((100.0, 60.0)), Node((160.0, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse"),
Edge(1, 2, [(100.0, 0.0), (100.0, 60.0)], 12.0, "wohnstrasse"),
Edge(1, 3, [(100.0, 0.0), (160.0, 0.0)], 12.0, "wohnstrasse")])
assert graph.degree(1) == 3
r = setback(graph, 1)
assert r == SECTION["wohnstrasse"].carriageway / 2 + 2.0
line = trimmed_line(graph, 0)
assert round(line.length, 6) == round(100.0 - r, 6)
assert line.coords[0] == (0.0, 0.0), "das freie Ende bleibt, nur das Kreuzungsende weicht"
def test_kante_ganz_innerhalb_der_kreuzung_liefert_kein_band():
# Zwei Kreuzungen (Grad 3) nur 3 m auseinander - genau der Fall, den CONTRACT_MAX in roads.py
# sonst zusammenzieht, hier aber synthetisch stehen gelassen, um trimmed_line() an diesem
# Rand zu prüfen. Der Auftrag hatte hier nur ein Kreuz aus vier Knoten mit je einem Arm an
# den Knoten 0 und 1 - das ergibt Grad 2, keine Kreuzung, und die Kante würde nicht
# zurückgeschnitten. Erst der zweite Arm an jedem der beiden Knoten macht sie zu echten
# Kreuzungen (Grad 3), deren Rückschnitt die 3 m kurze Verbindungskante ganz auffrisst.
graph = Graph(nodes=[Node((0.0, 0.0)), Node((3.0, 0.0)),
Node((0.0, 50.0)), Node((0.0, -50.0)),
Node((3.0, 50.0)), Node((3.0, -50.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (3.0, 0.0)], 18.0, "hauptstrasse"),
Edge(0, 2, [(0.0, 0.0), (0.0, 50.0)], 18.0, "hauptstrasse"),
Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], 18.0, "hauptstrasse"),
Edge(1, 4, [(3.0, 0.0), (3.0, 50.0)], 18.0, "hauptstrasse"),
Edge(1, 5, [(3.0, 0.0), (3.0, -50.0)], 18.0, "hauptstrasse")])
assert graph.degree(0) == 3 and graph.degree(1) == 3
assert trimmed_line(graph, 0) is None
assert ribbon(graph, 0) is None