feat(extract): amerikanische Laengsmarkierungen als Geometrie

Gelbe Mitte, Spurtrenner und Randlinien je Kante, zwischen den Kreuzungsraendern -
Geometrie statt Textur, weil Haltelinie und Zebrastreifen (Task 9) quer zur
Fahrtrichtung liegen und in einer laengs laufenden Kachel nicht unterzubringen waeren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 07:32:20 +02:00
1 parent f2f5db94fe
commit 3216e9407f
2 files changed
+142

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
"""Amerikanische Fahrbahnmarkierung als Geometrie.
Das Schema ist das Gegenteil des europäischen: Gelb trennt Gegenrichtungen und steht deshalb in
der Mitte, Weiß trennt gleichgerichtete Spuren und begrenzt die Fahrbahn. Die mitgelieferten
Texturen von modular_city_road_pack__game_ready zeigen das europäische Schema und sind hier
deshalb unbrauchbar (Entwurf, Abschnitt "Die beiden Kits").
Markierungen sind flache Vierecke, keine Textur: An Kreuzungen liegen Haltelinie und
Zebrastreifen quer zur Fahrtrichtung und ließen sich in einer längs laufenden Kachel gar nicht
unterbringen, und aus der Nähe bleibt Geometrie scharf, wo eine Textur verwischt.
"""
from __future__ import annotations
from shapely.geometry import LineString
from shapely.ops import substring
from .roads import Graph
from .section import LANE, SECTION, trimmed_line
WIDTH = 0.10 # 4 in
DASH = 3.0
GAP = 9.0
KINDS = ("gelb_gestrichelt", "gelb_doppelt", "weiss_gestrichelt", "weiss_rand",
"haltelinie", "zebra")
def longitudinal(graph: Graph) -> list[tuple[object, str]]:
"""Mittellinie, Spurtrenner und Randlinien je Kante, zwischen den Kreuzungsrändern."""
out = []
for k, edge in enumerate(graph.edges):
section = SECTION[edge.klass]
line = trimmed_line(graph, k)
if line is None or section.lanes < 2:
continue # die Gasse hat nur eine Fahrgasse und bleibt blank
if section.parking == 0.0:
# Wohnstraße: eine gestrichelte gelbe Mitte, Überholen erlaubt.
out += [(p, "gelb_gestrichelt") for p in _dashed(line, 0.0)]
else:
# Doppelt durchgezogen: zwei Linien mit 10 cm Abstand der Mitten.
out += [(_solid(line, off), "gelb_doppelt") for off in (-WIDTH, WIDTH)]
rand = section.carriageway / 2 - section.parking
out += [(_solid(line, off), "weiss_rand") for off in (-rand, rand)]
if section.lanes >= 4:
# Zwischen den gleichgerichteten Spuren, je Richtung einer.
out += [(p, "weiss_gestrichelt") for off in (-LANE, LANE) for p in _dashed(line, off)]
return out
def _offset(line: LineString, distance: float) -> LineString:
if distance == 0.0:
return line
# offset_curve versetzt nach links; negative Werte nach rechts. Bei geraden Straßen ist das
# exakt, bei Knicken folgt die Kurve dem Knick - genau das soll eine Markierung auch.
return line.offset_curve(distance)
def _solid(line: LineString, distance: float):
return _offset(line, distance).buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)
def _dashed(line: LineString, distance: float) -> list:
"""Striche im Takt DASH/GAP, beginnend am Anfang der Kante.
Die Phase läuft bewusst nicht über Kreuzungen hinweg weiter: Jede Kante fängt neu an. Alles
andere hieße, eine Ordnung zu behaupten, die die Karte gar nicht hergibt.
"""
versetzt = _offset(line, distance)
out, at = [], 0.0
while at < versetzt.length:
end = min(at + DASH, versetzt.length)
if end - at > 0.5:
out.append(substring(versetzt, at, end).buffer(
WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5))
at = end + GAP
return out
+67
View File
@@ -0,0 +1,67 @@
"""Längsmarkierungen: Mittellinie, Spurtrenner, Randlinien."""
from __future__ import annotations
from collections import Counter
from leonida.roads import Edge, Graph, Node
from leonida.marking import DASH, GAP, WIDTH, longitudinal
from leonida.section import SECTION
def strecke(klass: str, length: float = 120.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)], SECTION[klass].corridor, klass)])
def arten(graph: Graph) -> Counter:
return Counter(art for _, art in longitudinal(graph))
def test_gasse_bekommt_keine_markierung():
assert longitudinal(strecke("gasse")) == []
def test_wohnstrasse_bekommt_eine_gestrichelte_gelbe_mitte():
zahl = arten(strecke("wohnstrasse"))
assert set(zahl) == {"gelb_gestrichelt"}
# 120 m in 12-m-Takten: zehn Striche.
assert zahl["gelb_gestrichelt"] == 10
assert DASH == 3.0 and GAP == 9.0
def test_hauptstrasse_bekommt_doppelte_mitte_und_zwei_randlinien():
zahl = arten(strecke("hauptstrasse"))
assert zahl["gelb_doppelt"] == 2, "zwei durchgezogene Linien nebeneinander"
assert zahl["weiss_rand"] == 2, "je eine an der Grenze zur Parkspur"
assert "weiss_gestrichelt" not in zahl, "zwei Spuren brauchen keinen Spurtrenner"
def test_boulevard_bekommt_zusaetzlich_spurtrenner():
zahl = arten(strecke("boulevard"))
assert zahl["gelb_doppelt"] == 2
assert zahl["weiss_rand"] == 2
assert zahl["weiss_gestrichelt"] == 20, "je Richtung ein gestrichelter Trenner, 10 Striche"
def test_randlinie_liegt_an_der_grenze_zur_parkspur():
section = SECTION["hauptstrasse"]
ränder = [poly for poly, art in longitudinal(strecke("hauptstrasse")) if art == "weiss_rand"]
abstände = sorted(round(abs(poly.centroid.y), 2) for poly in ränder)
erwartet = round(section.carriageway / 2 - section.parking, 2)
assert abstände == [erwartet, erwartet]
def test_linien_sind_zehn_zentimeter_breit():
poly = longitudinal(strecke("wohnstrasse"))[0][0]
x0, y0, x1, y1 = poly.bounds
assert round(y1 - y0, 3) == WIDTH
assert round(x1 - x0, 3) == DASH
def test_markierung_endet_am_kreuzungsrand():
graph = Graph(nodes=[Node((0.0, 0.0)), Node((120.0, 0.0)), Node((120.0, 60.0)), Node((180.0, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (120.0, 0.0)], 12.0, "wohnstrasse"),
Edge(1, 2, [(120.0, 0.0), (120.0, 60.0)], 12.0, "wohnstrasse"),
Edge(1, 3, [(120.0, 0.0), (180.0, 0.0)], 12.0, "wohnstrasse")])
ende = max(poly.bounds[2] for poly, _ in longitudinal(graph) if poly.bounds[2] < 120)
assert ende < 120 - SECTION["wohnstrasse"].carriageway / 2, "vor der Kreuzung ist Schluss"