diff --git a/lib/export_csv.py b/lib/export_csv.py index ee4b22b..dc91c65 100644 --- a/lib/export_csv.py +++ b/lib/export_csv.py @@ -7,6 +7,14 @@ Fuer Omniflo-Elemente (Bogen/Weiche/Gerade) wird am Ende zusaetzlich eine "Omniflo Sum"-Zeile mit Anzahl Boegen/Weichentypen und Gesamtlaenge AP110/AP60 angehaengt (siehe build_omni_sum_merkmale). +Nach der Kollisions-/Nachbarschaftserkennung wird ausserdem an jedem +Beruehrpunkt zweier Kreisel eine "ILS Weiche"-Zeile angehaengt (siehe +build_kreisel_weiche_items / export_neighbors.compute_kreisel_touch_switches): +Anzahl 1, Position = Beruehrkoordinaten, Nachbarn = die beiden Kreisel-IDs, +K1-K4/Insertpoint leer, Boundingbox = statische Default-Box (WEICHE_BOX_*), +mit der Laengsseite senkrecht zur Achse des beruehrenden Kreisels; die TeileId +zaehlt aus der hoechsten bereits vergebenen numerischen ID hoch. + Aufruf: python export_csv.py @@ -81,7 +89,9 @@ from export_neighbors import ( compute_neighbor_errors, compute_sensor_zuordnung, compute_scanner_nearest_separator, + compute_kreisel_touch_switches, SENSOR_TEILEARTEN, + WEICHE_TEILEART, ) BLOCKPATTERNS = load_patterns() @@ -392,6 +402,66 @@ SKIP_BLOCKS = set(BLOCKPATTERNS.get("pattern_ks_subblocks", ["K1", "K2", "K3", " # Bloecke verarbeiten (einfache Liste, keine Summierung) # --------------------------------------------------------------------------- +def max_numeric_teileid(items): + """Hoechste rein-numerische TeileId in items (z.B. "0008" -> 8). Nicht- + numerische IDs (SEP-0001, autogenerated_of_json, ...) werden ignoriert. + 0, wenn keine numerische ID existiert. Basis fuer die fortlaufende + Nummerierung der am Kreisel-Beruehrpunkt erzeugten ILS-Weichen (siehe + 'Zaehle die Id aus der letzten enthaltenen ID hoch').""" + max_id = 0 + for item in items: + tid = str(item.get("teileid", "")).strip() + if tid.isdigit(): + max_id = max(max_id, int(tid)) + return max_id + + +def build_kreisel_weiche_items(items, planquadrat_cfg, start_elem_nr, dbg=None): + """Haengt fuer jeden Kreisel-Beruehrpunkt eine "ILS Weiche"-Zeile an items + an (siehe export_neighbors.compute_kreisel_touch_switches). + + TeileId zaehlt aus der hoechsten bereits enthaltenen numerischen ID hoch + (max_numeric_teileid), 4-stellig aufgefuellt wie die Kreisel-IDs. Anzahl + ist wie bei allen Zeilen 1; Nachbarn = die beiden Kreisel-IDs; K1-K4 und + Insertpoint bleiben leer; Position = Beruehrkoordinaten; Boundingbox = die + statische, an der Kreiselachse ausgerichtete Default-Box (WEICHE_BOX_*). + + Rueckgabe: fortgeschriebene Elementnummer (start_elem_nr + Anzahl Weichen). + """ + switches = compute_kreisel_touch_switches( + items, NEIGHBOR_TOLERANCE_MM, durchmesser_mm=KREISEL_DURCHMESSER_MM, dbg=dbg) + + naechste_id = max_numeric_teileid(items) + elem_nr = start_elem_nr + for n, sw in enumerate(switches, start=1): + elem_nr += 1 + naechste_id += 1 + teileid = f"{naechste_id:04d}" + planquadrat = (compute_planquadrat(sw["x"], sw["y"], planquadrat_cfg) + if planquadrat_cfg is not None else "") + items.append({ + "nr": elem_nr, + "teileart": WEICHE_TEILEART, + "teileid": teileid, + "bezeichnung": f"ILS Weiche :{n}", + "planquadrat": planquadrat, + "position": f'{sw["x"]:.2f}, {sw["y"]:.2f}, {sw["z"]:.2f}', + "boundingbox": f'{sw["dx"]:.2f}, {sw["dy"]:.2f}, {sw["dz"]:.2f}', + "insertpoint": "", + "k1": "", "k2": "", "k3": "", "k4": "", + "nachbarn": ", ".join(sw["nachbarn"]), + "fehler": "", + "merkmale": { + "Erzeugt aus": "Kreisel-Beruehrpunkt", + "Kreisel A": sw["nachbarn"][0], + "Kreisel B": sw["nachbarn"][1], + "Achse von Kreisel": sw["achse_von"], + "Beruehrung": sw["beruehrung"], + }, + }) + return elem_nr + + def process_blocks(blocks, lookup, dbg=None): # x_origin_mm/y_origin_mm einmalig fuer diesen Exportlauf aufloesen (siehe # export_planquadrat.resolve_origins): automatisch aus den Bloecken, falls @@ -655,6 +725,12 @@ def process_blocks(blocks, lookup, dbg=None): if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id: item["merkmale"]["Naechster Separator"] = separator_id + # Nach der Kollisions-/Nachbarschaftserkennung: an jedem Kreisel- + # Beruehrpunkt eine ILS-Weiche anhaengen (eigene Zeilen am Listenende - + # sie nehmen an der Nachbarschaftserkennung selbst nicht teil, ihre + # Nachbarn sind fest die beiden beruehrenden Kreisel). + build_kreisel_weiche_items(items, planquadrat_cfg, elem_nr, dbg=dbg) + return items diff --git a/lib/export_neighbors.py b/lib/export_neighbors.py index f743b09..88f47fe 100644 --- a/lib/export_neighbors.py +++ b/lib/export_neighbors.py @@ -79,6 +79,19 @@ SENSOR_TEILEARTEN = {"ILS 2.0 Separator", "ILS 2.0 Scanner"} # 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" + +# Statische Default-Boundingbox einer solchen ILS-Weiche (Laenge x Breite x +# Hoehe in mm). Die Laengsseite (WEICHE_BOX_LAENGE_MM) wird senkrecht zur +# Achse desjenigen Kreisels ausgerichtet, dessen Endkreis den Beruehrpunkt +# bildet; ausgegeben wird die achsparallele Welt-Bounding-Box dieser +# gedrehten Box (siehe _oriented_box_aabb). +WEICHE_BOX_LAENGE_MM = 250.0 +WEICHE_BOX_BREITE_MM = 100.0 +WEICHE_BOX_HOEHE_MM = 30.0 + # 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 @@ -740,3 +753,195 @@ def compute_scanner_nearest_separator(items, dbg=None): 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, 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 (WEICHE_BOX_*) 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 + 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, WEICHE_BOX_LAENGE_MM, WEICHE_BOX_BREITE_MM, WEICHE_BOX_HOEHE_MM) + 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