Files
gta6/tools/extract/leonida/marking.py
T
knso ca01141c5b fix(extract): Zebrabalken mittig verteilt, Kantenbreite gerundet
Befund 8, zwei kleine, aber datenwirksame Funde:

- Zebrastreifen: Der Startversatz (-halb + BAR/2, Takt 2*BAR) liess die
  Balkenreihe an einem Fahrbahnrand bündig sitzen und am gegenüber-
  liegenden bis zu eine Taktbreite frei (0,6 m bei Hauptstraße/Boulevard,
  0,4 m bei Wohnstraße), statt der laut Entwurf verlangten Aufteilung
  "Balken 60 cm, Lücke 60 cm" über die ganze Breite. n = floor((2*halb +
  BAR) / (2*BAR)) Balken, symmetrisch bei -(n-1)*BAR gestartet, lassen an
  beiden Rändern denselben Rest frei (nachgemessen: 0,3 m statt 0/0,6 m
  bei Hauptstraße, 0,2 m statt 0/0,4 m bei Wohnstraße), ohne die Balkenzahl
  je Arm zu ändern.
- edges[].breite (Median aus der Distanztransformation) kam ungerundet an
  (Werte wie 31.622776601683793), während jede andere Zahl in der Ausgabe
  auf 2 Stellen gerundet ist. fahrbahn bleibt unverändert, weil es schon
  eine der vier festen SECTION-Konstanten ist.

Zwei Tests, beide ohne die jeweilige Korrektur rot: die Zebrabalken einer
Kreuzungszufahrt lassen ungleiche Ränder (0,0/0,6 m statt 0,3/0,3 m), und
eine krumme Kantenbreite blieb ungerundet in der Ausgabe.
2026-09-18 07:09:15 +02:00

202 lines
11 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
# 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, 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 = (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)