Nacharbeit zu Befund 3, beim Nachmessen am echten Netz entdeckt: Die erste
Korrektur (Richtung aus origin -> interpolate(reach) statt aus points[1])
reichte an zwei Stellen nicht.
Erstens kann eine Schleife geradewegs von ihrem Knoten weg- und auf
demselben Weg wieder zurücklaufen - am echten Netz eine 21,63 m lange
Boulevard-Schleife, deren Punktliste den Hinweg exakt rückwärts
wiederholt. Landet reach nahe dieser Kehre, zeigt die Sehne vom Ursprung
weiter nach außen, obwohl die Kante dort längst umkehrt: Die Markierung
lag an dieser einen Schleife weiterhin vollständig (100 %) neben der
Fahrbahn. Die Richtung kommt jetzt aus dem letzten Teilstück von
substring(line, 0, reach) - der tatsächlichen Anfahrtsrichtung an der
Schnittstelle, gleich wohin die Kante von dort aus weiterläuft.
Zweitens blieb "origin" (der Kreuzungsknoten) der Anker für Balken und
Haltelinie. origin und der Punkt bei Streckenlänge reach fallen nur auf
einer geraden Kante zusammen - mit der neuen Anfahrtsrichtung, aber dem
Knoten als Anker, läge selbst eine gerade Fortsetzung ab reach
systematisch daneben, sobald die Kante zwischen Knoten und reach von der
reinen Ursprungsrichtung abweicht. Anker ist jetzt der tatsächliche Punkt
auf der Kante bei Streckenlänge min(reach, länge).
Nachgemessen am echten Netz: von 452,65 m² (vor Befund 3) über 342,66 m²
(nur die erste Korrektur) auf 110,06 m² (0,25 % der Gesamtfläche) - beide
Korrekturen zusammen eine Verbesserung um 75,7 %. Der Rest ist eine engere
Spielart desselben Problems: Ein Bogen kann auch noch zwischen reach und
reach + STOP_SETBACK + STOP_DEPTH abknicken, was ein einzelner
Richtungsstrahl nicht mehr auffangen kann - das verlangte die vom Befund
selbst genannte, größere Alternative ("die Markierung entlang der
tatsächlichen Kante aufbauen"), die diese Fixwelle nicht umsetzt. Siehe
Fixwelle-Bericht für die Einzelheiten und die verbleibenden Einzelfälle.
Neuer Test mit den echten Schleifenmaßen (9 m hin, 6 m quer, reach 11 m):
vor dieser Korrektur lag die gesamte Markierung an der Schleife daneben,
danach vollständig darauf.
227 lines
13 KiB
Python
227 lines
13 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
|
|
|
|
import math
|
|
|
|
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
|
|
# Befund 3 der Fixwelle: "along" kam aus der Richtung origin -> points[1], also allein
|
|
# aus dem ersten Segment der Zufahrt, während Zebrastreifen und Haltelinie bis
|
|
# reach + STOP_SETBACK + STOP_DEPTH (bis 16 m) auf dieser Geraden aufgespannt wurden.
|
|
# Knickte die Kante innerhalb dieser Strecke ab, landete die Markierung daneben statt
|
|
# darauf - am echten Netz gemessen 452,65 m² (1,03 % der Gesamtfläche), darunter ganze
|
|
# Haltelinien.
|
|
#
|
|
# Zwei Korrekturen daran, beide am echten Netz nachgewiesen nötig:
|
|
#
|
|
# 1) Die Richtung kommt aus dem letzten Teilstück von substring(line, 0, reach), nicht
|
|
# aus der Sehne vom Ursprung zu interpolate(reach): Eine Schleife kann geradewegs von
|
|
# ihrem Knoten weg- und auf demselben Weg wieder zurücklaufen (eine 21,63 m lange
|
|
# Schleife im echten Netz, deren Punktliste den Hinweg exakt rückwärts wiederholt).
|
|
# Landet reach nahe dieser Kehre, zeigt die Sehne vom Ursprung weiter nach außen,
|
|
# obwohl die Kante dort längst umkehrt. Das letzte Teilstück von substring() nimmt
|
|
# dagegen die tatsächliche Anfahrtsrichtung an der Schnittstelle an, gleich wohin die
|
|
# Kante von dort aus weiterläuft.
|
|
# 2) Anker für Balken und Haltelinie ist der tatsächliche Punkt auf der Kante bei
|
|
# Streckenlänge min(reach, länge), nicht der Kreuzungsknoten selbst: origin und
|
|
# dieser Punkt fallen nur auf einer geraden Kante zusammen. Mit der Anfahrtsrichtung
|
|
# aus (1), aber dem Knoten als Anker, läge selbst eine gerade Fortsetzung ab reach
|
|
# systematisch daneben, sobald die Kante zwischen Knoten und reach von der reinen
|
|
# Ursprungsrichtung abweicht.
|
|
#
|
|
# Mit beiden Korrekturen bleiben am echten Netz noch 110,06 m² (0,25 %) außerhalb der
|
|
# Fahrbahn - ein Bogen kann auch noch zwischen reach und reach + STOP_SETBACK +
|
|
# STOP_DEPTH abknicken, und ein einzelner Richtungsstrahl kann das nicht mehr auffangen;
|
|
# das verlangte die vom Befund selbst genannte, größere Alternative ("die Markierung
|
|
# entlang der tatsächlichen Kante aufbauen"), die diese Fixwelle nicht umsetzt. Siehe
|
|
# Bericht der Fixwelle für die Einzelheiten. min(reach, länge) schützt kurze Arme, die
|
|
# schon vorher an der Prüfung unten scheitern und dann gar nicht gezeichnet werden.
|
|
teil = substring(line, 0.0, min(reach, länge))
|
|
rand = [] if teil.is_empty else list(teil.coords)
|
|
along = _unit(rand[-2], rand[-1]) if len(rand) > 1 else (0.0, 0.0)
|
|
across = (-along[1], along[0])
|
|
basis = rand[-1] if rand else origin
|
|
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, mittig über die Fahrbahn verteilt (Befund 8 der
|
|
# Fixwelle): Der alte Startversatz (-halb + BAR/2, im Takt 2*BAR) saß am einen
|
|
# Rand bündig und ließ am anderen bis zu eine Taktbreite frei - 0,6 m bei Haupt-
|
|
# straße/Boulevard, 0,4 m bei Wohnstraße, statt der verlangten 60/60-cm-Teilung
|
|
# über die ganze Breite. n Balken im selben Takt, symmetrisch um die Fahrbahnmitte
|
|
# gestartet, lassen an beiden Rändern denselben Rest frei.
|
|
n = math.floor((2 * halb + BAR) / (2 * BAR))
|
|
offset = -(n - 1) * BAR
|
|
for _ in range(n):
|
|
mitte = (basis[0] + across[0] * offset, basis[1] + across[1] * offset)
|
|
out.append((_rect(mitte, along, across, 0.0, 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 = (basis[0] + across[0] * halb / 2, basis[1] + across[1] * halb / 2)
|
|
out.append((_rect(mitte, along, across, 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)
|