material_flow: Kreisel-Umlauf als Kreispfeil, Weiche-Bahnzuordnung ueber Geometrie

material_flow.svg: der UZS/GUZ-Pfeil zwischen Bahn R/L eines Kreisels wird nach
dem Graphviz-Rendern durch einen 270-Grad-Kreispfeil mit dem Kuerzel in der
Mitte ersetzt (Graphviz kennt keine kreisfoermigen Pfeile/Labels).

Weiche zwischen zwei Kreiseln: neue _kreisel_touch_lane() bestimmt per
Boundingbox (Stadion-Form) und Weiche-Position geometrisch, ob der
Beruehrpunkt an einer Stirnseite oder an der Laengsseite jedes beteiligten
Kreisels liegt. Nur am reinen Stirnseiten-Treffer (Kreis/Kreis) ist der
Bahnwechsel L<->R eindeutig und wird weiterhin per Drehrichtung gerichtet
gezeichnet; an einer Laengsseite (Kreis/Gerade) kommt die betroffene Bahn
direkt aus der Geometrie, die Richtung bleibt aber unbestimmt, da sie vom
(unbekannten) Schaltzustand der Weiche abhaengt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-10-08 09:28:22 +02:00
co-authored by Claude Sonnet 5
parent 7a71e12272
commit e91b754d90
+180 -11
View File
@@ -69,6 +69,7 @@ from __future__ import annotations
import argparse
import csv
import json
import math
import os
import re
import shutil
@@ -1188,6 +1189,51 @@ def _kreisel_lane_node(kreisel_base: str, strecke: Element, nodes: dict[str, Nod
return kreisel_base if kreisel_base in nodes else None
def _kreisel_touch_lane(
kreisel: Element, point: tuple[float, float], elements: list[Element],
) -> tuple[str, bool] | None:
"""
Bahn (LANE_LEFT/LANE_RIGHT) und Beruehrart eines Punkts (z. B. einer
Weiche-Position) auf einem Kreisel, rein geometrisch aus Boundingbox +
Position.
Ein Kreisel ist eine Stadion-Form (zwei Halbkreise vom Radius der kurzen
Bbox-Haelfte, verbunden durch zwei tangentiale Geraden entlang der langen
Achse). Je nachdem, ob 'point' im Bereich einer der beiden Endkappen oder
entlang der geraden Laengsseite liegt, ergeben sich zwei Faelle:
* Endkappe (zwei Kreisel stossen Stirnseite auf Stirnseite): hier liegt
der Beruehrpunkt in der Naehe der langen Mittelachse, eine eindeutige
L/R-Zuordnung ueber die Bahn-Haelften ist dort nicht sinnvoll - der
Aufrufer behandelt diesen Fall stattdessen ueber die Drehrichtung
(siehe _weiche_lane_roles). Rueckgabe (lane, True), lane nur als grobe
Richtungsangabe (naeher an L- oder R-Haelfte).
* Laengsseite (die Weiche sitzt seitlich an einer der beiden Bahnen):
(lane, False) mit der tatsaechlich beruehrten Bahn.
None, wenn Position/Boundingbox fehlen.
"""
x, y, bw, bh = kreisel.x, kreisel.y, kreisel.bbox_x, kreisel.bbox_y
if x is None or y is None or bw is None or bh is None:
return None
px, py = point
horizontal = bw >= bh
radius = min(bw, bh) / 2.0
long_coord, long_half = (px, bw / 2.0) if horizontal else (py, bh / 2.0)
long_center = x if horizontal else y
# Abstand vom Kreisel-Mittelpunkt entlang der langen Achse: innerhalb der
# geraden Mitte (bis long_half - radius) ist es die Laengsseite, im
# aeusseren Ring davon (ab long_half - radius, einschliesslich des
# Tangentenpunkts) die Endkappe - ein Kreisel-Kreisel-Stirnseitentreffer
# liegt typischerweise genau auf diesem Tangentenpunkt.
dist_long = abs(long_coord - long_center)
is_end_cap = dist_long >= max(long_half - radius, 0.0)
lane_rects = _kreisel_lane_rects(kreisel, elements)
lane = _lane_of_point(point, lane_rects)
return lane, is_end_cap
def _weiche_lane_roles(kreisel_base: str, by_id: dict[str, Element], nodes: dict[str, Node]) -> tuple[str, str] | None:
"""Eingangs-/Ausgangsbahn eines Kreisels an einer Kreisel<->Kreisel-Weiche.
@@ -1232,7 +1278,7 @@ def _apply_transfers(
# ein = Auslauf (Strecke->Kreisel, Ende)
per_strecke: dict[str, dict[str, str]] = {}
weiche_pairs: list[set[str]] = []
weiche_ab: list[tuple[str, str]] = []
weiche_ab: list[tuple[str, str, Element]] = []
for el in elements:
if el.kind in ("Ausschleus", "Einschleus"):
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
@@ -1255,15 +1301,54 @@ def _apply_transfers(
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
if a and b:
weiche_pairs.append({a, b})
weiche_ab.append((a, b))
weiche_ab.append((a, b, el))
# Weiche zwischen zwei Kreiseln: zwei geometrisch unterschiedliche Faelle
# (siehe _kreisel_touch_lane), je nachdem wo die Weiche-Position relativ
# zur Stadion-Form jedes beteiligten Kreisels liegt:
#
# * Stirnseite/Stirnseite (beide Kreisel beruehren sich an einer Endkappe,
# "Kreis/Kreis"): ein Objekt wechselt dort physisch immer die Seite
# (Bahn L des einen trifft auf Bahn R des anderen, siehe Review
# 2026-10-08) - *welche* der beiden Bahnen Eingang bzw. Ausgang ist,
# kommt weiterhin aus der eigenen Drehrichtung jedes Kreisels
# (_weiche_lane_roles): eindeutig gerichtet.
# * Laengsseite (die Weiche sitzt seitlich an einer Bahn, "Kreis/Gerade"):
# die beruehrte Bahn ergibt sich direkt aus der Geometrie (welche
# Bahn-Haelfte den Punkt enthaelt) - ob diese Bahn dort Eingang oder
# Ausgang ist, haengt von der (hier unbekannten) Schaltung der Weiche
# ab und bleibt unbestimmt (beidseitig gezeichnet), nur die betroffenen
# Bahnen werden markiert.
def _opposite_lane_on(other_base: str, node_id: str) -> str:
lane = node_id.rpartition("-")[2]
return f"{other_base}-{LANE_RIGHT if lane == LANE_LEFT else LANE_LEFT}"
# Weiche zwischen zwei Kreiseln: Bahn und Richtung deterministisch aus der
# Drehrichtung beider Kreisel ableiten (Ausgang der einen Bahn -> Eingang
# der anderen, in beiden Richtungen) statt aus dem rohen Nachbarn-Kreuzprodukt,
# das links/rechts nicht unterscheiden kann.
weiche_edges: list[Edge] = []
weiche_resolved: set[frozenset] = set()
for a, b in weiche_ab:
for a, b, weiche_el in weiche_ab:
el_a, el_b = by_id.get(a), by_id.get(b)
point = (weiche_el.x, weiche_el.y) if weiche_el.x is not None and weiche_el.y is not None else None
touch_a = _kreisel_touch_lane(el_a, point, elements) if el_a and point else None
touch_b = _kreisel_touch_lane(el_b, point, elements) if el_b and point else None
if touch_a is not None and touch_b is not None and (not touch_a[1] or not touch_b[1]):
# Mindestens eine Seite liegt an der Laengsseite: Bahn direkt aus
# der Geometrie, Richtung bleibt unbestimmt (ohne Schaltzustand
# der Weiche ist dort kein Eingang/Ausgang ableitbar).
lane_a_id = f"{a}-{touch_a[0]}"
lane_b_id = f"{b}-{touch_b[0]}"
if lane_a_id in nodes and lane_b_id in nodes:
weiche_edges.append(Edge(lane_a_id, lane_b_id, EDGE_UNRESOLVED, "Weiche"))
weiche_resolved.add(frozenset({a, b}))
else:
warnings.append(
f"Weiche {a}<->{b}: Bahn {lane_a_id if lane_a_id not in nodes else lane_b_id} "
f"nicht im Graph - Verbindung bleibt unbestimmt"
)
continue
# Beide Seiten Stirnseite (oder Geometrie nicht verfuegbar - dann wie
# bisher per Drehrichtung): eindeutiger Seitenwechsel.
roles_a = _weiche_lane_roles(a, by_id, nodes)
roles_b = _weiche_lane_roles(b, by_id, nodes)
if roles_a is None or roles_b is None:
@@ -1272,10 +1357,10 @@ def _apply_transfers(
f"Bahnzuordnung nicht bestimmbar, Verbindung bleibt unbestimmt"
)
continue
in_a, out_a = roles_a
in_b, out_b = roles_b
weiche_edges.append(Edge(out_a, in_b, EDGE_FLOW, "Weiche"))
weiche_edges.append(Edge(out_b, in_a, EDGE_FLOW, "Weiche"))
_, out_a = roles_a
_, out_b = roles_b
weiche_edges.append(Edge(out_a, _opposite_lane_on(b, out_a), EDGE_FLOW, "Weiche"))
weiche_edges.append(Edge(out_b, _opposite_lane_on(a, out_b), EDGE_FLOW, "Weiche"))
weiche_resolved.add(frozenset({a, b}))
directed: dict[frozenset, tuple[str, str]] = {}
@@ -3097,6 +3182,86 @@ def dot_to_svg(dot_file: Path, svg_file: Path) -> None:
graph_to_svg(dot_file, svg_file, engine="dot")
# Regex fuer eine komplette Kreisel-Umlauf-Kante im von Graphviz erzeugten
# SVG (siehe render_dot(): EDGE_CIRCLE-Kanten tragen xlink:title "Kreisel-
# Umlauf UZS"/"GUZ" und einen xlabel-Text mit demselben Kuerzel). Ersetzt
# die gerade Pfeil-Linie + Dreieck-Pfeilspitze + Text durch einen
# kreisfoermigen 270-Grad-Pfeil mit dem Kuerzel in der Mitte
# (_rotation_glyph_svg()).
_ROTATION_EDGE_RE = re.compile(
r'(<g id="edge\d+" class="edge">\s*<title>[^<]*</title>\s*)'
r'<g id="a_edge\d+"><a xlink:title="Kreisel&#45;Umlauf (UZS|GUZ)">\s*'
r'<path[^>]*/>\s*'
r'<polygon[^>]*/>\s*'
r'</a>\s*</g>\s*'
r'<text[^>]*x="([-\d.]+)" y="([-\d.]+)"[^>]*>(?:UZS|GUZ)</text>'
)
def _rotation_glyph_svg(
cx: float, cy: float, radius: float, clockwise: bool, color: str,
text: str, font_size: float,
) -> str:
"""
Kreisfoermiger 270-Grad-Pfeil (Luecke unten) mit Pfeilspitze am
bewegten Ende in Drehrichtung und 'text' (UZS/GUZ) mittig im Kreis -
ersetzt den geraden xlabel-Pfeil, den Graphviz fuer EDGE_CIRCLE-Kanten
zeichnet (siehe _ROTATION_EDGE_RE).
"""
def clock_point(clock_deg: float) -> tuple[float, float]:
theta = math.radians(clock_deg - 90)
return (cx + radius * math.cos(theta), cy + radius * math.sin(theta))
if clockwise:
start_clock, end_clock, sweep_flag = 230.0, 230.0 + 260.0, 1
else:
start_clock, end_clock, sweep_flag = 130.0, 130.0 - 260.0, 0
x1, y1 = clock_point(start_clock)
x2, y2 = clock_point(end_clock)
path_d = f"M{x1:.2f},{y1:.2f} A{radius:.2f},{radius:.2f} 0 1 {sweep_flag} {x2:.2f},{y2:.2f}"
tangent_clock = end_clock + (90.0 if clockwise else -90.0)
tangent_theta = math.radians(tangent_clock - 90)
tx, ty = math.cos(tangent_theta), math.sin(tangent_theta)
nx, ny = -ty, tx
head_len, head_w = radius * 0.55, radius * 0.22
tip = (x2 + tx * head_len * 0.6, y2 + ty * head_len * 0.6)
base = (x2 - tx * head_len * 0.4, y2 - ty * head_len * 0.4)
p1 = (base[0] + nx * head_w, base[1] + ny * head_w)
p2 = (base[0] - nx * head_w, base[1] - ny * head_w)
return (
f'<path fill="none" stroke="{color}" stroke-width="1.2" d="{path_d}"/>'
f'<polygon fill="{color}" stroke="{color}" stroke-width="0.5" '
f'points="{tip[0]:.2f},{tip[1]:.2f} {p1[0]:.2f},{p1[1]:.2f} {p2[0]:.2f},{p2[1]:.2f}"/>'
f'<text text-anchor="middle" x="{cx:.2f}" y="{cy + font_size * 0.33:.2f}" '
f'font-family="Segoe UI" font-size="{font_size:.1f}" fill="{color}">{text}</text>'
)
def _draw_rotation_glyphs(svg_text: str) -> str:
"""
Nach dem Rendern mit Graphviz die geraden 'Kreisel-Umlauf UZS/GUZ'-Pfeile
(render_dot(): EDGE_CIRCLE, xlabel) durch einen kreisfoermigen 270-Grad-
Pfeil mit dem Kuerzel in der Mitte ersetzen - Graphviz selbst kennt keine
kreisfoermigen Pfeilspitzen/Labels.
"""
color = "#5b7fc7"
radius = 9.0
font_size = 6.5
def replace(match: re.Match[str]) -> str:
prefix, rotation, tx, ty = match.groups()
cx, cy = float(tx), float(ty)
clockwise = rotation == ROTATION_CW
glyph = _rotation_glyph_svg(cx, cy, radius, clockwise, color, rotation, font_size)
return f"{prefix}{glyph}"
return _ROTATION_EDGE_RE.sub(replace, svg_text)
# ---------------------------------------------------------------------------
# Plausibilitaet
# ---------------------------------------------------------------------------
@@ -3589,6 +3754,10 @@ def main(argv: list[str] | None = None) -> int:
graph_to_svg(dot_file, svg_file, engine="neato", no_op=1)
else:
dot_to_svg(dot_file, svg_file)
svg_file.write_text(
_draw_rotation_glyphs(svg_file.read_text(encoding="utf-8")),
encoding="utf-8",
)
except RuntimeError as exc:
svg_error = str(exc)
created_svg = svg_file if args.tosvg and svg_error is None else None