#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ export_neighbors.py - Ermittelt benachbarte Elemente ueber Bounding-Box- Ueberschneidung in der x/y-Ebene (Grundriss, Z/Hoehe wird ignoriert). Die Bloecke werden dazu in drei Gruppen eingeteilt und NUR innerhalb dieser Kombinationen gegeneinander geprueft (nicht mehr alle Elemente einer Zeichnung gemeinsam): 1. Kreisel/Eckrad gegen Kreisel/Eckrad 2. Kreisel/Eckrad gegen Gefaellestrecke/Foerderer/Strecke-Modul - diese drei Kategorien werden NICHT gegeneinander getestet (nur relevant, wo sie an einen Kreisel/Eckrad anschliessen) 3. Omniflo-Elemente (Bogen/Weiche/Gerade) gegen Omniflo-Elemente Omniflo-Anlagen koennen mehrere hundert Elemente enthalten - ein Test jedes gegen jedes (O(n^2)) waere dort zu teuer. Die Omniflo-Gruppe wird daher in zwei Phasen geprueft: * Broad-Phase: Ein shapely-STRtree ueber die (tolerierten) Bounding-Boxen liefert alle Paare, deren BBoxen sich in x/y ueberschneiden - die "moeglichen Nachbarn". Ist shapely nicht verfuegbar, faellt die Broad-Phase auf ein Raster (Zellgroesse siehe [Nachbarschaft] -> omniflo_zellgroesse_mm in cfg/export.cfg) mit 3x3-Zellennachbarschaft zurueck. * Narrow-Phase (KOS-Verfeinerung): Fuer jedes moegliche Nachbarpaar wird zusaetzlich geprueft, ob sich ihre Anschluss-Koordinatensysteme K1-K4 tatsaechlich beruehren - nur wenn ein K-Punkt des einen Elements naeher als omniflo_ks_toleranz_mm (Default 10mm, siehe [Nachbarschaft]) an einem K-Punkt des anderen liegt, gelten sie als echte Nachbarn. Das trennt Elemente, deren BBoxen sich zwar ueberlappen, die aber nicht an einem gemeinsamen Anschlusspunkt zusammenstossen. Elemente ohne K1-K4 (z.B. Altbestand) fallen auf die reine BBox-Ueberschneidung zurueck. Die K-Punkte kommen aus den csv:trans-encode-Strings der CSV-Spalten K1-K4 (siehe _omniflo_kpoints); Geraden fuehren keine echten K-Bloecke, ihre K1/K2 werden beim Export synthetisiert (Lisp/export.lsp). Zwei Bounding-Boxes gelten als benachbart, wenn sie sich (nach Erweiterung um die halbe Toleranz je Seite) in x/y ueberschneiden. Toleranz aus cfg/export.cfg ([Nachbarschaft] -> toleranz_mm), Wert in mm. compute_neighbor_errors() prueft zusaetzlich, ob jedes Element die fuer seine TeileArt noetige Mindestanzahl an Partnern (Nachbarn) hat (siehe MIN_PARTNER) - Ergebnis ist die CSV-Spalte "Fehler" in export_csv.py. Nur von export_csv.py genutzt (EXPORTCSV), nicht von export_sivas.py. """ import math import os from export_blockpatterns import cfg_path_from_env, load_export_cfg, safe_float # shapely-STRtree fuer die Broad-Phase der Omniflo-Nachbarschaft (optional - # fehlt shapely, wird auf das Raster _candidate_pairs_grid zurueckgefallen). try: from shapely import STRtree from shapely.geometry import box as _shapely_box _HAVE_STRTREE = True except ImportError: # pragma: no cover - shapely ist optional STRtree = None _shapely_box = None _HAVE_STRTREE = False # Base64-Alphabet + Fixed-Point-Faktor von csv:trans-encode (Lisp/export.lsp) - # zum Zurueckrechnen der K1-K4-Positionsstrings in x/y-Weltkoordinaten (mm). _B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" _B64_INDEX = {c: i for i, c in enumerate(_B64_CHARS)} _TRANS_FAKTOR = 10.0 KREISEL_TEILEARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"} STRECKEN_TEILEARTEN = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"} OMNIFLO_TEILEARTEN = {"Omniflo Kurve", "Omniflo Weiche", "Omniflo Gerade"} SENSOR_TEILEARTEN = {"ILS 2.0 Separator", "ILS 2.0 Scanner"} # Nur "ILS 2.0 Kreisel" (echte Kreisel-Bloecke mit AN8/Antrieb + SP8/Spann- # station + ABSTAND-Attribut) werden links/rechts gesplittet - "ILS 2.0 # Eckrad" hat keine dieser zwei Stationen und bleibt eine einzelne BBox. KREISEL_SPLIT_TEILEART = "ILS 2.0 Kreisel" # TeileArt der automatisch am Kreisel-Beruehrpunkt erzeugten Weiche # (compute_kreisel_touch_switches / export_csv.py). WEICHE_TEILEART = "ILS Weiche" # TeileArten der automatisch an den Strecken-Enden (Kollision Kreisel <-> # Gefaellestrecke/Foerderer) erzeugten Ein-/Ausschleuselemente # (compute_strecke_kreisel_schleus / export_csv.py). AUSSCHLEUS_TEILEART = "ILS Ausschleuselement" EINSCHLEUS_TEILEART = "ILS Einschleuselement" # Strecken-TeileArten, die Ein-/Ausschleuselemente bekommen: Gefaellestrecke # und (Vario-)Foerderer. "ILS 2.0 Strecke - Modul" (Einzelkomponenten) bewusst # NICHT - nur die kompletten GF_n/VF_n-Bloecke. GF_VF_TEILEARTEN = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke"} # Fallback-Masse (mm) der festen Element-Bounding-Box, falls cfg/export.cfg # [Boundingbox] element_box_* nicht gesetzt/lesbar ist (siehe # load_element_box_mm). NICHT direkt verwenden - die Rechenfunktionen bekommen # die Masse als Parameter (Default = load_element_box_mm()), damit die cfg-Werte # greifen. Laengsseite (LAENGE) quer zur Foerder-/Kreiselachse, BREITE laengs # (siehe _oriented_box_aabb). WEICHE_BOX_LAENGE_MM = 250.0 WEICHE_BOX_BREITE_MM = 100.0 WEICHE_BOX_HOEHE_MM = 30.0 def load_element_box_mm(cfg_path=None): """Feste Bounding-Box-Masse (Laenge, Breite, Hoehe in mm) der synthetischen Export-Elemente (ILS-Weichen an Kreisel-Beruehrpunkten, ILS-Ein-/Aus- schleuselemente an GF/VF-Enden) sowie der AS/ES-Kollisionsbox aus cfg/export.cfg [Boundingbox] element_box_laenge_mm / _breite_mm / _hoehe_mm. Fallback auf WEICHE_BOX_*_MM, falls die Sektion/Schluessel fehlen. Rueckgabe: (laenge, breite, hoehe).""" if cfg_path is None: cfg_path = cfg_path_from_env() parser = load_export_cfg(cfg_path) return ( parser.getfloat("Boundingbox", "element_box_laenge_mm", fallback=WEICHE_BOX_LAENGE_MM), parser.getfloat("Boundingbox", "element_box_breite_mm", fallback=WEICHE_BOX_BREITE_MM), parser.getfloat("Boundingbox", "element_box_hoehe_mm", fallback=WEICHE_BOX_HOEHE_MM), ) # Mindestanzahl Partner (Nachbarn) je TeileArt fuer die Fehlerspalte "Fehler" # (siehe compute_neighbor_errors): Kreisel/Eckrad und Omniflo-Elemente # brauchen mindestens einen Partner, Gefaellestrecke/Foerderer/Strecke-Modul # muessen an zwei Seiten anschliessen (mindestens zwei Partner). TeileArten, # die hier nicht auftauchen (z.B. die synthetische "Omniflo Sum"-Zeile), # werden nicht geprueft. MIN_PARTNER = {} MIN_PARTNER.update({t: 1 for t in KREISEL_TEILEARTEN}) MIN_PARTNER.update({t: 1 for t in OMNIFLO_TEILEARTEN}) MIN_PARTNER.update({t: 2 for t in STRECKEN_TEILEARTEN}) def load_neighbor_tolerance_mm(cfg_path=None): """Liest die Nachbarschafts-Toleranz (mm) aus export.cfg. Default: 0.""" if cfg_path is None: cfg_path = cfg_path_from_env() parser = load_export_cfg(cfg_path) return parser.getfloat("Nachbarschaft", "toleranz_mm", fallback=0.0) def load_omniflo_cell_size_mm(cfg_path=None): """Liest die Rastergroesse (mm) fuer die Omniflo-Nachbarschaftspruefung. Default 3000mm: groesser als die ueblichen Omniflo-Bauteile (AP110- Geraden ca. 2000mm), damit ein Element nicht durch ein zu kleines Raster in mehr als eine Zelle ueberlappt. """ if cfg_path is None: cfg_path = cfg_path_from_env() parser = load_export_cfg(cfg_path) return parser.getfloat("Nachbarschaft", "omniflo_zellgroesse_mm", fallback=3000.0) def load_omniflo_ks_tolerance_mm(cfg_path=None): """Liest die Toleranz (mm) fuer die KOS-basierte Verfeinerung der Omniflo- Nachbarschaft (K1-K4-Anschlusspunkte) aus export.cfg. Default 10mm. Nachdem der STRtree zwei Omniflo-Elemente anhand ihrer Bounding-Boxen als moegliche Nachbarn erkannt hat, gelten sie nur dann als echte Nachbarn, wenn ein K-Punkt des einen naeher als dieser Wert an einem K-Punkt des anderen liegt. 0 = Verfeinerung aus (nur BBox). Siehe [Nachbarschaft] -> omniflo_ks_toleranz_mm. """ if cfg_path is None: cfg_path = cfg_path_from_env() parser = load_export_cfg(cfg_path) return parser.getfloat("Nachbarschaft", "omniflo_ks_toleranz_mm", fallback=10.0) def load_kreisel_durchmesser_mm(cfg_path=None): """Liest den Kreisel-Durchmesser (mm) aus export.cfg. Default: 800.0 (siehe [Kreisel] durchmesser_mm - gespiegelter Wert von data/json/component_defaults.json "kreisel"."durchmesser").""" if cfg_path is None: cfg_path = cfg_path_from_env() parser = load_export_cfg(cfg_path) return parser.getfloat("Kreisel", "durchmesser_mm", fallback=800.0) def load_collision_debug_target(cfg_path=None): """Zielpfad fuer das Kollisions-/Nachbarschafts-Debuglog aus export.cfg. [Nachbarschaft] -> debug_log: leer/0/false/no/off = aus (Rueckgabe None). 1/true/yes/on = DXFM_LOG/export_collision.dbg (bzw. /logs/, falls DXFM_LOG nicht gesetzt ist). Jeder andere Wert wird als expliziter Dateipfad interpretiert. """ if cfg_path is None: cfg_path = cfg_path_from_env() parser = load_export_cfg(cfg_path) raw = parser.get("Nachbarschaft", "debug_log", fallback="").strip() if not raw or raw.lower() in ("0", "false", "no", "off", "nein", "aus"): return None if raw.lower() in ("1", "true", "yes", "on", "ja", "ein"): log_dir = os.environ.get("DXFM_LOG") if not log_dir: # cfg_path zeigt auf /cfg/export.cfg -> /logs log_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(cfg_path))), "logs") return os.path.join(log_dir, "export_collision.dbg") return raw # expliziter Pfad def _bounds(bbox, half_tol): """(minx, maxx, miny, maxy) einer Bounding-Box, je Seite um half_tol erweitert.""" return ( bbox["cx"] - bbox["dx"] / 2.0 - half_tol, bbox["cx"] + bbox["dx"] / 2.0 + half_tol, bbox["cy"] - bbox["dy"] / 2.0 - half_tol, bbox["cy"] + bbox["dy"] / 2.0 + half_tol, ) def _overlaps(bounds_a, bounds_b): """True, wenn sich zwei (minx, maxx, miny, maxy)-Rechtecke in x/y ueberschneiden.""" a_minx, a_maxx, a_miny, a_maxy = bounds_a b_minx, b_maxx, b_miny, b_maxy = bounds_b return a_minx <= b_maxx and b_minx <= a_maxx and a_miny <= b_maxy and b_miny <= a_maxy def _add_neighbor(result, idx_a, idx_b, id_a, id_b): result[idx_a].append(id_b) result[idx_b].append(id_a) def kreisel_half_bboxes(block, durchmesser_mm): """Teilt die Welt-Bounding-Box eines "ILS 2.0 Kreisel"-Blocks entlang der Kreiselachse (Linie durch die Mitten von Antriebs- (AN8) und Spann- station (SP8)) in eine linke und eine rechte Haelfte. Geometrie (siehe KreiselInsert.lsp::draw-module): AN8 liegt in lokalen (unrotierten) Blockkoordinaten bei (radius, 0), SP8 bei (radius+abstand, 0) - radius = durchmesser_mm/2 (Default 400mm, siehe [Kreisel] in cfg/export.cfg). Beide Stationen liegen exakt auf der lokalen X-Achse (Y=0); die gesamte Geometrie (zwei Kreise + zwei Tangenten bei Y=+-radius) ist symmetrisch dazu. Die Trennlinie durch die Stationsmitten IST also die lokale X-Achse selbst, und teilt die Bounding-Box in zwei GLEICH GROSSE Haelften mit je "radius" (Default 400mm) Breite - "links" = lokal Y in [0, +radius], "rechts" = lokal Y in [-radius, 0] (Blickrichtung von Antriebs- zu Spannstation, +X; mathe- matische Standardkonvention: mathematisch positive/CCW-Drehung von +X fuehrt nach +Y, also links). Block-Insertpunkt (world x,y) und -rotation (world "rotation", Grad, CCW positiv) kommen direkt aus block (siehe csv:block-to-json). Die ABSTAND-Attribut wird fuer die lokale Laenge (0..abstand+durchmesser) benoetigt. Rueckgabe: (bbox_links, bbox_rechts), je ein dict {"cx","cy","dx","dy"} - die Welt-AABB der jeweils rotierten Haelfte (bei Rotationen, die kein Vielfaches von 90 Grad sind, umschliesst die AABB die tatsaechliche Haelfte etwas grosszuegiger, wie jede achsparallele Bounding-Box einer gedrehten Flaeche - konsistent mit jeder anderen Bounding-Box in diesem Modul). """ attribs = block.get("attribs", {}) abstand_mm = safe_float(attribs.get("ABSTAND", "0"), 0.0) radius_mm = durchmesser_mm / 2.0 x0 = block.get("x", 0.0) or 0.0 y0 = block.get("y", 0.0) or 0.0 rot_rad = math.radians(block.get("rotation", 0.0) or 0.0) cos_r, sin_r = math.cos(rot_rad), math.sin(rot_rad) def world(local_x, local_y): return ( x0 + local_x * cos_r - local_y * sin_r, y0 + local_x * sin_r + local_y * cos_r, ) # AN8-Kreis reicht lokal bis X=0 (Mitte radius, Radius radius); SP8-Kreis # bis X=abstand+durchmesser (Mitte radius+abstand, Radius radius). local_x_max = abstand_mm + durchmesser_mm def half_bbox(y_lo, y_hi): corners = [ world(0.0, y_lo), world(local_x_max, y_lo), world(local_x_max, y_hi), world(0.0, y_hi), ] xs = [c[0] for c in corners] ys = [c[1] for c in corners] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) return { "cx": (minx + maxx) / 2.0, "cy": (miny + maxy) / 2.0, "dx": maxx - minx, "dy": maxy - miny, } bbox_links = half_bbox(0.0, radius_mm) bbox_rechts = half_bbox(-radius_mm, 0.0) return bbox_links, bbox_rechts def _fmt_bounds(b): """(minx, maxx, miny, maxy) kompakt fuer das Debuglog.""" return f"[x {b[0]:.0f}..{b[1]:.0f} | y {b[2]:.0f}..{b[3]:.0f}]" def _dbg_pair(dbg, id_a, id_b, overlap, detail=""): if dbg: dbg(f" {id_a} <-> {id_b}: " f"{'NACHBARN' if overlap else 'kein Kontakt'}{detail}") def _decode_trans_xy(text): """csv:trans-encode-String (K1-K4, 12 Zeichen, 3 Werte) -> (x, y) in mm (Grundriss, Z wird nicht gebraucht), oder None bei leerem/ungueltigem String. Kodierung siehe Lisp/export.lsp (csv:trans-encode/csv:b64-encode-ints): vorzeichenbehaftete 24-Bit-Fixed-Point-Werte (Faktor 10), je 4 Base64- Zeichen, hoechstwertige 6 Bit zuerst. Es genuegen die ersten beiden Werte (x, y) = die ersten 8 Zeichen. """ text = (text or "").strip() if len(text) < 8: return None werte = [] for start in (0, 4): wert = 0 for zeichen in text[start:start + 4]: idx = _B64_INDEX.get(zeichen) if idx is None: return None wert = (wert << 6) | idx if wert >= 8388608: # 2^23 -> negativer 24-Bit-Wert (Zweierkompl.) wert -= 16777216 werte.append(wert / _TRANS_FAKTOR) return (werte[0], werte[1]) def _omniflo_kpoints(item): """Welt-(x,y)-Punkte der vorhandenen K1-K4-Koordinatensysteme eines Omniflo-Elements, dekodiert aus den csv:trans-encode-Strings item["k1"].. ["k4"] (siehe export_csv.bbox_columns). Leere/fehlende Strings werden uebersprungen - die Liste kann also 0 bis 4 Punkte enthalten. Fuer Geraden stehen darin die beim Export synthetisierten Anfang/Ende-Punkte (K1/K2).""" pts = [] for key in ("k1", "k2", "k3", "k4"): p = _decode_trans_xy(item.get(key, "")) if p is not None: pts.append(p) return pts def _ks_within(kpts_a, kpts_b, tol_mm): """True, wenn irgendein K-Punkt aus a einem K-Punkt aus b naeher als tol_mm kommt (euklidischer Abstand in x/y). Beide Listen muessen nicht leer sein - das prueft der Aufrufer.""" for ax, ay in kpts_a: for bx, by in kpts_b: if math.hypot(ax - bx, ay - by) <= tol_mm: return True return False def _test_group_pairs(group, result, dbg=None): """Alle Paare EINER Gruppe gegeneinander testen (O(n^2) - fuer Kreisel/ Eckrad-Stueckzahlen pro Zeichnung unkritisch). idx_a == idx_b wird uebersprungen: seit dem Kreisel-Links/Rechts-Split (siehe kreisel_half_bboxes) kann dieselbe Original-idx zweimal in der Gruppe auftauchen (die linke und die rechte Haelfte DESSELBEN Kreisels) - die beiden beruehren sich zwangslaeufig an der Trennlinie und waeren sonst faelschlich gegenseitige "Nachbarn". """ n = len(group) for a in range(n): idx_a, bounds_a, id_a = group[a] for b in range(a + 1, n): idx_b, bounds_b, id_b = group[b] if idx_a == idx_b: continue overlap = _overlaps(bounds_a, bounds_b) _dbg_pair(dbg, id_a, id_b, overlap) if overlap: _add_neighbor(result, idx_a, idx_b, id_a, id_b) def _test_group_against_group(group_a, group_b, result, dbg=None): """Jedes Element aus group_a gegen jedes Element aus group_b testen. group_b wird NICHT gegen sich selbst getestet (Aufgabe des Aufrufers).""" for idx_a, bounds_a, id_a in group_a: for idx_b, bounds_b, id_b in group_b: overlap = _overlaps(bounds_a, bounds_b) _dbg_pair(dbg, id_a, id_b, overlap) if overlap: _add_neighbor(result, idx_a, idx_b, id_a, id_b) def _grid_cell(bbox, cell_size_mm): """Rasterzelle (Quadrant) einer Bounding-Box nach ihrem Mittelpunkt.""" return (int(bbox["cx"] // cell_size_mm), int(bbox["cy"] // cell_size_mm)) def _candidate_pairs_strtree(group): """Broad-Phase per shapely-STRtree: liefert alle Gruppen-Indexpaare (a 0 and kpts_a and kpts_b: if _ks_within(kpts_a, kpts_b, ks_tol_mm): detail = f" (KOS-Kontakt <= {ks_tol_mm:.0f}mm)" else: overlap = False detail = f" (BBox ueberschneidet, aber kein KOS-Paar <= {ks_tol_mm:.0f}mm)" _dbg_pair(dbg, id_a, id_b, overlap, detail) if overlap: _add_neighbor(result, idx_a, idx_b, id_a, id_b) def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, kreisel_durchmesser_mm=800.0, omniflo_ks_tolerance_mm=10.0, element_box_mm=None, dbg=None): """Ermittelt je Item die IDs (item["teileid"]) benachbarter Elemente. items = Liste von dict mit "teileart", "teileid" und optional "_bbox" ({"cx","cy","dx","dy",...}, siehe export_csv.py bbox_columns). Items ohne _bbox (z.B. die synthetische Omniflo-Sum-Zeile) bleiben ohne Nachbarn. Omniflo-Items koennen zusaetzlich "k1".."k4" (csv:trans-encode- Strings) tragen - daraus wird die KOS-Verfeinerung gespeist (siehe _test_omniflo / omniflo_ks_tolerance_mm). omniflo_ks_tolerance_mm = Toleranz (mm) fuer die KOS-Verfeinerung der Omniflo-Nachbarschaft (K1-K4). 0 = aus (nur BBox-Ueberschneidung). "ILS 2.0 Kreisel"-Items werden dabei NICHT als eine BBox, sondern als ZWEI gegeneinander getestet: eine linke und eine rechte Haelfte (siehe kreisel_half_bboxes) - die Kreisel-Gruppe fuer die Kollisionspruefung besteht also aus -L/-R statt je Kreisel ("ILS 2.0 Eckrad" bleibt unveraendert eine einzelne BBox). Ein angrenzendes Element (z.B. eine Gefaellestrecke) bekommt dadurch in seiner eigenen Nachbarliste "-L" oder "-R" statt nur "" - je nachdem, welche Haelfte tatsaechlich ueberschneidet. dbg = optionale Callable(str) fuer ein Debugprotokoll (Klassifikation, Gruppen, jeder Ueberschneidungstest, Ergebnis). None = kein Protokoll. Rueckgabe: Liste von kommaseparierten Nachbar-ID-Strings, positionsgleich zu items. Innerhalb eines Items dedupliziert (identische IDs entfernt - das passiert z.B., wenn ein Kreisel dieselbe Strecke ueber BEIDE Haelften beruehrt), sonst nicht global ueber alle Items hinweg (TeileId ist nicht zwingend eindeutig). """ half_tol = tolerance_mm / 2.0 if element_box_mm is None: element_box_mm = load_element_box_mm() result = [[] for _ in items] kreisel_group = [] strecken_group = [] omniflo_group = [] skipped = 0 if dbg: dbg("=== Nachbarschafts-/Kollisionserkennung ===") dbg(f"Toleranz={tolerance_mm}mm (Bounding-Box je Seite +{half_tol}mm erweitert), " f"Omniflo-Zellgroesse={omniflo_cell_size_mm}mm (nur Raster-Fallback), " f"Omniflo-KOS-Toleranz={omniflo_ks_tolerance_mm}mm, " f"Kreisel-Durchmesser={kreisel_durchmesser_mm}mm (Radius je Haelfte)") dbg(f"Elemente gesamt: {len(items)}") dbg("") dbg("Klassifikation je Element:") for idx, item in enumerate(items): bbox = item.get("_bbox") if not bbox: skipped += 1 if dbg: dbg(f" idx={idx} teileid={item.get('teileid', '')!r} " f"teileart={item.get('teileart', '')!r} -> UEBERSPRUNGEN " f"(keine Bounding-Box)") continue teileart = item.get("teileart", "") teileid = item.get("teileid", "") if teileart == KREISEL_SPLIT_TEILEART: gruppe = "Kreisel (links/rechts gesplittet)" bbox_links, bbox_rechts = kreisel_half_bboxes(item, kreisel_durchmesser_mm) bounds_links = _bounds(bbox_links, half_tol) bounds_rechts = _bounds(bbox_rechts, half_tol) kreisel_group.append((idx, bounds_links, f"{teileid}-L")) kreisel_group.append((idx, bounds_rechts, f"{teileid}-R")) if dbg: dbg(f" idx={idx} teileid={teileid!r} teileart={teileart!r} " f"-> {gruppe}; links bbox(cx={bbox_links['cx']:.0f},cy={bbox_links['cy']:.0f}," f"dx={bbox_links['dx']:.0f},dy={bbox_links['dy']:.0f}) bounds={_fmt_bounds(bounds_links)}; " f"rechts bbox(cx={bbox_rechts['cx']:.0f},cy={bbox_rechts['cy']:.0f}," f"dx={bbox_rechts['dx']:.0f},dy={bbox_rechts['dy']:.0f}) bounds={_fmt_bounds(bounds_rechts)}") continue bounds = _bounds(bbox, half_tol) asel_bounds = None if teileart in KREISEL_TEILEARTEN: gruppe = "Kreisel/Eckrad" kreisel_group.append((idx, bounds, teileid)) elif teileart in STRECKEN_TEILEARTEN: # GF/VF (GF_VF_TEILEARTEN): NICHT die grosse Wrapper-Box gegen die # Kreisel testen, sondern je eine kleine feste Box (element_box_mm) an # der AS-Position (K1) und der ES-Position (K2) - nur dort beruehrt # eine Strecke einen Kreisel. Wie beim Kreisel-Links/Rechts-Split # koennen dadurch bis zu ZWEI Gruppeneintraege mit derselben idx/ # teileid entstehen; die spaetere dict.fromkeys()-Deduplizierung # faengt Doppelnennungen ab. Gilt fuer ALLE Strecken-TeileArten # (GF/VF UND "ILS 2.0 Strecke - Modul"), sofern sie K1/K2 fuehren - # fehlt K1/K2 (Altbestand oder Modul ohne AS/ES), liefert # _asel_collision_boxes None und es bleibt bei der Wrapper-Box. asel_bounds = _asel_collision_boxes(item, half_tol, element_box_mm) if asel_bounds is not None: gruppe = f"Strecke/Foerderer (AS/ES-Box, {len(asel_bounds)}x)" for b in asel_bounds: strecken_group.append((idx, b, teileid)) else: gruppe = "Strecke/Foerderer" strecken_group.append((idx, bounds, teileid)) elif teileart in OMNIFLO_TEILEARTEN: gruppe = "Omniflo" omniflo_group.append((idx, bounds, teileid, bbox, _omniflo_kpoints(item))) else: gruppe = "(keine Gruppe - wird nicht geprueft)" if dbg: if asel_bounds is not None: bounds_txt = "; ".join(_fmt_bounds(b) for b in asel_bounds) dbg(f" idx={idx} teileid={teileid!r} teileart={teileart!r} " f"-> {gruppe}; AS/ES-Box(en) bounds={bounds_txt}") else: dbg(f" idx={idx} teileid={teileid!r} teileart={teileart!r} " f"-> {gruppe}; bbox(cx={bbox.get('cx', 0):.0f},cy={bbox.get('cy', 0):.0f}," f"dx={bbox.get('dx', 0):.0f},dy={bbox.get('dy', 0):.0f}) " f"bounds={_fmt_bounds(bounds)}") if dbg: dbg("") dbg(f"Gruppengroessen: Kreisel/Eckrad={len(kreisel_group)}, " f"Strecke/Foerderer={len(strecken_group)}, Omniflo={len(omniflo_group)}, " f"ohne Bounding-Box={skipped}") dbg("") dbg("[1] Kreisel/Eckrad gegen Kreisel/Eckrad:") # 1. Kreisel/Eckrad gegen Kreisel/Eckrad _test_group_pairs(kreisel_group, result, dbg) if dbg: dbg("[2] Kreisel/Eckrad gegen Strecke/Foerderer/Gefaellestrecke:") # 2. Kreisel/Eckrad gegen Gefaellestrecke/Foerderer/Strecke-Modul - # diese drei Kategorien nicht gegeneinander (siehe Modul-Docstring) _test_group_against_group(kreisel_group, strecken_group, result, dbg) if dbg: dbg("[3] Omniflo gegen Omniflo (STRtree-Broad-Phase + KOS-Verfeinerung):") # 3. Omniflo gegen Omniflo: BBox-Broad-Phase (STRtree bzw. Raster-Fallback), # dann KOS-Verfeinerung ueber K1-K4 (siehe _test_omniflo) _test_omniflo(omniflo_group, omniflo_cell_size_mm, omniflo_ks_tolerance_mm, result, dbg) # dict.fromkeys() dedupliziert unter Erhalt der Reihenfolge - relevant # seit dem Kreisel-Split: beruehrt eine Strecke BEIDE Haelften desselben # Kreisels nicht (normalerweise ausgeschlossen), waere sie sonst nicht # betroffen; umgekehrt kann derselbe Strecken-Nachbar im Kreisel-Eintrag # theoretisch doppelt auftauchen, wenn (Rand-/Toleranzfall) beide # Haelften ihn beruehren. neighbor_id_lists = [", ".join(dict.fromkeys(neighbor_ids)) for neighbor_ids in result] if dbg: dbg("") dbg("Ergebnis je Element:") for idx, item in enumerate(items): dbg(f" idx={idx} teileid={item.get('teileid', '')!r}: " f"{len(result[idx])} Nachbar(n) [{neighbor_id_lists[idx]}]") dbg("") dbg(f"Summe gerichteter Nachbarschaftsbeziehungen: " f"{sum(len(r) for r in result)}") return neighbor_id_lists def compute_neighbor_errors(items, neighbor_ids): """Prueft je Item die Mindestanzahl an Partnern (siehe MIN_PARTNER). items = dieselbe Liste wie bei compute_neighbor_ids (braucht "teileart"). neighbor_ids = Rueckgabe von compute_neighbor_ids (positionsgleich zu items). Rueckgabe: Liste von Fehlertexten, positionsgleich zu items: "unverbunden" - keine Partner, obwohl mindestens einer noetig waere "nur ein Partner" - genau ein Partner, obwohl mindestens zwei noetig waeren "" - Mindestanzahl erreicht, oder TeileArt wird nicht geprueft """ errors = [] for item, nachbarn in zip(items, neighbor_ids): min_partner = MIN_PARTNER.get(item.get("teileart", "")) if min_partner is None: errors.append("") continue anzahl = len([p for p in nachbarn.split(",") if p.strip()]) if anzahl == 0: errors.append("unverbunden") elif anzahl == 1 and min_partner >= 2: errors.append("nur ein Partner") else: errors.append("") return errors def compute_sensor_zuordnung(items, tolerance_mm, kreisel_durchmesser_mm=800.0, dbg=None): """Ordnet jeden Separator/Scanner (SENSOR_TEILEARTEN) per Bounding-Box- Ueberschneidung seiner Gefaellestrecke, seinem Foerderer oder einer Kreiselhaelfte (links/rechts, siehe kreisel_half_bboxes) zu. Eigener, unabhaengiger Durchlauf NACH compute_neighbor_ids (nicht in dessen Gruppenaufbau integriert, da Separatoren/Scanner dort bewusst keine eigene Gruppe sind - Nachbarschaft und Zuordnung sind zwei unterschiedliche Fragen: Nachbarschaft = "beruehrt sich", Zuordnung = "gehoert zu genau einem Trägerelement"). Prioritaet bei mehreren Treffern: Gefaellestrecke vor Foerderer vor Kreiselhaelfte (ein Separator/Scanner haengt fachlich an genau einem dieser drei). items = dieselbe Liste wie bei compute_neighbor_ids (braucht "teileart", "teileid" und "_bbox"). Rueckgabe: Liste von Zuordnungs-Strings, positionsgleich zu items - "" fuer Items, die keine SENSOR_TEILEART sind oder keine BBox haben. Bei Treffer: die TeileId der Gefaellestrecke/des Foerderers, oder "-L"/"-R" bei einem Kreisel-Treffer. Kein Treffer bei vorhandener BBox: "nicht zugeordnet". """ half_tol = tolerance_mm / 2.0 gf_entries = [] vf_entries = [] kreisel_entries = [] for item in items: bbox = item.get("_bbox") if not bbox: continue teileart = item.get("teileart", "") teileid = item.get("teileid", "") if teileart == "ILS 2.0 Gefaellestrecke": gf_entries.append((teileid, _bounds(bbox, half_tol))) elif teileart in ("ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"): vf_entries.append((teileid, _bounds(bbox, half_tol))) elif teileart == KREISEL_SPLIT_TEILEART: bbox_links, bbox_rechts = kreisel_half_bboxes(item, kreisel_durchmesser_mm) kreisel_entries.append((f"{teileid}-L", _bounds(bbox_links, half_tol))) kreisel_entries.append((f"{teileid}-R", _bounds(bbox_rechts, half_tol))) if dbg: dbg("") dbg("=== Separator/Scanner-Zuordnung (GF > Foerderer > Kreiselhaelfte) ===") dbg(f"Kandidaten: {len(gf_entries)} Gefaellestrecke(n), " f"{len(vf_entries)} Foerderer/Streckenmodul(e), " f"{len(kreisel_entries)} Kreiselhaelfte(n)") def _find(bounds, candidates): for teileid, other_bounds in candidates: if _overlaps(bounds, other_bounds): return teileid return None result = [] for item in items: teileart = item.get("teileart", "") if teileart not in SENSOR_TEILEARTEN: result.append("") continue bbox = item.get("_bbox") if not bbox: result.append("") continue bounds = _bounds(bbox, half_tol) treffer = (_find(bounds, gf_entries) or _find(bounds, vf_entries) or _find(bounds, kreisel_entries) or "nicht zugeordnet") if dbg: dbg(f" {item.get('teileart', '')} {item.get('teileid', '')!r} " f"bounds={_fmt_bounds(bounds)} -> {treffer!r}") result.append(treffer) return result def compute_scanner_nearest_separator(items, dbg=None): """Sucht fuer jeden Scanner ("ILS 2.0 Scanner") den raeumlich naechst- gelegenen Separator ("ILS 2.0 Separator") - euklidischer Abstand der BBox-Mittelpunkte (cx/cy, Grundriss, Z ignoriert wie ueberall in diesem Modul). Eigener, unabhaengiger Durchlauf, der NACH compute_sensor_zuordnung aufgerufen wird (die Zuordnung der Separatoren zu ihrer Gefaellestrecke/ ihrem Foerderer/ihrer Kreiselhaelfte steht damit bereits fest - diese Funktion aendert daran nichts, sie sucht unabhaengig davon zusaetzlich den naechsten Separator zu jedem Scanner). Es gibt KEINE Abstands- obergrenze - "immer der naechstgelegene" bedeutet auch bei grosser Entfernung einen Treffer, solange ueberhaupt ein Separator existiert. Rueckgabe: Liste von TeileId-Strings (des jeweils naechsten Separators), positionsgleich zu items - "" fuer Nicht-Scanner-Items, fuer Scanner ohne BBox oder wenn ueberhaupt kein Separator mit BBox in der Zeichnung ist. """ separators = [ (item.get("teileid", ""), item["_bbox"]) for item in items if item.get("teileart", "") == "ILS 2.0 Separator" and item.get("_bbox") ] if dbg: dbg("") dbg("=== Scanner -> naechstgelegener Separator ===") dbg(f"Separator-Kandidaten mit BBox: {len(separators)}") result = [] for item in items: if item.get("teileart", "") != "ILS 2.0 Scanner": result.append("") continue bbox = item.get("_bbox") if not bbox or not separators: result.append("") if dbg: dbg(f" Scanner {item.get('teileid', '')!r}: " f"{'keine BBox' if not bbox else 'keine Separatoren vorhanden'} -> kein Treffer") continue cx, cy = bbox["cx"], bbox["cy"] best_id, best_dist = "", None for sep_id, sep_bbox in separators: dist = math.hypot(sep_bbox["cx"] - cx, sep_bbox["cy"] - cy) if best_dist is None or dist < best_dist: best_dist = dist best_id = sep_id if dbg: dbg(f" Scanner {item.get('teileid', '')!r} -> naechster Separator " f"{best_id!r} (Abstand {best_dist:.0f}mm)") result.append(best_id) return result # --------------------------------------------------------------------------- # Kreisel-Beruehrpunkte -> ILS-Weichen # --------------------------------------------------------------------------- def _clamp(v, lo, hi): return lo if v < lo else (hi if v > hi else v) def _kreisel_capsule(item, durchmesser_mm): """Achsen-Segment (Kapsel) eines "ILS 2.0 Kreisel"-Blocks in Welt- koordinaten. Geometrie siehe kreisel_half_bboxes / KreiselInsert.lsp::draw-module: die AN8-(Antriebs-) Stationsmitte liegt lokal bei (radius, 0), die SP8-(Spann-) Stationsmitte bei (radius+abstand, 0), radius = durchmesser_mm/2. Beide auf der lokalen X-Achse. Die gesamte Kreisel-Flaeche ist das um radius "aufgeblasene" Segment zwischen diesen beiden Mitten (Stadium/Kapsel: zwei Endkreise mit Radius radius + zwei Tangenten bei Y=+-radius). Rueckgabe: dict {"a": (ax,ay), "b": (bx,by), "r": radius, "rot": rot_rad, "z": z-Mitte} - a = AN8-Mitte, b = SP8-Mitte (Welt). """ attribs = item.get("attribs", {}) abstand = safe_float(attribs.get("ABSTAND", "0"), 0.0) r = durchmesser_mm / 2.0 x0 = item.get("x", 0.0) or 0.0 y0 = item.get("y", 0.0) or 0.0 rot = math.radians(item.get("rotation", 0.0) or 0.0) cos_r, sin_r = math.cos(rot), math.sin(rot) def world(lx, ly): return (x0 + lx * cos_r - ly * sin_r, y0 + lx * sin_r + ly * cos_r) bbox = item.get("_bbox") or {} z = bbox.get("cz", item.get("z", 0.0) or 0.0) return { "a": world(r, 0.0), "b": world(r + abstand, 0.0), "r": r, "rot": rot, "z": z, } def _closest_pts_segments(p1, q1, p2, q2): """Naechstgelegene Punkte zweier 2D-Strecken [p1,q1] und [p2,q2] (Algorithmus aus Ericson, Real-Time Collision Detection). Rueckgabe (c1, c2, s, t) mit c1 auf Strecke 1, c2 auf Strecke 2 und Parametern s,t in [0,1] (0=Anfang, 1=Ende) - s/t am Rand bedeutet: der naechste Punkt liegt auf dem Endkreis (Cap), dazwischen auf der Tangentenflanke.""" eps = 1e-9 d1 = (q1[0] - p1[0], q1[1] - p1[1]) d2 = (q2[0] - p2[0], q2[1] - p2[1]) r = (p1[0] - p2[0], p1[1] - p2[1]) a = d1[0] * d1[0] + d1[1] * d1[1] e = d2[0] * d2[0] + d2[1] * d2[1] f = d2[0] * r[0] + d2[1] * r[1] if a <= eps and e <= eps: s = t = 0.0 elif a <= eps: s = 0.0 t = _clamp(f / e, 0.0, 1.0) else: c = d1[0] * r[0] + d1[1] * r[1] if e <= eps: t = 0.0 s = _clamp(-c / a, 0.0, 1.0) else: b = d1[0] * d2[0] + d1[1] * d2[1] denom = a * e - b * b s = _clamp((b * f - c * e) / denom, 0.0, 1.0) if denom > eps else 0.0 t = (b * s + f) / e if t < 0.0: t = 0.0 s = _clamp(-c / a, 0.0, 1.0) elif t > 1.0: t = 1.0 s = _clamp((b - c) / a, 0.0, 1.0) c1 = (p1[0] + d1[0] * s, p1[1] + d1[1] * s) c2 = (p2[0] + d2[0] * t, p2[1] + d2[1] * t) return c1, c2, s, t def _oriented_box_aabb(rot_rad, laenge, breite, hoehe): """Achsparallele Welt-Bounding-Box (dx,dy,dz) einer Box, deren Laengsseite (laenge) senkrecht zur Achsenrichtung rot_rad liegt und deren Breite (breite) entlang der Achse - konsistent mit allen anderen BBox-Spalten (Ausdehnung der gedrehten Flaeche in Welt-X/Y). Achse = (cos rot, sin rot); Laengsseite senkrecht dazu = (-sin rot, cos rot). """ c = abs(math.cos(rot_rad)) s = abs(math.sin(rot_rad)) dx = laenge * s + breite * c dy = laenge * c + breite * s return (dx, dy, hoehe) def compute_kreisel_touch_switches(items, tolerance_mm, durchmesser_mm=800.0, element_box_mm=None, dbg=None): """Erzeugt fuer jedes sich beruehrende Paar echter Kreisel ("ILS 2.0 Kreisel") eine ILS-Weiche am Beruehrpunkt. Kreisel-Modell: eine Kapsel (Stadium) - das Achsensegment AN8-Mitte -> SP8-Mitte, aufgeblasen um r = durchmesser_mm/2 (siehe _kreisel_capsule). Zwei Kreisel beruehren sich, wenn der minimale Abstand ihrer Achsen- segmente <= 2r + tolerance_mm ist (2r = durchmesser_mm). Eigenstaendige, praezise Geometriepruefung - unabhaengig vom groben Links/Rechts-AABB- Nachbarschaftstest (compute_neighbor_ids), der nur ueberschneidende Bounding-Boxen, nicht den exakten Beruehrpunkt liefert. Beruehrpunkt (= Weichen-Position) = Mittelpunkt der beiden naechstgelegenen Segmentpunkte. Die Weichen-Box (element_box_mm) wird mit ihrer Laengsseite senkrecht zur Achse desjenigen Kreisels ausgerichtet, dessen beruehrendes Merkmal ein Endkreis ist (naechster Segmentpunkt am Segmentende). Beruehren sich zwei Endkreise oder zwei Tangentenflanken, wird der erstgenannte Kreisel des Paares (kleinerer items-Index) genommen. Ausgegeben wird die achsparallele Welt-Bounding-Box der gedrehten Box (siehe _oriented_box_aabb). Rueckgabe: Liste von dicts (in items-Reihenfolge der Paare), je erzeugte Weiche: {"x","y","z": Beruehrpunkt (mm), "dx","dy","dz": Welt-AABB der ausgerichteten Box (mm), "nachbarn": [teileid_a, teileid_b], "achse_von": teileid des orientierungsgebenden Kreisels, "beruehrung": "Kreis/Tangente" | "Kreis/Kreis" | "Tangente/Tangente"} """ eps = 1e-6 if element_box_mm is None: element_box_mm = load_element_box_mm() box_l, box_b, box_h = element_box_mm kreisel = [] for item in items: if item.get("teileart", "") != KREISEL_SPLIT_TEILEART: continue kreisel.append((item.get("teileid", ""), _kreisel_capsule(item, durchmesser_mm))) if dbg: dbg("") dbg("=== Kreisel-Beruehrpunkte -> ILS-Weichen ===") dbg(f"Echte Kreisel ('{KREISEL_SPLIT_TEILEART}'): {len(kreisel)}; " f"Beruehrschwelle Abstand <= {durchmesser_mm + tolerance_mm:.0f}mm " f"(2r={durchmesser_mm:.0f} + Toleranz {tolerance_mm:.0f})") switches = [] for i in range(len(kreisel)): id_a, cap_a = kreisel[i] for j in range(i + 1, len(kreisel)): id_b, cap_b = kreisel[j] c1, c2, s, t = _closest_pts_segments(cap_a["a"], cap_a["b"], cap_b["a"], cap_b["b"]) dist = math.hypot(c1[0] - c2[0], c1[1] - c2[1]) schwelle = cap_a["r"] + cap_b["r"] + tolerance_mm if dist > schwelle: if dbg: dbg(f" {id_a} <-> {id_b}: kein Kontakt " f"(Achsabstand {dist:.0f}mm > {schwelle:.0f}mm)") continue # Beruehrpunkt = Mitte der beiden naechstgelegenen Segmentpunkte tx = (c1[0] + c2[0]) / 2.0 ty = (c1[1] + c2[1]) / 2.0 tz = (cap_a["z"] + cap_b["z"]) / 2.0 # Endkreis (Cap) beruehrt, wenn der naechste Segmentpunkt am Ende # liegt (s bzw. t == 0 oder 1); dazwischen Tangentenflanke. cap1 = s <= eps or s >= 1.0 - eps cap2 = t <= eps or t >= 1.0 - eps if cap1 and not cap2: achse_id, achse_rot, beruehrung = id_a, cap_a["rot"], "Kreis/Tangente" elif cap2 and not cap1: achse_id, achse_rot, beruehrung = id_b, cap_b["rot"], "Kreis/Tangente" else: achse_id, achse_rot = id_a, cap_a["rot"] beruehrung = "Kreis/Kreis" if cap1 else "Tangente/Tangente" dx, dy, dz = _oriented_box_aabb(achse_rot, box_l, box_b, box_h) switches.append({ "x": tx, "y": ty, "z": tz, "dx": dx, "dy": dy, "dz": dz, "nachbarn": [id_a, id_b], "achse_von": achse_id, "beruehrung": beruehrung, }) if dbg: dbg(f" {id_a} <-> {id_b}: BERUEHRUNG ({beruehrung}, Achsabstand " f"{dist:.0f}mm) -> Weiche bei ({tx:.0f}, {ty:.0f}, {tz:.0f}), " f"Box-AABB ({dx:.0f}, {dy:.0f}, {dz:.0f}), Achse von Kreisel {achse_id}") if dbg: dbg(f"Erzeugte ILS-Weichen: {len(switches)}") return switches # --------------------------------------------------------------------------- # Kreisel <-> Strecken-Enden -> Ein-/Ausschleuselemente # --------------------------------------------------------------------------- def _closest_pt_on_segment(a, b, p): """Naechstgelegener Punkt auf der 2D-Strecke [a,b] zum Punkt p.""" abx, aby = b[0] - a[0], b[1] - a[1] denom = abx * abx + aby * aby if denom <= 1e-9: return (a[0], a[1]) t = _clamp(((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / denom, 0.0, 1.0) return (a[0] + abx * t, a[1] + aby * t) def _strecke_ends(bbox, insert_xy): """Die beiden Enden einer GF/VF-Strecke als (Anfang, Ende) in Welt-x/y. Enden = Mitten der beiden kurzen Stirnseiten der (achsparallelen) Bounding- Box entlang ihrer laengeren Ausdehnung. "Anfang" ist das Ende, das dem Block-Einfuegepunkt (insert_xy = Kettenanfang/startpunkt, siehe ssg-block-wrap-welt) naeher liegt; "Ende" das andere. """ cx, cy = bbox.get("cx", 0.0), bbox.get("cy", 0.0) dx, dy = bbox.get("dx", 0.0), bbox.get("dy", 0.0) if dx >= dy: e0, e1 = (cx - dx / 2.0, cy), (cx + dx / 2.0, cy) else: e0, e1 = (cx, cy - dy / 2.0), (cx, cy + dy / 2.0) ix, iy = insert_xy if math.hypot(e0[0] - ix, e0[1] - iy) <= math.hypot(e1[0] - ix, e1[1] - iy): return e0, e1 return e1, e0 def _asel_kos_xy(item): """(k1_xy, k2_xy) einer GF/VF-Strecke = Welt-(x,y) von AS (K1/KS_EIN) und ES (K2/KS_AUS), dekodiert aus item["k1"]/["k2"] (csv:trans-encode, siehe csv:vfgf-k-kos-strings in export.lsp). Fehlender/leerer String -> None. Rueckgabe (None, None), wenn die Strecke keine K1/K2 fuehrt (Altbestand ohne den neuen Export).""" return _decode_trans_xy(item.get("k1", "")), _decode_trans_xy(item.get("k2", "")) def _asel_axis_rot(k1_xy, k2_xy): """Rotationswinkel (rad) der Foerderachse AS->ES = Richtung K1->K2. Fuer die Ausrichtung der festen AS/ES-Kollisionsbox (element_box_mm, Laengsseite quer zur Achse - konsistent mit _oriented_box_aabb / den Schleuselementen). Fehlt ein Punkt oder fallen beide zusammen, 0.0 (achsparallele Box).""" if k1_xy is None or k2_xy is None: return 0.0 dx, dy = k2_xy[0] - k1_xy[0], k2_xy[1] - k1_xy[1] if math.hypot(dx, dy) < 1e-9: return 0.0 return math.atan2(dy, dx) def _asel_collision_boxes(item, half_tol, box_dims): """Kollisions-Bounding-Boxen einer GF/VF-Strecke fuer den Test gegen die Kreisel: statt der GROSSEN Wrapper-Box je EINE kleine, fest dimensionierte Box (box_dims, siehe load_element_box_mm) an der AS-Position (K1) und der ES-Position (K2) - das sind die einzigen Stellen, an denen eine Strecke einen Kreisel beruehrt. Die Box liegt mit ihrer Laengsseite quer zur Foerderachse AS->ES (_asel_axis_rot / _oriented_box_aabb). box_dims = (laenge, breite, hoehe) in mm. Rueckgabe: Liste von (minx, maxx, miny, maxy)-bounds (0-2 Eintraege) oder None, wenn die Strecke KEINE K1/K2 fuehrt (dann faellt der Aufrufer auf die Wrapper-Box zurueck - Altbestand ohne AS/ES-Export).""" k1_xy, k2_xy = _asel_kos_xy(item) if k1_xy is None and k2_xy is None: return None rot = _asel_axis_rot(k1_xy, k2_xy) dx, dy, _dz = _oriented_box_aabb(rot, box_dims[0], box_dims[1], box_dims[2]) boxes = [] for pt in (k1_xy, k2_xy): if pt is None: continue boxes.append(_bounds({"cx": pt[0], "cy": pt[1], "dx": dx, "dy": dy}, half_tol)) return boxes def _distinct_kreisel_ids(nachbarn): """Eindeutige Kreisel-TeileIds aus einem Nachbarn-String ("0009-L, 0002-R, 0001-L" -> ["0009", "0002", "0001"]). Das Haelften-Suffix -L/-R (siehe kreisel_half_bboxes) wird entfernt, Reihenfolge bleibt erhalten.""" ids = [] for part in (nachbarn or "").split(","): p = part.strip() if not p: continue if p.endswith("-L") or p.endswith("-R"): p = p[:-2] if p not in ids: ids.append(p) return ids def compute_strecke_kreisel_schleus(items, durchmesser_mm=800.0, element_box_mm=None, dbg=None): """Erzeugt je Gefaellestrecke/Foerderer (GF_VF_TEILEARTEN) an Anfang und Ende ein Aus- bzw. Einschleuselement an der Kollisionsposition zum anschliessenden Kreisel. Grundlage sind NICHT eigene Abstandsschwellen, sondern die bereits per Bounding-Box ermittelten Kreisel-Nachbarn der Strecke (Spalte "Nachbarn", siehe compute_neighbor_ids). Jeder der beiden Streckenenden (Anfang = dem Block-Einfuegepunkt naeheres Ende, siehe _strecke_ends) wird der unter diesen Nachbarn naechstgelegene Kreisel zugeordnet: - Am ANFANG entsteht ein Ausschleuselement (AUSSCHLEUS_TEILEART), - am ENDE ein Einschleuselement (EINSCHLEUS_TEILEART). Beruehrt eine Strecke nur EINEN Kreisel (z.B. ein Foerderer, der zum selben Kreisel zurueckfuehrt), wird dieser beiden Enden zugeordnet. Beruehrt sie KEINEN Kreisel, entstehen keine Elemente. Eine Strecke sollte genau zwei Kreisel-Bounding-Boxen beruehren (ein Anfang + ein Ende). Beruehrt ihre BBox MEHR als zwei (mit exakten 3D-BBoxen sollte das nicht vorkommen - hier wird in x/y ohne Hoehe geprueft), entsteht eine Warnung: die beiden Enden bekommen weiterhin ihren jeweils naechsten Kreisel, die ueberzaehligen werden ignoriert und in der Warnung genannt. Position eines Elements = das AS-/ES-Koordinatensystem (K1/K2, siehe csv:vfgf-k-kos-strings), um eine HALBE Box (Box-Breite/2 entlang der Foerderachse, siehe element_box_mm) nach aussen versetzt: das Ausschleuselement (AS/Anfang) VOR das AS-KOS (entgegen der Achse), das Einschleuselement (ES/Ende) HINTER das ES-KOS (in Achsrichtung) - beide also zum anschliessenden Kreisel hin, sodass das Element realistisch zwischen Streckenende und Kreisel liegt. Fehlen K1/K2 (Altbestand ohne AS/ES-Export), werden die Streckenenden aus der Wrapper-BBox genommen (_strecke_ends). Die feste Box (element_box_mm) wird mit ihrer Laengsseite senkrecht zur Foerderachse ausgerichtet (Welt-AABB, _oriented_box_aabb). element_box_mm = (laenge, breite, hoehe) in mm; None -> load_element_box_mm(). Rueckgabe: (result, warnings) result = Liste von dicts: {"teileart": AUSSCHLEUS_/EINSCHLEUS_TEILEART, "x","y","z": Kollisionsposition (mm), "dx","dy","dz": Welt-AABB der ausgerichteten Box (mm), "kreisel_id": TeileId des anschliessenden Kreisels, "strecke_id": TeileId der GF/VF, "ende_label": "Anfang" | "Ende"} warnings = dict {strecke_teileid: warntext} fuer Strecken mit > 2 Kreisel-BBox-Beruehrungen. """ if element_box_mm is None: element_box_mm = load_element_box_mm() box_l, box_b, box_h = element_box_mm kreisel = {} for it in items: if it.get("teileart", "") == KREISEL_SPLIT_TEILEART: kreisel[it.get("teileid", "")] = _kreisel_capsule(it, durchmesser_mm) if dbg: dbg("") dbg("=== Strecken-Enden <-> Kreisel -> Ein-/Ausschleuselemente ===") dbg(f"Echte Kreisel: {len(kreisel)}; Zuordnung ueber die BBox-Nachbarn " f"der Strecke (naechster Kreisel je Ende), erwartet 2 Kreisel/Strecke") result = [] warnings = {} for it in items: if it.get("teileart", "") not in GF_VF_TEILEARTEN: continue bbox = it.get("_bbox") if not bbox: continue sid = it.get("teileid", "") nb_ids = [k for k in _distinct_kreisel_ids(it.get("nachbarn", "")) if k in kreisel] if not nb_ids: if dbg: dbg(f" Strecke {sid}: kein Kreisel-Nachbar -> keine Schleuselemente") continue insert_xy = (it.get("x", bbox.get("cx", 0.0)) or 0.0, it.get("y", bbox.get("cy", 0.0)) or 0.0) # Streckenenden bevorzugt aus den AS/ES-Koordinatensystemen (K1=AS, # K2=ES, siehe csv:vfgf-k-kos-strings) - das ist die tatsaechliche # Position der Ein-/Ausschleuspunkte. "Anfang" bleibt das dem Block- # Einfuegepunkt naehere Ende. Fehlen K1/K2 (Altbestand ohne AS/ES- # Export), Fallback auf die Stirnseiten-Mitten der Wrapper-BBox. k1_xy, k2_xy = _asel_kos_xy(it) if k1_xy is not None and k2_xy is not None: ix, iy = insert_xy if math.hypot(k1_xy[0] - ix, k1_xy[1] - iy) <= math.hypot(k2_xy[0] - ix, k2_xy[1] - iy): anfang_end, ende_end = k1_xy, k2_xy else: anfang_end, ende_end = k2_xy, k1_xy else: anfang_end, ende_end = _strecke_ends(bbox, insert_xy) # Foerderachse (Einheitsvektor Anfang->Ende) fuer den Halbe-Box-Versatz # der Schleuselemente. Ohne verwertbare Richtung (Enden fallen zusammen) # bleibt der Versatz 0. adx, ady = ende_end[0] - anfang_end[0], ende_end[1] - anfang_end[1] alen = math.hypot(adx, ady) axis_u = (adx / alen, ady / alen) if alen > 1e-9 else (0.0, 0.0) # Versatz = halbe Box-Ausdehnung ENTLANG der Foerderachse. Die feste Box # (element_box_mm) liegt mit ihrer Breite laengs der Achse (siehe # _oriented_box_aabb), also ist die halbe Achs-Ausdehnung Breite/2. halbe_box = box_b / 2.0 # Ausrichtung der Schleuselement-Box: entlang der Foerderachse (nicht # mehr der Kreiselachse) - das Element sitzt am AS/ES-KOS in # Foerderrichtung, konsistent mit den AS/ES-Kollisionsboxen. achs_rot = math.atan2(ady, adx) if alen > 1e-9 else 0.0 scz = bbox.get("cz", 0.0) def _nearest(end_pt): best = None for kid in nb_ids: cap = kreisel[kid] p = _closest_pt_on_segment(cap["a"], cap["b"], end_pt) d = math.hypot(p[0] - end_pt[0], p[1] - end_pt[1]) if best is None or d < best[3]: best = (kid, cap, p, d) return best a_best = _nearest(anfang_end) e_best = _nearest(ende_end) if len(nb_ids) > 2: genutzt = {a_best[0], e_best[0]} extra = [k for k in nb_ids if k not in genutzt] warnings[sid] = ( f"beruehrt {len(nb_ids)} Kreisel-BoundingBoxen " f"(erwartet 2 = Anfang + Ende): {', '.join(nb_ids)}" + (f"; ueberzaehlig/ignoriert: {', '.join(extra)}" if extra else "") + " - mit exakten 3D-BoundingBoxen sollte das nicht auftreten") if dbg: dbg(f" Strecke {sid}: WARNUNG - {warnings[sid]}") # Versatz der Schleuselement-Position gegenueber dem AS/ES-KOS: eine # HALBE Box entlang der Foerderachse, jeweils NACH AUSSEN (weg von der # Streckenmitte, zum anschliessenden Kreisel hin) - damit das Element # realistisch zwischen Streckenende und Kreisel liegt statt exakt auf # dem KOS. Vorzeichen: Ausschleuselement (AS/Anfang) "vor das AS-KOS" # = entgegen der Achse (-u), Einschleuselement (ES/Ende) "hinter das # ES-KOS" = in Achsrichtung (+u). for best, teileart, label, end_pt, vorzeichen in ( (a_best, AUSSCHLEUS_TEILEART, "Anfang", anfang_end, -1.0), (e_best, EINSCHLEUS_TEILEART, "Ende", ende_end, 1.0)): kid, cap, p, d = best pos = (end_pt[0] + vorzeichen * axis_u[0] * halbe_box, end_pt[1] + vorzeichen * axis_u[1] * halbe_box) dx, dy, dz = _oriented_box_aabb(achs_rot, box_l, box_b, box_h) result.append({ "teileart": teileart, "x": pos[0], "y": pos[1], "z": (cap["z"] + scz) / 2.0, "dx": dx, "dy": dy, "dz": dz, "kreisel_id": kid, "strecke_id": sid, "ende_label": label, }) if dbg: dbg(f" Strecke {sid} {label}: Kreisel {kid} (Abstand Ende<->Achse " f"{d:.0f}mm) -> {teileart} bei ({pos[0]:.0f}, {pos[1]:.0f}, " f"{(cap['z'] + scz) / 2.0:.0f}) = KOS ({end_pt[0]:.0f}, {end_pt[1]:.0f}) " f"{'+' if vorzeichen > 0 else '-'} halbe Box ({halbe_box:.0f}mm) entlang Achse, " f"Box-AABB ({dx:.0f}, {dy:.0f}, {dz:.0f})") if dbg: dbg(f"Erzeugte Ein-/Ausschleuselemente: {len(result)}; " f"Strecken mit Warnung (>2 Kreisel): {len(warnings)}") return result, warnings