diff --git a/lib/material_flow.py b/lib/material_flow.py index e85c435..7bbaabd 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -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'(\s*[^<]*\s*)' + r'\s*' + r']*/>\s*' + r']*/>\s*' + r'\s*\s*' + r']*x="([-\d.]+)" y="([-\d.]+)"[^>]*>(?:UZS|GUZ)' +) + + +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'' + f'' + f'{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