diff --git a/lib/tro_flow.py b/lib/tro_flow.py index c29a869..221fc84 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -172,10 +172,11 @@ GENERATED_MARKER = "" # Zeichnung, in der Symbole und Schrift verschwanden; Review 2026-10-01). COORD_SCALE = 0.1 -# Mindestabstand (mm) einer Systemgrenze zu einem TRO in der lagerichtigen -# Zeichnung - eine BTMT-Station sitzt oft fast auf ihrem Separator (Mubea SYS1 -# 186 mm neben TRO04); sie wird dann nur in der Darstellung beiseitegeschoben. -COORD_MIN_ABSTAND = 600 +# Zu-/Ablaufpfeil einer Station in der lagerichtigen Zeichnung: feste Laenge +# (mm) ab dem angehaengten TRO und Faecherwinkel (Grad), wenn mehrere +# Stationen am selben TRO haengen (siehe _station_positionen). +COORD_PFEIL_LAENGE = 1300 +COORD_PFEIL_FAECHER = 45 # Groesste Luecke (mm) zwischen zwei benachbarten Knotenkoordinaten je Achse in # der lagerichtigen Zeichnung. Groessere Leerstrecken (lange Foerderer ohne @@ -1274,13 +1275,9 @@ def link_tros( def _add_chain_pair(a: str, b: str) -> None: if a == b: return - # Be- und Entladestationen auf der Kreisel-Bahn werden DURCHFAHREN: der - # Kreisel laeuft immer weiter, was nicht entladen wird, faehrt an der - # naechsten Station vorbei. Systemgrenzen sind in der Umlaufkette daher - # gewoehnliche Glieder (Review 2026-10-01): Mubea Kreisel2 - # TRO02 -> SYS3 -> SYS2 -> TRO13, HundM05 Kreisel1 - # TRO19 -> SYS3 -> Beladungen -> TRO20. Vorher endete der Fluss an jeder - # Entladung, und eine Beladung durfte nie Ziel sein. + # Keine Sperre fuer Systemgrenzen noetig: sie sind kein Kettenglied + # (siehe station_hosts unten) und kommen hier nur als Zu-/Ablaufpfeil + # in der richtigen Richtung an. pair = (a, b) edges.add(pair) edge_kinds.setdefault(pair, "bahn") @@ -1371,25 +1368,61 @@ def link_tros( speicher_ausschleus[el.teile_id] = store_by_host[strecke_id] else: einschleus_vario[el.teile_id] = store_by_host[strecke_id] - ketten_owner = {**elem_owner, **speicher_ausschleus} + # Systemgrenzen (Be-/Entladung, Eingang/Ausgang) sind KEIN Kettenglied: + # die Umlaufkette verbindet die TROs direkt an ihnen vorbei, die Station + # haengt nur als Zu- bzw. Ablaufpfeil am benachbarten TRO (Review + # 2026-10-02). Vorher liefen sie als Glied mit (TRO19 -> SYS3 -> SYS1 -> + # SYS2 -> TRO20), jetzt TRO19 -> TRO20 plus TRO19 -> SYS3, SYS1/SYS2 -> TRO20. + station_hosts = {t.hosts[0]: t for t in tros if t.type_name in BOUNDARY_TYPES} + ketten_owner = { + **{k: v for k, v in elem_owner.items() if k not in station_hosts}, + **speicher_ausschleus, + } for el in elements: if not el.is_circle or len(el.track_ids) < 2: continue chain = list(el.track_ids) - if el.rotation in (ROTATION_CW, ROTATION_CCW): - chain.append(chain[0]) # Umlauf schliessen + laenge = len(chain) + geschlossen = el.rotation in (ROTATION_CW, ROTATION_CCW) + if geschlossen: + # Umlauf schliessen: erstes Element hinten anhaengen. Stationen am + # Listenanfang sind kein Kettenglied - ueber sie hinweg bis zum + # ersten anderen Element verlaengern, sonst fehlt der Schluss + # (HundM05 Kreisel1 beginnt mit den Beladungen 0021/0022: ohne das + # fehlte TRO19 -> TRO20). + k = 0 + while k < laenge - 1 and chain[k] in station_hosts: + k += 1 + chain.extend(chain[:k + 1]) - # Hauptkette: Separatoren/Systemgrenzen in Reihenfolge, Aus-/ - # Einschleuselemente bewusst uebersprungen (unowned) - das beschreibt - # den BYPASS fuer Verkehr, der NICHT abzweigt (z. B. TRO01 -> TRO03, - # wenn dazwischen niemand abbiegt). + # Hauptkette: Separatoren in Reihenfolge, Aus-/Einschleuselemente und + # Stationen bewusst uebersprungen (unowned) - das beschreibt den BYPASS + # fuer Verkehr, der NICHT abzweigt (z. B. TRO01 -> TRO03, wenn + # dazwischen niemand abbiegt). owned = [(i, ketten_owner[tid]) for i, tid in enumerate(chain) if tid in ketten_owner] for (_, a), (_, b) in zip(owned, owned[1:]): _add_chain_pair(a, b) if owned: covered_kreisel_bases.add(el.teile_id) + # Stationen als Pfeile: Entladung/Ausgang verlaesst den TRO direkt + # DAVOR, Beladung/Eingang muendet in den TRO direkt DANACH. + for i, tid in enumerate(chain[:laenge]): + station = station_hosts.get(tid) + if station is None or not owned: + continue + if station.type_name == BOUNDARY_EXIT: + tro_id = next((o for j, o in reversed(owned) if j < i), + owned[-1][1] if geschlossen else None) + if tro_id is not None: + _add_chain_pair(tro_id, station.tro_id) + else: + tro_id = next((o for j, o in owned if j > i), + owned[0][1] if geschlossen else None) + if tro_id is not None: + _add_chain_pair(station.tro_id, tro_id) + # Zusaetzlich der reale Wiedereintritt eines abgezweigten Vario- # Segments: vom Einschleuselement zum naechsten Halt DANACH in # derselben Kette - ersetzt fuer dieses Vario-TRO den groben "Strecke @@ -1694,26 +1727,44 @@ def _kompakte_positionen( return {k: (ax[x], ay[y]) for k, (x, y) in positionen.items()} -def _anzeige_position( - boundary: Tro, tros: list[Tro] -) -> tuple[float, float, float] | None: - """Position einer Systemgrenze fuer die Zeichnung: liegt sie naeher als - COORD_MIN_ABSTAND an einem TRO, wird sie radial von ihm weggeschoben.""" - if not boundary.position: - return None - x, y, z = boundary.position - for tro in tros: - if not tro.position: +def _station_positionen( + analysis: Analysis, tro_zeichen: dict[str, tuple[float, float]] +) -> dict[str, tuple[float, float]]: + """Zeichenposition der Stationsbeschriftung (Ende des Zu-/Ablaufpfeils). + + Feste Pfeillaenge COORD_PFEIL_LAENGE ab dem angehaengten TRO (Beladung: + der TRO danach, Entladung: der TRO davor), Richtung zur echten Station - + so bleibt der Pfeil sichtbar, auch wenn die Station praktisch auf dem TRO + sitzt (Mubea SYS1 186 mm neben TRO04). Haengen mehrere Stationen am selben + TRO, werden sie um COORD_PFEIL_FAECHER Grad aufgefaechert. Station ohne + angehaengten TRO: echte Koordinate.""" + tro_by_id = {t.tro_id: t for t in analysis.tros} + je_tro: dict[str, list[Tro]] = {} + ergebnis: dict[str, tuple[float, float]] = {} + for station in analysis.boundaries: + nachbarn = (station.successors if station.type_name == BOUNDARY_ENTRY + else station.predecessors) + anker = next((n for n in nachbarn if n in tro_zeichen), None) + if anker is None: + if station.position: + ergebnis[station.tro_id] = (station.position[0], station.position[1]) continue - dx, dy = x - tro.position[0], y - tro.position[1] - d = math.hypot(dx, dy) - if d >= COORD_MIN_ABSTAND: - continue - if d == 0: - dx, dy, d = 0.0, -1.0, 1.0 - x = tro.position[0] + dx / d * COORD_MIN_ABSTAND - y = tro.position[1] + dy / d * COORD_MIN_ABSTAND - return (x, y, z) + je_tro.setdefault(anker, []).append(station) + + for anker, stationen in je_tro.items(): + ax, ay = tro_zeichen[anker] + echt = tro_by_id[anker].position + for k, station in enumerate(sorted(stationen, key=lambda s: s.tro_id)): + dx = dy = 0.0 + if echt and station.position: + dx, dy = station.position[0] - echt[0], station.position[1] - echt[1] + if math.hypot(dx, dy) < 1.0: + dx, dy = 0.0, -1.0 + winkel = math.atan2(dy, dx) + math.radians( + (k - (len(stationen) - 1) / 2) * COORD_PFEIL_FAECHER) + ergebnis[station.tro_id] = (ax + math.cos(winkel) * COORD_PFEIL_LAENGE, + ay + math.sin(winkel) * COORD_PFEIL_LAENGE) + return ergebnis def _tro_kurzinfo(tro: Tro) -> str: @@ -1765,19 +1816,18 @@ def _legend_pos_rechts(zeichen: dict[str, tuple[float, float]]) -> tuple[float, def _zeichen_positionen( analysis: Analysis, compact: bool ) -> dict[str, tuple[float, float]]: - """Zeichenposition (mm) je TRO/Systemgrenze fuer --use-cords: Anlagen- - koordinate, Systemgrenzen ggf. beiseitegeschoben (_anzeige_position), - mit compact die Leerstrecken gekuerzt (_kompakte_positionen). Reine - Darstellung - Tro.position und die Tooltip-Koordinaten bleiben echt.""" + """Zeichenposition (mm) je TRO/Systemgrenze fuer --use-cords: TROs auf + ihrer Anlagenkoordinate, mit compact die Leerstrecken gekuerzt + (_kompakte_positionen); die Stationsbeschriftungen danach relativ zum + angehaengten TRO (_station_positionen). Reine Darstellung - Tro.position + und die Tooltip-Koordinaten bleiben echt.""" roh: dict[str, tuple[float, float]] = {} for tro in analysis.tros: if tro.position: roh[tro.tro_id] = (tro.position[0], tro.position[1]) - for boundary in analysis.boundaries: - position = _anzeige_position(boundary, analysis.tros) - if position: - roh[boundary.tro_id] = (position[0], position[1]) - return _kompakte_positionen(roh, COORD_MAX_LUECKE) if compact else roh + zeichen = _kompakte_positionen(roh, COORD_MAX_LUECKE) if compact else roh + zeichen.update(_station_positionen(analysis, zeichen)) + return zeichen # Kantenstil je Art (an material_flow.py angelehnt: Umlauf hellblau gestrichelt, @@ -1791,6 +1841,13 @@ TRO_EDGE_STYLE = { } +# Farbe der Zu-/Ablaufpfeile und ihrer Stationsbeschriftung +STATION_FARBE = { + BOUNDARY_ENTRY: "#2e8b57", # gruen: Material kommt herein + BOUNDARY_EXIT: "#c0392b", # rot: Material geht hinaus +} + + def _tro_edge_attrs(kind: str | None) -> str: return TRO_EDGE_STYLE.get(kind or "normal", "") @@ -1864,18 +1921,23 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False, # Systemgrenzen (BTMT Be-/Entladestationen): kein Katalogtyp, eigene # Farbe/Form (ext-Stil, Ellipse statt Box) - deutlich als "kein TRO" # erkennbar, aber sichtbar als tatsaechlicher Anfang/Ende des Flusses. + # Systemgrenzen (Be-/Entladung, Eingang/Ausgang) sind kein TRO und kein + # Kettenglied: sie erscheinen nur als Beschriftung am Ende eines farbigen + # Zu- bzw. Ablaufpfeils (Review 2026-10-02). + boundary_ids = {b.tro_id for b in analysis.boundaries} for boundary in analysis.boundaries: - rendered = ", ".join(f'{k}="{v}"' for k, v in STYLE_EXT.dot_attrs().items()) - pos = _pos(boundary) - if use_coords: - attrs = dict(STYLE_EXT.dot_attrs(), shape="ellipse") - add(f' "{boundary.tro_id}" [{_coord_node_attrs(boundary, attrs)}{pos}];') - else: - add(f' "{boundary.tro_id}" [label="{_tro_label(boundary)}", ' - f'shape=ellipse, style=filled, {rendered}{pos}];') + farbe = STATION_FARBE[boundary.type_name] + add(f' "{boundary.tro_id}" [shape=plaintext, style="", fontsize=9, ' + f'fontcolor="{farbe}", label="{_dot_escape(boundary.label)}", ' + f'tooltip="{_tro_tooltip(boundary)}"{_pos(boundary)}];') add("") for src, dst in analysis.edges: + if src in boundary_ids or dst in boundary_ids: + farbe = STATION_FARBE[BOUNDARY_ENTRY if src in boundary_ids else BOUNDARY_EXIT] + add(f' "{src}" -> "{dst}" [color="{farbe}", penwidth=2.0, arrowsize=0.9, ' + f'tooltip="{"Materialeingang" if src in boundary_ids else "Materialausgang"}"];') + continue add(f' "{src}" -> "{dst}" [{_tro_edge_attrs(analysis.edge_kinds.get((src, dst)))}];') # "gleiche Bahn"-Punktlinien nur in der berechneten Anordnung: lagerichtig @@ -1906,8 +1968,10 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False, f'
| durchgezogen | '