diff --git a/Lisp/ssg_id.lsp b/Lisp/ssg_id.lsp index 28aabdd..0bb29ea 100644 --- a/Lisp/ssg_id.lsp +++ b/Lisp/ssg_id.lsp @@ -28,7 +28,12 @@ ;; --- Alle exportierbaren INSERT-Entities sammeln --- -;; Gleiche Filterlogik wie csv:collect-export-blocks +;; Gleiche Filterlogik wie csv:collect-export-blocks (siehe dort auch +;; pattern_separator/pattern_scanner in cfg/export.cfg - Separator_SP/S-LP/ +;; Scanner sind eigenstaendige Sensor-Bloecke, die inzwischen ein eigenes +;; ID-ATTDEF haben und daher hier ebenfalls erfasst werden muessen, sonst +;; bleibt ihr ID-Attribut trotz ATTDEF für immer leer - ssg-attrib-set-on +;; kann nur einen Wert SETZEN, wenn der Block ueberhaupt erst hier landet). (defun ssg-id-collect-blocks ( / ss-all ss-out i ename ed bname) (setq ss-all (ssget "X" (list (cons 0 "INSERT")))) (if (null ss-all) @@ -43,6 +48,7 @@ (wcmatch bname "KR_*,KREISEL_*,ECKRAD_*") (wcmatch (strcase bname) "AP110*,AP_110*,AP60*,AP_60*,APG110*") (wcmatch bname "Vario*,Staustrecke*,AUS_Element*,EIN_Element*,VF_*,GF_*") + (wcmatch bname "Separator_SP*,S-LP*,Scanner*") (wcmatch bname "#*") ) (ssadd ename ss-out) diff --git a/cfg/export.cfg b/cfg/export.cfg index 2ff5962..fb8e7c0 100644 --- a/cfg/export.cfg +++ b/cfg/export.cfg @@ -115,3 +115,18 @@ debug_log=1 # nicht gesetzt. bb_minimum_height_mm=1 +[Kreisel] +# Kreisel-Durchmesser (Antriebs-/Spannstation AN8/SP8) in mm. Quelle der +# Wahrheit ist data/json/component_defaults.json ("kreisel"."durchmesser", +# Default 800.0) - hier fuer Python gespiegelt (Aenderungen bitte an beiden +# Stellen nachziehen, siehe Hinweis oben bei [blockpattern]). +# +# Wird von lib/export_neighbors.py (kreisel_half_bboxes) benutzt, um die +# Bounding-Box jedes Kreisels bei der Kollisionspruefung in eine linke und +# eine rechte Haelfte zu teilen: AN8 (Antriebsstation) liegt lokal bei +# (radius, 0), SP8 (Spannstation) bei (radius+abstand, 0) - siehe +# KreiselInsert.lsp::draw-module - radius = durchmesser_mm/2 (Default 400mm). +# Die gesamte Geometrie ist symmetrisch um die lokale X-Achse (Y=0, die +# Linie durch beide Stationsmitten) - genau diese Linie trennt links/rechts. +durchmesser_mm=800 + diff --git a/data/ils/Scanner_2D.dwg b/data/ils/Scanner_2D.dwg index a190780..47a100d 100644 Binary files a/data/ils/Scanner_2D.dwg and b/data/ils/Scanner_2D.dwg differ diff --git a/data/ils/Scanner_3D.dwg b/data/ils/Scanner_3D.dwg index c2afd3f..8daec6f 100644 Binary files a/data/ils/Scanner_3D.dwg and b/data/ils/Scanner_3D.dwg differ diff --git a/data/ils/Separator_SP_2D.dwg b/data/ils/Separator_SP_2D.dwg index 0704170..dc21e42 100644 Binary files a/data/ils/Separator_SP_2D.dwg and b/data/ils/Separator_SP_2D.dwg differ diff --git a/lib/export_csv.py b/lib/export_csv.py index 8efd884..e21b6b6 100644 --- a/lib/export_csv.py +++ b/lib/export_csv.py @@ -38,7 +38,20 @@ Fehler = "unverbunden" (keine Partner, aber mindestens einer noetig), (Mindestanzahl erreicht bzw. TeileArt wird nicht geprueft). Kreisel/Eckrad/ Omniflo-Elemente brauchen mindestens einen Partner, Gefaellestrecke/ Foerderer/Strecke-Modul mindestens zwei (siehe export_neighbors.py -MIN_PARTNER). +MIN_PARTNER). Ein Kreisel wird dabei NICHT als eine, sondern als zwei +BBoxen (links/rechts, geteilt entlang der Achse durch Antriebs-/ +Spannstation, siehe export_neighbors.kreisel_half_bboxes) gegen +Gefaellestrecke/Foerderer getestet - deren "Nachbarn"-Spalte zeigt darum +"-L" bzw. "-R" statt nur der Kreisel-TeileId. +Separator/Scanner-Zeilen bekommen zusaetzlich ein berechnetes "Zuordnung"- +Merkmal (Gefaellestrecke > Foerderer > Kreiselhaelfte, per BBox-Ueber- +schneidung - siehe export_neighbors.compute_sensor_zuordnung), das die +ATTRIB-Zuordnung (falls das Attribut ZUORDNUNG in der Zeichnung von Hand +gesetzt wurde) ersetzt, sofern ein Treffer gefunden wird. Scanner-Zeilen +bekommen zusaetzlich ein "Naechster Separator"-Merkmal (raeumlich naechst- +gelegener Separator, euklidischer Abstand der BBox-Mittelpunkte - eigene, +von der Zuordnung unabhaengige Suche, siehe export_neighbors.compute_ +scanner_nearest_separator). Position, Boundingbox, Planquadrat, Nachbarn und Fehler sind nur fuer EXPORTCSV vorhanden (nicht EXPORTSIVAS) - siehe csv:run-export. Insertpoint/ K1-K4 stehen zwar in export_raw.json fuer jeden Export zur Verfuegung (siehe @@ -55,15 +68,20 @@ from export_planquadrat import load_planquadrat_config, resolve_origins, compute from export_neighbors import ( load_neighbor_tolerance_mm, load_omniflo_cell_size_mm, + load_kreisel_durchmesser_mm, load_collision_debug_target, compute_neighbor_ids, compute_neighbor_errors, + compute_sensor_zuordnung, + compute_scanner_nearest_separator, + SENSOR_TEILEARTEN, ) BLOCKPATTERNS = load_patterns() PLANQUADRAT_CFG = load_planquadrat_config() NEIGHBOR_TOLERANCE_MM = load_neighbor_tolerance_mm() NEIGHBOR_OMNIFLO_CELL_SIZE_MM = load_omniflo_cell_size_mm() +KREISEL_DURCHMESSER_MM = load_kreisel_durchmesser_mm() NEIGHBOR_DEBUG_TARGET = load_collision_debug_target() @@ -191,9 +209,9 @@ def build_gerade_merkmale(block): def build_sensor_merkmale(block): """Merkmale-Dict fuer einen eigenstaendigen Separator_SP/S-LP- oder Scanner-Block (siehe pattern_separator/pattern_scanner in cfg/export.cfg). - Diese Bloecke haben (anders als Kreisel/GF/VF) kein ID-Attribut - - ZUORDNUNG/ARTINR/LAYER sind die einzigen ATTDEFs (siehe ils-insert-sensor/ - mubea:build-separator-one).""" + ZUORDNUNG/ARTINR/LAYER (+ neuerdings ID) sind die ATTDEFs (siehe + ils-insert-sensor/mubea:build-separator-one, ssg-id-collect-blocks in + ssg_id.lsp).""" attribs = block.get("attribs", {}) return { "Zuordnung": attribs.get("ZUORDNUNG", ""), @@ -204,13 +222,17 @@ def build_sensor_merkmale(block): def format_sensor_id(prefix, n): - """Erzeugt eine laufende TeileId fuer Separator/Scanner-Zeilen im CSV-Export. + """Fallback-TeileId fuer Separator/Scanner-Zeilen im CSV-Export, falls das + Block-Attribut "ID" (noch) leer ist. - Diese Bloecke haben KEIN persistiertes ID-Attribut in der Zeichnung - (anders als Kreisel/GF/VF, deren "ID" von ssg-id-generate/ssg-id-check-all - beim Insert bzw. vor jedem Export vergeben wird) - der Praefix macht das - sichtbar: eine rein fuer diesen Exportlauf erzeugte, NICHT ueber mehrere - Exportlaeufe hinweg stabile laufende Nummer, keine echte Block-ID. + Separator_SP/Scanner haben seit kurzem ein eigenes ID-ATTDEF (siehe + ssg-id-collect-blocks in ssg_id.lsp) - normalerweise wird also das echte, + ueber ssg-id-generate/ssg-id-check-all vergebene "ID"-Attribut als + TeileId verwendet, genau wie bei Kreisel/GF/VF. Nur wenn das (noch) + fehlt (z.B. bei aelteren, noch nicht neu exportierten Bloecken), erzeugt + diese Funktion eine rein fuer DIESEN Exportlauf gueltige, NICHT ueber + mehrere Exportlaeufe hinweg stabile Ersatznummer - der Praefix macht + sichtbar, dass es keine echte, persistierte Block-ID ist. """ return f"{prefix}-{n:04d}" @@ -489,6 +511,15 @@ def process_blocks(blocks, lookup, dbg=None): "bezeichnung": f"Kreisel :{kreisel_count}", "planquadrat": planquadrat_column(block, planquadrat_cfg), "merkmale": build_kreisel_merkmale(block), + # x/y/rotation/attribs (roh, nicht nur die Merkmale) werden + # von export_neighbors.kreisel_half_bboxes gebraucht, um die + # Links/Rechts-Trennung ueber AN8/SP8/ABSTAND nachzurechnen - + # bbox_columns() liefert nur die fertige Gesamt-BBox, keine + # Rohdaten dafuer. + "x": block.get("x", 0.0), + "y": block.get("y", 0.0), + "rotation": block.get("rotation", 0.0), + "attribs": block.get("attribs", {}), **bbox_columns(block), }) continue @@ -508,18 +539,23 @@ def process_blocks(blocks, lookup, dbg=None): }) continue - # Separator (Separator_SP/S-LP) - eigene TeileArt-Zeile mit laufender - # ID (siehe format_sensor_id; kein echtes ID-Attribut am Block, anders - # als Kreisel/GF/VF). EXPORTSIVAS schliesst denselben Blockpattern - # bewusst aus (siehe export_sivas.py) - dort zaehlt bereits - # ANZAHL_SEPARATOR am zugehoerigen Kreisel/GF/VF. + # Separator (Separator_SP/S-LP) - eigene TeileArt-Zeile. TeileId: das + # echte ID-Attribut, falls vorhanden (Separator_SP/Scanner haben seit + # kurzem ein eigenes ID-ATTDEF, siehe ssg-id-collect-blocks in + # ssg_id.lsp - ssg-id-check-all fuellt es vor jedem Export). Fallback + # format_sensor_id (laufende SEP-/SCN-Nummer NUR fuer diesen Export- + # lauf) fuer aeltere Bloecke, deren ID-Attribut noch leer ist (z.B. + # vor dem naechsten Insert/ssg-id-check-all-Durchlauf). + # EXPORTSIVAS schliesst denselben Blockpattern bewusst aus (siehe + # export_sivas.py) - dort zaehlt bereits ANZAHL_SEPARATOR am + # zugehoerigen Kreisel/GF/VF. if matches_any(bname, BLOCKPATTERNS.get("pattern_separator", [])): elem_nr += 1 separator_count += 1 items.append({ "nr": elem_nr, "teileart": "ILS 2.0 Separator", - "teileid": format_sensor_id("SEP", separator_count), + "teileid": block.get("attribs", {}).get("ID") or format_sensor_id("SEP", separator_count), "bezeichnung": f"Separator :{separator_count}", "planquadrat": planquadrat_column(block, planquadrat_cfg), "merkmale": build_sensor_merkmale(block), @@ -534,7 +570,7 @@ def process_blocks(blocks, lookup, dbg=None): items.append({ "nr": elem_nr, "teileart": "ILS 2.0 Scanner", - "teileid": format_sensor_id("SCN", scanner_count), + "teileid": block.get("attribs", {}).get("ID") or format_sensor_id("SCN", scanner_count), "bezeichnung": f"Scanner :{scanner_count}", "planquadrat": planquadrat_column(block, planquadrat_cfg), "merkmale": build_sensor_merkmale(block), @@ -563,12 +599,31 @@ def process_blocks(blocks, lookup, dbg=None): }) neighbor_ids = compute_neighbor_ids( - items, NEIGHBOR_TOLERANCE_MM, NEIGHBOR_OMNIFLO_CELL_SIZE_MM, dbg=dbg) + items, NEIGHBOR_TOLERANCE_MM, NEIGHBOR_OMNIFLO_CELL_SIZE_MM, + kreisel_durchmesser_mm=KREISEL_DURCHMESSER_MM, dbg=dbg) neighbor_errors = compute_neighbor_errors(items, neighbor_ids) for item, nachbarn, fehler in zip(items, neighbor_ids, neighbor_errors): item["nachbarn"] = nachbarn item["fehler"] = fehler + # Separator/Scanner: Zuordnung zu Gefaellestrecke/Foerderer/Kreiselhaelfte + # per BBox-Ueberschneidung berechnen (Prioritaet siehe compute_sensor_ + # zuordnung) - ersetzt die von Hand gesetzte ZUORDNUNG-Attribut-Merkmale + # nur, wenn tatsaechlich ein Treffer gefunden wurde. + sensor_zuordnung = compute_sensor_zuordnung( + items, NEIGHBOR_TOLERANCE_MM, kreisel_durchmesser_mm=KREISEL_DURCHMESSER_MM, dbg=dbg) + for item, zuordnung in zip(items, sensor_zuordnung): + if item.get("teileart", "") in SENSOR_TEILEARTEN and zuordnung: + item["merkmale"]["Zuordnung"] = zuordnung + + # NACH der Separator-Zuordnung: zu jedem Scanner den raeumlich naechst- + # gelegenen Separator suchen (eigene, unabhaengige Suche - siehe + # compute_scanner_nearest_separator). + nearest_separator = compute_scanner_nearest_separator(items, dbg=dbg) + for item, separator_id in zip(items, nearest_separator): + if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id: + item["merkmale"]["Naechster Separator"] = separator_id + return items diff --git a/lib/export_neighbors.py b/lib/export_neighbors.py index e442182..1d42d2b 100644 --- a/lib/export_neighbors.py +++ b/lib/export_neighbors.py @@ -33,13 +33,20 @@ 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 +from export_blockpatterns import cfg_path_from_env, load_export_cfg, safe_float 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 @@ -74,6 +81,16 @@ def load_omniflo_cell_size_mm(cfg_path=None): return parser.getfloat("Nachbarschaft", "omniflo_zellgroesse_mm", fallback=3000.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. @@ -120,6 +137,73 @@ def _add_neighbor(result, idx_a, idx_b, id_a, 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}]" @@ -132,12 +216,21 @@ def _dbg_pair(dbg, id_a, id_b, overlap): def _test_group_pairs(group, result, dbg=None): """Alle Paare EINER Gruppe gegeneinander testen (O(n^2) - fuer Kreisel/ - Eckrad-Stueckzahlen pro Zeichnung unkritisch).""" + 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: @@ -195,7 +288,8 @@ def _test_omniflo_grid(group, cell_size_mm, result, dbg=None): _add_neighbor(result, idx_a, idx_b, id_a, id_b) -def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, dbg=None): +def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, + kreisel_durchmesser_mm=800.0, dbg=None): """Ermittelt je Item die IDs (item["teileid"]) benachbarter Elemente. items = Liste von dict mit "teileart", "teileid" und optional "_bbox" @@ -203,12 +297,23 @@ def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, dbg=N ohne _bbox (z.B. die synthetische Omniflo-Sum-Zeile) bleiben ohne Nachbarn. + "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 (nicht ueber die ID dedupliziert, da TeileId nicht zwingend - eindeutig ist). + 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] @@ -221,7 +326,8 @@ def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, dbg=N 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") + f"Omniflo-Zellgroesse={omniflo_cell_size_mm}mm, " + f"Kreisel-Durchmesser={kreisel_durchmesser_mm}mm (Radius je Haelfte)") dbg(f"Elemente gesamt: {len(items)}") dbg("") dbg("Klassifikation je Element:") @@ -237,6 +343,20 @@ def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, dbg=N 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" @@ -276,7 +396,13 @@ def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0, dbg=N # 3. Omniflo gegen Omniflo, rasterbasiert vorgefiltert _test_omniflo_grid(omniflo_group, omniflo_cell_size_mm, result, dbg) - neighbor_id_lists = [", ".join(neighbor_ids) for neighbor_ids in result] + # 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("") @@ -316,3 +442,136 @@ def compute_neighbor_errors(items, neighbor_ids): 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