From 3216e9407f219216e468667342f7d5c8508b1c14 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 07:32:20 +0200 Subject: [PATCH] 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 --- tools/extract/leonida/marking.py | 75 ++++++++++++++++++++++++ tools/extract/tests/test_marking_long.py | 67 +++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tools/extract/leonida/marking.py create mode 100644 tools/extract/tests/test_marking_long.py diff --git a/tools/extract/leonida/marking.py b/tools/extract/leonida/marking.py new file mode 100644 index 0000000..dda1651 --- /dev/null +++ b/tools/extract/leonida/marking.py @@ -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 diff --git a/tools/extract/tests/test_marking_long.py b/tools/extract/tests/test_marking_long.py new file mode 100644 index 0000000..c8bc547 --- /dev/null +++ b/tools/extract/tests/test_marking_long.py @@ -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"