CSV-Export: Nachbarschaft/bbox fuer Kreisel-Kreisel-Weichen, sepliste-Ketten und Separatoren repariert

Kreisel-Kreisel-Weichen fehlten bisher im Kreisel-Umlauf (compute_kreisel_umlauf),
wodurch angrenzende Separatoren auf falsche/fehlende Nachbarn zeigten. Die
kreisel_bounds-Matching-Box ignorierte ausserdem die tatsaechliche Kreiseldrehung
(faelschlich als achsparallel angenommen), was bei gedrehten langen Kreiseln
(Mubea Kreisel 0002) zu falschen Nachbarschaften fuehrte.

Die sepliste-XDATA (AS/SEP/ES-Reihenfolge je VF_n/GF_n) wurde nie ans Python-Item
durchgereicht - die gesamte Ketten-Nachbarschaftserkennung lief dadurch ins Leere.
Nach dem Beheben entstand eine Doppelzaehlung: die in einer Kette verpackten
Separatoren existieren bereits als echte Proxy-INSERTs (csv:sep-proxies-erzeugen);
build_separator_kette_items erzeugte zusaetzlich eigene Zeilen aus der sepliste.
Umgebaut zu map_separator_kette_items: ordnet die sepliste-Reihenfolge den
vorhandenen Proxy-Items zu (Match ueber Wrapper-ID + INSERT-Punkt) statt neue
Zeilen zu erzeugen - keine Doppelzaehlung mehr, Nachbarn korrekt gesetzt.

Ausserdem: vla-getboundingbox scheiterte in einem realen Export bei ALLEN
Bloecken (fehlende bbox ueberall), der Fehler wurde bisher still verschluckt.
REGENALL vor der Export-Schleife behebt das; eine neue Diagnose (exp-bbox-fail)
meldet kuenftige Ausfaelle statt sie zu verschlucken. Freie/eingebettete
Separatoren ohne bbox bekommen zusaetzlich einen Fallback aus einer konfigurierbaren
Symbol-Groesse (cfg/export.cfg [Boundingbox] separator_box_*).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:41:02 +02:00
parent e08b8b2b08
commit 27ae3df056
10 changed files with 1042 additions and 244 deletions
+107 -14
View File
@@ -121,6 +121,56 @@ def load_element_box_mm(cfg_path=None):
parser.getfloat("Boundingbox", "element_box_hoehe_mm", fallback=WEICHE_BOX_HOEHE_MM),
)
# Fallback-Masse (mm) der festen Separator-Symbol-Bounding-Box, falls
# cfg/export.cfg [Boundingbox] separator_box_* nicht gesetzt/lesbar ist (siehe
# load_separator_box_mm). LAENGE laengs des Symbols (bei Drehung 0 entlang X),
# BREITE quer, HOEHE = Z. Gemessenes Separator_SP-Symbol (~210 x 150 x 14 mm).
SEPARATOR_BOX_LAENGE_MM = 210.0
SEPARATOR_BOX_BREITE_MM = 150.0
SEPARATOR_BOX_HOEHE_MM = 14.0
def load_separator_box_mm(cfg_path=None):
"""Feste Bounding-Box-Masse (Laenge, Breite, Hoehe in mm) eines Separator-
Symbols aus cfg/export.cfg [Boundingbox] separator_box_laenge_mm /
_breite_mm / _hoehe_mm. Gebraucht fuer die in einer VF_n/GF_n-Kette
eingebetteten Separatoren (sepliste-XDATA), die kein reales INSERT mehr
sind und darum von vla-getboundingbox keine Box bekommen. Fallback auf
SEPARATOR_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", "separator_box_laenge_mm", fallback=SEPARATOR_BOX_LAENGE_MM),
parser.getfloat("Boundingbox", "separator_box_breite_mm", fallback=SEPARATOR_BOX_BREITE_MM),
parser.getfloat("Boundingbox", "separator_box_hoehe_mm", fallback=SEPARATOR_BOX_HOEHE_MM),
)
def separator_bbox(x, y, rotation_grad, separator_box_mm=None, z=0.0):
"""Achsparallele Welt-Bounding-Box (_bbox-dict) eines Separator-Symbols an
Weltposition (x, y) mit Drehung rotation_grad. Die feste Symbol-Box
(separator_box_mm = (laenge, breite, hoehe)) wird um rotation_grad gedreht
und ihre achsparallele Welt-Huelle zurueckgegeben - fuer eingebettete
sepliste-Separatoren, die keine echte Zeichnungsgeometrie haben.
Rueckgabe: dict {"cx","cy","cz","dx","dy","dz"} wie csv:get-bbox / die
"_bbox" der realen Bloecke (siehe export_csv.bbox_columns)."""
if separator_box_mm is None:
separator_box_mm = load_separator_box_mm()
laenge, breite, hoehe = separator_box_mm
# Laenge liegt bei Drehung 0 entlang X, Breite entlang Y (anders als
# _oriented_box_aabb, das die Laengsseite QUER zur Achse legt - hier ist
# es die reine Symbol-AABB, konsistent mit der gemessenen Referenz).
rot = math.radians(rotation_grad or 0.0)
c, s = abs(math.cos(rot)), abs(math.sin(rot))
dx = laenge * c + breite * s
dy = laenge * s + breite * c
return {"cx": x, "cy": y, "cz": z, "dx": dx, "dy": dy, "dz": hoehe}
# 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
@@ -930,7 +980,13 @@ def compute_kreisel_touch_switches(items, tolerance_mm, durchmesser_mm=800.0,
Rueckgabe: Liste von dicts (in items-Reihenfolge der Paare), je erzeugte
Weiche:
{"x","y","z": Beruehrpunkt (mm),
{"key": "WEICHE#<teileid_a>#<teileid_b>" - stabiler Schluessel, ueber den
compute_kreisel_umlauf die Weiche in BEIDE beruehrenden Kreisel-
Umlaeufe einhaengt (die Weiche gehoert als Verzweigungspunkt zu
beiden, anders als ein Separator oder ein AS/ES-Ende) UND
build_kreisel_weiche_items die zugehoerige TeileId nach dem Umlauf-
Aufbau wiederfindet,
"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,
@@ -987,6 +1043,7 @@ def compute_kreisel_touch_switches(items, tolerance_mm, durchmesser_mm=800.0,
dx, dy, dz = _oriented_box_aabb(achse_rot, box_l, box_b, box_h)
switches.append({
"key": f"WEICHE#{id_a}#{id_b}",
"x": tx, "y": ty, "z": tz,
"dx": dx, "dy": dy, "dz": dz,
"nachbarn": [id_a, id_b],
@@ -1285,7 +1342,7 @@ def compute_sep_kette(items, dbg=None):
Jeder Sepliste-Eintrag bekommt eine (innerhalb dieser Kette eindeutige)
Schluessel-ID "<strecke_teileid>#<lfdnr>" - das ist die ID, mit der
build_strecke_schleus_items/build_separator_kette_items die spaeteren
build_strecke_schleus_items/map_separator_kette_items die spaeteren
globalen TeileIds verknuepft (siehe export_csv.py). Vorgaenger/Nachfolger
sind schlicht der vorherige/naechste Eintrag in der Sepliste (die bereits
in Baureihenfolge vorliegt) - kein Rateverfahren noetig.
@@ -1320,13 +1377,23 @@ def compute_sep_kette(items, dbg=None):
return result
def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0, dbg=None):
def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
touch_switches=None, dbg=None):
"""Bringt fuer jeden echten Kreisel (KREISEL_SPLIT_TEILEART) alle dort
liegenden Punkte - frei platzierte Separatoren (per compute_sensor_
zuordnung bereits einer Kreiselhaelfte zugeordnet) UND die AS/ES-Enden
zuordnung bereits einer Kreiselhaelfte zugeordnet), die AS/ES-Enden
aller andockenden VF_n/GF_n-Ketten (aus sep_kette, per BBox-Naehe wie
compute_strecke_kreisel_schleus zugeordnet) - in eine geschlossene
Umlauf-Reihenfolge (zirkulaer, siehe DREHRICHTUNG-Attribut).
compute_strecke_kreisel_schleus zugeordnet) UND die Kreisel-Kreisel-
Weichen (touch_switches, siehe compute_kreisel_touch_switches) - in eine
geschlossene Umlauf-Reihenfolge (zirkulaer, siehe DREHRICHTUNG-Attribut).
Eine Kreisel-Kreisel-Weiche liegt (anders als ein Separator oder ein
AS/ES-Ende) auf GENAU EINEM Punkt, gehoert aber zu ZWEI Kreiseln - man
kann von Kreisel A zu Kreisel B UND umgekehrt wechseln. Sie wird darum
in punkte_je_kreisel BEIDER beruehrender Kreisel eingetragen (gleiche
Weltposition, gleicher "key" - siehe touch_switches), damit sie im
Umlauf jedes der beiden Kreisel zwischen den jeweils angrenzenden
Separatoren/AS-ES-Enden auftaucht statt spurlos zu fehlen.
Winkel wird um den Kapsel-Mittelpunkt (Mitte AN8-/SP8-Achse, siehe
_kreisel_capsule) gemessen, 0 Grad = Kapsel-Achsrichtung. UZS
@@ -1336,12 +1403,15 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
items = dieselbe Liste wie bei compute_neighbor_ids/compute_sensor_
zuordnung. sep_kette = Rueckgabe von compute_sep_kette (fuer die AS/ES-
Punkte: x/y + strecke_id).
Punkte: x/y + strecke_id). touch_switches = Rueckgabe von
compute_kreisel_touch_switches (None/leer = wie bisher ohne Weichen im
Umlauf).
Rueckgabe: dict {kreisel_teileid: [(punkt_key, vorgaenger_key,
nachfolger_key), ...]} in Umlaufreihenfolge - punkt_key ist entweder eine
Separator-TeileId (freie Separatoren) oder ein sep_kette-Schluessel
(AS/ES-Enden). Kreisel ohne zugeordnete Punkte liefern eine leere Liste.
nachfolger_key), ...]} in Umlaufreihenfolge - punkt_key ist eine
Separator-TeileId (freie Separatoren), ein sep_kette-Schluessel (AS/ES-
Enden) oder ein touch_switches-"key" (Kreisel-Kreisel-Weiche). Kreisel
ohne zugeordnete Punkte liefern eine leere Liste.
"""
half_tol = tolerance_mm / 2.0
kreisel = {}
@@ -1355,13 +1425,27 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
# bestimmbar, wie compute_sensor_zuordnung es fuer die "Zuordnung"-Spalte
# tut - hier direkt gegen die volle Kreisel-BBox statt der Haelften, weil
# nur "gehoert zu diesem Kreisel" gebraucht wird, nicht links/rechts).
#
# Achsparallele Welt-AABB der Kapsel (a/b + Radius je Achse) statt einer
# Box mit fest "dx=durchmesser+achs_len, dy=durchmesser" - letzteres
# nimmt STILLSCHWEIGEND an, die Kreiselachse liegt entlang Welt-X. Bei
# einem um 90 Grad gedrehten Kreisel (Achse entlang Welt-Y, z.B. ein
# 20m-Kreisel laengs der Halle) drehte sich damit auch die Box um 90 Grad
# gegen die tatsaechliche Geometrie - schmal in Y (dort, wo der Kreisel in
# Wahrheit 20m lang ist) und ueberbreit in X (dort, wo er nur
# durchmesser_mm misst). Ergebnis: entfernte Punkte wurden faelschlich
# als "auf dem Kreisel" erkannt, waehrend tatsaechlich anliegende
# Separatoren durchfielen (realer Bug-Fall: Mubea, Kreisel 0002).
kreisel_bounds = {}
for tid, (cap, _dr) in kreisel.items():
acx = (cap["a"][0] + cap["b"][0]) / 2.0
acy = (cap["a"][1] + cap["b"][1]) / 2.0
achs_len = math.hypot(cap["b"][0] - cap["a"][0], cap["b"][1] - cap["a"][1])
r = cap["r"]
ax, ay = cap["a"]
bx, by = cap["b"]
minx, maxx = min(ax, bx) - r, max(ax, bx) + r
miny, maxy = min(ay, by) - r, max(ay, by) + r
kreisel_bounds[tid] = _bounds(
{"cx": acx, "cy": acy, "dx": durchmesser_mm + achs_len, "dy": durchmesser_mm},
{"cx": (minx + maxx) / 2.0, "cy": (miny + maxy) / 2.0,
"dx": maxx - minx, "dy": maxy - miny},
half_tol)
punkte_je_kreisel = {tid: [] for tid in kreisel}
@@ -1395,6 +1479,15 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
if best_tid is not None:
punkte_je_kreisel[best_tid].append((key, eintrag["x"], eintrag["y"]))
# Kreisel-Kreisel-Weichen: die beiden beruehrenden Kreisel-IDs stehen
# bereits in sw["nachbarn"] (siehe compute_kreisel_touch_switches) - kein
# Naeherungsverfahren wie bei Separator/AS/ES noetig, die Weiche wird in
# BEIDE Umlaeufe eingetragen.
for sw in (touch_switches or []):
for tid in sw.get("nachbarn", []):
if tid in punkte_je_kreisel:
punkte_je_kreisel[tid].append((sw["key"], sw["x"], sw["y"]))
result = {}
for tid, punkte in punkte_je_kreisel.items():
cap, drehrichtung = kreisel[tid]