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:
co-authored by
Claude Sonnet 5
parent
7a71e12272
commit
e91b754d90
+180
-11
@@ -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-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
|
||||
|
||||
Reference in New Issue
Block a user