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:
+180
-11
@@ -69,6 +69,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
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
|
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:
|
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.
|
"""Eingangs-/Ausgangsbahn eines Kreisels an einer Kreisel<->Kreisel-Weiche.
|
||||||
|
|
||||||
@@ -1232,7 +1278,7 @@ def _apply_transfers(
|
|||||||
# ein = Auslauf (Strecke->Kreisel, Ende)
|
# ein = Auslauf (Strecke->Kreisel, Ende)
|
||||||
per_strecke: dict[str, dict[str, str]] = {}
|
per_strecke: dict[str, dict[str, str]] = {}
|
||||||
weiche_pairs: list[set[str]] = []
|
weiche_pairs: list[set[str]] = []
|
||||||
weiche_ab: list[tuple[str, str]] = []
|
weiche_ab: list[tuple[str, str, Element]] = []
|
||||||
for el in elements:
|
for el in elements:
|
||||||
if el.kind in ("Ausschleus", "Einschleus"):
|
if el.kind in ("Ausschleus", "Einschleus"):
|
||||||
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
|
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
|
||||||
@@ -1255,15 +1301,54 @@ def _apply_transfers(
|
|||||||
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
|
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
|
||||||
if a and b:
|
if a and b:
|
||||||
weiche_pairs.append({a, 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_edges: list[Edge] = []
|
||||||
weiche_resolved: set[frozenset] = set()
|
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_a = _weiche_lane_roles(a, by_id, nodes)
|
||||||
roles_b = _weiche_lane_roles(b, by_id, nodes)
|
roles_b = _weiche_lane_roles(b, by_id, nodes)
|
||||||
if roles_a is None or roles_b is None:
|
if roles_a is None or roles_b is None:
|
||||||
@@ -1272,10 +1357,10 @@ def _apply_transfers(
|
|||||||
f"Bahnzuordnung nicht bestimmbar, Verbindung bleibt unbestimmt"
|
f"Bahnzuordnung nicht bestimmbar, Verbindung bleibt unbestimmt"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
in_a, out_a = roles_a
|
_, out_a = roles_a
|
||||||
in_b, out_b = roles_b
|
_, out_b = roles_b
|
||||||
weiche_edges.append(Edge(out_a, in_b, EDGE_FLOW, "Weiche"))
|
weiche_edges.append(Edge(out_a, _opposite_lane_on(b, out_a), EDGE_FLOW, "Weiche"))
|
||||||
weiche_edges.append(Edge(out_b, in_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}))
|
weiche_resolved.add(frozenset({a, b}))
|
||||||
|
|
||||||
directed: dict[frozenset, tuple[str, str]] = {}
|
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")
|
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-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
|
# 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)
|
graph_to_svg(dot_file, svg_file, engine="neato", no_op=1)
|
||||||
else:
|
else:
|
||||||
dot_to_svg(dot_file, svg_file)
|
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:
|
except RuntimeError as exc:
|
||||||
svg_error = str(exc)
|
svg_error = str(exc)
|
||||||
created_svg = svg_file if args.tosvg and svg_error is None else None
|
created_svg = svg_file if args.tosvg and svg_error is None else None
|
||||||
|
|||||||
Reference in New Issue
Block a user