#!/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" # 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, 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 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) if teileart in KREISEL_TEILEARTEN: gruppe = "Kreisel/Eckrad" kreisel_group.append((idx, bounds, teileid)) elif teileart in STRECKEN_TEILEARTEN: 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: 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