Befund 3: In junction_marks() kam die Richtung einer Zufahrt aus origin -> points[1], also nur aus dem ersten Segment der Kante. Haltelinie und Zebrastreifen wurden aber bis reach + STOP_SETBACK + STOP_DEPTH (bis 16 m) auf dieser Geraden aufgespannt - knickte die Kante innerhalb dieser Strecke ab, landete die Markierung daneben statt darauf. Am echten Netz (gemessen über alle 144 committeten Chunks): 452,65 m² von 43.906 m² Markierungsfläche (1,03 %) liegen außerhalb der Fahrbahn, davon 291,44 m² Zebra und 147,68 m² Haltelinie; die schlimmsten Einzelfälle sind vollständige Haltelinien mit 5,41 m². Die Richtung kommt jetzt aus line.interpolate(min(reach, länge)) - dem tatsächlichen Punkt auf der Kante bei Streckenlänge reach - statt aus dem zweiten Stützpunkt. Befund 4: offset_curve() liefert bei einem engen Zickzack eine MultiLineString statt einer LineString; substring() in _dashed() akzeptiert nur LineString und wirft dann GeometryTypeError. Am heutigen Mosaik tritt das nicht auf, aber eine geänderte Korrektur oder eine eingehängte Kante mit engem Knick hätte den Vier-Minuten-Lauf abgebrochen. Zweiter, stiller Ast: Eine sehr spitze Kehre lässt offset_curve() leer zurückkommen; _solid() puffert das ungeprüft zu einem Nullflächen-Polygon. _offset() normalisiert jetzt auf eine Liste von LineStrings (leer -> [], MultiLineString -> ihre geoms), _dashed() und _solid() iterieren darüber; longitudinal() flacht die jetzt listenwertigen _solid()-Aufrufe ab. Beide Befunde mit Tests, die ohne die jeweilige Korrektur rot sind: der Zickzack-Fall bricht am unkorrigierten Code mit GeometryTypeError ab, der Kehre-Fall lieferte zwei Nullflächen-Polygone unter den Rand-/ Doppellinien.
194 lines
10 KiB
Python
194 lines
10 KiB
Python
"""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, Polygon
|
|
from shapely.ops import substring
|
|
|
|
from .roads import Graph
|
|
from .section import LANE, SECTION, setback, 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[Polygon, 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: Die Achsen liegen bei ±WIDTH, also 20 cm auseinander. Jede
|
|
# Linie ist WIDTH breit (Pufferung um WIDTH/2 je Seite), sodass von den 20 cm genau
|
|
# WIDTH = 10 cm als Lücke zwischen den gemalten Linien übrig bleiben - das ist der im
|
|
# Entwurf verlangte Abstand, nicht der Achsabstand selbst.
|
|
out += [(p, "gelb_doppelt") for off in (-WIDTH, WIDTH) for p in _solid(line, off)]
|
|
rand = section.carriageway / 2 - section.parking
|
|
out += [(p, "weiss_rand") for off in (-rand, rand) for p in _solid(line, off)]
|
|
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) -> list[LineString]:
|
|
"""Der versetzte Linienzug, als Liste normalisiert (Befund 4 der Fixwelle).
|
|
|
|
offset_curve() liefert bei einem engen Zickzack keine einzelne LineString, sondern eine
|
|
MultiLineString (nachgestellt: eine S-Kurve mit 1 m Amplitude, LANE=3,5 m Versatz spaltet sich
|
|
in zwei Teilstücke) - substring() in _dashed() akzeptiert aber nur LineString und wirft sonst
|
|
GeometryTypeError. An einer sehr spitzen Kehre kann das Ergebnis auch ganz leer sein; eine
|
|
leere LineString ungeprüft gepuffert (_solid()) ergäbe ein Nullflächen-Polygon statt gar
|
|
nichts. Heute tritt keiner der beiden Fälle am Mosaik auf, aber eine geänderte Korrektur, ein
|
|
anderer Ausschnitt oder eine eingehängte Kante mit engem Knick reicht dafür.
|
|
"""
|
|
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.
|
|
versetzt = line.offset_curve(distance)
|
|
if versetzt.is_empty:
|
|
return []
|
|
return list(versetzt.geoms) if versetzt.geom_type == "MultiLineString" else [versetzt]
|
|
|
|
|
|
def _solid(line: LineString, distance: float) -> list[Polygon]:
|
|
return [teil.buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)
|
|
for teil in _offset(line, distance)]
|
|
|
|
|
|
def _dashed(line: LineString, distance: float) -> list:
|
|
"""Striche im Takt DASH/GAP, beginnend am Anfang jedes Teilstücks.
|
|
|
|
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. Ein Zickzack-Versatz
|
|
aus mehreren Teilstücken (_offset()) fängt entsprechend in jedem Teilstück neu an - eine Naht,
|
|
die am Mosaik nicht vorkommt und die auch die Kreuzungsgrenze schon in Kauf nimmt.
|
|
"""
|
|
out = []
|
|
for versetzt in _offset(line, distance):
|
|
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
|
|
|
|
|
|
CROSSWALK_DEPTH = 3.0 # Tiefe des Zebrastreifens in Fahrtrichtung, 10 ft
|
|
BAR = 0.6 # Balkenbreite und Lücke, 2 ft - das Miami-Balkenmuster
|
|
STOP_DEPTH = 0.6
|
|
STOP_SETBACK = 4.2 # Kreuzungsrand + Zebrastreifen (3,0) + Sicherheitsabstand (1,2)
|
|
|
|
|
|
def markings(graph: Graph) -> list[tuple[Polygon, str]]:
|
|
out = longitudinal(graph)
|
|
for node in range(len(graph.nodes)):
|
|
if graph.degree(node) >= 3:
|
|
out += junction_marks(graph, node)
|
|
return out
|
|
|
|
|
|
def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]:
|
|
"""Zebrastreifen und Haltelinie je Zufahrt.
|
|
|
|
Ein Zebrastreifen entsteht nur, wenn die Kreuzung mindestens zwei Arme ab Wohnstraße hat -
|
|
zwischen zwei Gassen quert niemand. Arme, die selbst Gassen sind, bleiben in jedem Fall
|
|
blank.
|
|
"""
|
|
incident = graph.incident(node)
|
|
echte = [k for k in incident if SECTION[graph.edges[k].klass].kerb]
|
|
if len(echte) < 2:
|
|
return []
|
|
reach = setback(graph, node)
|
|
origin = graph.nodes[node].point
|
|
out = []
|
|
for k in echte:
|
|
edge = graph.edges[k]
|
|
# Eine Schleife hängt mit beiden Enden am selben Knoten und stellt deshalb zwei getrennte
|
|
# Zufahrten dar (wie bei junction_arms() in section.py - Fixrunde zu Task 9, am echten
|
|
# Netz betrifft das 28 von 690 Kreuzungen mit einer bordsteintragenden Schleife). incident()
|
|
# liefert sie nur einmal, und edge.a == node ist bei ihr immer wahr - ohne die zweite
|
|
# Richtung bekäme die Schleife auf einer ihrer beiden Seiten weder Haltelinie noch Zebra.
|
|
richtungen = ([edge.points, list(reversed(edge.points))] if edge.a == edge.b
|
|
else [edge.points if edge.a == node else list(reversed(edge.points))])
|
|
for points in richtungen:
|
|
line = LineString(points)
|
|
länge = line.length
|
|
# Die Richtung kommt aus der tatsächlichen Kante bei Streckenlänge "reach", nicht aus
|
|
# dem zweiten Stützpunkt (Befund 3 der Fixwelle): Ein Knick konnte innerhalb der ersten
|
|
# reach Meter liegen - reach ist der Kreuzungsradius plus Luft, oft mehrere Meter -,
|
|
# während die alte Richtung (Anfang -> zweiter Stützpunkt) bis reach + STOP_SETBACK +
|
|
# STOP_DEPTH (bis 16 m) unverändert weiterlief. Am echten Netz lagen dadurch 452,65 m²
|
|
# Markierungsfläche (1,03 % der Gesamtfläche) neben statt auf der Fahrbahn, darunter
|
|
# ganze Haltelinien. min(reach, länge) schützt kurze Arme, die schon vorher an der
|
|
# Prüfung unten scheitern und dann gar nicht gezeichnet werden.
|
|
punkt = line.interpolate(min(reach, länge))
|
|
along = _unit(origin, (punkt.x, punkt.y))
|
|
across = (-along[1], along[0])
|
|
halb = SECTION[edge.klass].carriageway / 2
|
|
# reach kommt vom breitesten Arm der ganzen Kreuzung, gilt hier aber für diesen einen
|
|
# Arm - ein kurzer Arm an einer sonst breiten Kreuzung reicht oft nicht bis dorthin. Am
|
|
# echten Netz sind 38 von 1.508 geprüften Armstummeln zu kurz für die Haltelinie,
|
|
# im schlimmsten Fall um 10,6 m (Fixrunde 1 des Koordinators zu Task 9; auf die
|
|
# tatsächlichen Zufahrten umgerechnet, inklusive der doppelt zählenden Schleifen aus der
|
|
# vorigen Fixrunde, sind es 44 von 1.536). Ohne diese Prüfung ragte die Markierung über
|
|
# das Ende der Kante hinaus und läge neben der Straße statt darauf. Zebrastreifen und
|
|
# Haltelinie brauchen unterschiedlich viel Platz, deshalb getrennt geprüft statt
|
|
# gemeinsam verworfen.
|
|
if länge >= reach + CROSSWALK_DEPTH:
|
|
# Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt.
|
|
offset = -halb + BAR / 2
|
|
while offset <= halb - BAR / 2 + 1e-9:
|
|
mitte = (origin[0] + across[0] * offset, origin[1] + across[1] * offset)
|
|
out.append((_rect(mitte, along, across, reach, CROSSWALK_DEPTH, BAR), "zebra"))
|
|
offset += 2 * BAR
|
|
if länge >= reach + STOP_SETBACK + STOP_DEPTH:
|
|
# Haltelinie quer, nur über die rechte Hälfte aus Sicht der Zufahrt. "across" ist
|
|
# schon als links von "along" definiert (dieselbe Konvention wie offset_curve() in
|
|
# _offset(): positiv = links). Die Zufahrt fährt zum Knoten hin, also gegen "along" -
|
|
# und die Umkehr der Fahrtrichtung vertauscht links und rechts. Rechts der Einfahrt
|
|
# ist deshalb +across, ohne weiteres Vorzeichen: eine Probe mit offset_curve() auf
|
|
# der Einfahrtlinie bestätigt das (Fixrunde zu Task 9 - der Auftrag hatte hier
|
|
# -across und lag damit auf der Gegenspur).
|
|
mitte = (origin[0] + across[0] * halb / 2, origin[1] + across[1] * halb / 2)
|
|
out.append((_rect(mitte, along, across, reach + STOP_SETBACK, STOP_DEPTH, halb),
|
|
"haltelinie"))
|
|
return out
|
|
|
|
|
|
def _unit(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
length = (dx * dx + dy * dy) ** 0.5 or 1.0
|
|
return (dx / length, dy / length)
|
|
|
|
|
|
def _rect(mitte, along, across, start: float, length: float, width: float) -> Polygon:
|
|
"""Rechteck im Fahrbahnsystem: start..start+length in Fahrtrichtung, width quer um mitte."""
|
|
punkte = []
|
|
for dl, dq in ((start, -width / 2), (start + length, -width / 2),
|
|
(start + length, width / 2), (start, width / 2)):
|
|
punkte.append((mitte[0] + along[0] * dl + across[0] * dq,
|
|
mitte[1] + along[1] * dl + across[1] * dq))
|
|
return Polygon(punkte)
|