Files
dxfmakros/tests/test_export_kreisel_umlauf.py
T
m.stangl 27ae3df056 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>
2026-09-10 09:41:02 +02:00

283 lines
13 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
test_export_kreisel_umlauf.py - Kreisel-Kreisel-Weiche im Umlauf.
Laeuft komplett ohne BricsCAD (reine Python-Geometrie, siehe lib/export_neighbors.py).
Regressionstest fuer einen realen Mubea-Befund: zwei sich beruehrende Kreisel
erzeugen eine "ILS Weiche" am Beruehrpunkt (compute_kreisel_touch_switches).
Diese Weiche ist der Verzweigungspunkt zwischen beiden Kreiseln - ein
Separator direkt davor muss sie als Nachbarn bekommen, nicht (wie vor dem
Fix) einen Punkt vom jeweils ANDEREN Kreisel-Ende, weil die Weiche in
compute_kreisel_umlauf schlicht fehlte.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
from export_neighbors import ( # noqa: E402
compute_kreisel_touch_switches,
compute_kreisel_umlauf,
)
from export_csv import ( # noqa: E402
build_strecke_schleus_items,
map_separator_kette_items,
resolve_umlauf_und_sepliste_nachbarn,
)
DURCHMESSER_MM = 800.0
RADIUS_MM = DURCHMESSER_MM / 2.0
def _kreisel(teileid, x0, y0, rotation, abstand=1000.0):
return {
"teileart": "ILS 2.0 Kreisel",
"teileid": teileid,
"x": x0, "y": y0, "z": 0.0,
"rotation": rotation,
"attribs": {"ABSTAND": str(abstand), "DREHRICHTUNG": "UZS"},
"_bbox": {"cx": x0, "cy": y0, "cz": 0.0,
"dx": abstand + DURCHMESSER_MM, "dy": DURCHMESSER_MM},
}
def _separator(teileid, x, y):
return {
"teileart": "ILS 2.0 Separator",
"teileid": teileid,
"_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 150.0, "dy": 100.0},
}
def _zwei_beruehrende_kreisel():
"""Zwei Kreisel, deren AN8-Enden sich exakt beruehren (End-an-End, wie im
Mubea-Fall: 'Kreis/Kreis'-Beruehrung an der jeweiligen AN8-Kappe).
Kreisel 1 liegt entlang +X ab x=0 (AN8-Mitte bei radius=400, SP8-Mitte bei
radius+abstand=1400). Kreisel 2 liegt entlang -X ab x=800 (AN8-Mitte bei
800-400=400) - beide AN8-Kappen beruehren sich bei x=400 (Kreis/Kreis).
"""
k1 = _kreisel("0001", 0.0, 0.0, 0.0)
k2 = _kreisel("0002", 800.0, 0.0, 180.0)
return [k1, k2]
class TestKreiselKreiselWeicheImUmlauf:
def test_touch_switch_erkannt(self):
items = _zwei_beruehrende_kreisel()
switches = compute_kreisel_touch_switches(items, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
assert len(switches) == 1
sw = switches[0]
assert sorted(sw["nachbarn"]) == ["0001", "0002"]
assert sw["key"] == "WEICHE#0001#0002"
def test_weiche_in_beiden_umlaeufen(self):
"""Die Weiche muss als Punkt in BEIDEN Kreisel-Umlaeufen auftauchen -
sie ist der gemeinsame Verzweigungspunkt (K1->K2 und K2->K1)."""
items = _zwei_beruehrende_kreisel()
switches = compute_kreisel_touch_switches(items, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM,
touch_switches=switches)
weiche_key = switches[0]["key"]
for tid in ("0001", "0002"):
keys = [p[0] for p in umlauf[tid]]
assert weiche_key in keys, f"Weiche fehlt im Umlauf von Kreisel {tid}"
def test_separator_bekommt_weiche_als_nachbar_nicht_anderen_kreisel(self):
"""Realer Mubea-Befund: ein Separator, der unmittelbar VOR der
Kreisel-Kreisel-Weiche liegt (auf der AN8-Seite von Kreisel 1), muss
die Weiche als Umlauf-Nachbarn bekommen - nicht einen Punkt, der
eigentlich zu Kreisel 2 gehoert (das war der Bug: die Weiche fehlte
in punkte_je_kreisel, wodurch beide Separatoren am Beruehrpunkt auf
denselben (falschen) Nachbarn verwiesen)."""
items = _zwei_beruehrende_kreisel()
# Separator dicht vor der Beruehrung (x=390), gehoert zu Kreisel 1.
sep = _separator("0036", 390.0, 0.0)
items.append(sep)
switches = compute_kreisel_touch_switches(items, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
assert len(switches) == 1
weiche_key = switches[0]["key"]
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=50.0,
durchmesser_mm=DURCHMESSER_MM,
touch_switches=switches)
punkte_k1 = {p[0]: (p[1], p[2]) for p in umlauf["0001"]}
assert "0036" in punkte_k1, "Separator 0036 fehlt im Umlauf von Kreisel 1"
vorgaenger, nachfolger = punkte_k1["0036"]
assert weiche_key in (vorgaenger, nachfolger), (
f"Separator 0036 sollte die Kreisel-Kreisel-Weiche ({weiche_key}) "
f"als Umlauf-Nachbarn haben, hat aber vorgaenger={vorgaenger!r}, "
f"nachfolger={nachfolger!r}")
class TestKreiselBoundsRotationsUnabhaengig:
"""Regressionstest fuer einen zweiten realen Mubea-Befund: ein um 90 Grad
gedrehter, sehr langer Kreisel (Achse entlang Welt-Y statt Welt-X) bekam
in compute_kreisel_umlauf eine falsch orientierte Matching-Box (schmal in
Y, ueberbreit in X statt umgekehrt) - kreisel_bounds baute die Box bisher
IMMER mit dx=durchmesser+achs_len/dy=durchmesser, unabhaengig von der
tatsaechlichen Drehung. Echte anliegende Separatoren (weit entfernt in X,
aber auf der Kreiselachse in Y) fielen dadurch durch, waehrend entfernte,
zufaellig in der ueberbreiten X-Box liegende Separatoren faelschlich als
Kreisel-Nachbarn erkannt wurden (Mubea Kreisel 0002, 20m lang, Drehung 90)."""
def _langer_kreisel_90grad(self):
# Kreisel 0002 aus dem Mubea-Datensatz: x=0, y=4529.5, Drehung 90,
# ABSTAND=20020mm -> Achse verlaeuft entlang Welt-Y von y~4930 bis
# y~24950, nicht entlang Welt-X.
return _kreisel("0002", 0.0, 4529.5, 90.0, abstand=20020.0)
def test_separator_auf_der_gedrehten_achse_wird_erkannt(self):
"""Ein Separator, der WIRKLICH auf der (gedrehten) Kreiselachse liegt
(x nahe 0, y zwischen AN8- und SP8-Mitte), muss erkannt werden."""
k = self._langer_kreisel_90grad()
sep = _separator("9001", 0.0, 10000.0) # liegt mitten auf der Achse
items = [k, sep]
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=200.0,
durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in umlauf["0002"]]
assert "9001" in keys, "Separator auf der gedrehten Kreiselachse wurde nicht erkannt"
def test_separator_weit_neben_der_achse_wird_nicht_erkannt(self):
"""Ein Separator, der (wie im Mubea-Fall Separator 0065/0066) 958mm
seitlich der schmalen Kreiselbreite (durchmesser=800mm, Radius 400mm)
liegt, gehoert NICHT zum Kreisel - er darf nicht ueber eine faelschlich
breite X-Box eingefangen werden."""
k = self._langer_kreisel_90grad()
sep = _separator("0065", 958.0, 5100.0)
items = [k, sep]
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=200.0,
durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in umlauf["0002"]]
assert "0065" not in keys, (
"Separator 958mm neben der Kreiselachse wurde faelschlich als "
"Kreisel-Nachbar erkannt (rotationsabhaengiger BBox-Bug)")
class TestSeplisteNachbarnAufgeloest:
"""Regressionstest fuer einen dritten realen Mubea-Befund: die meisten
Separatoren blieben trotz sepliste-XDATA komplett ohne "Nachbarn".
Architektur (nach dem Umbau "Proxy liefert Zeilen, sepliste nur
Reihenfolge"): die in einer Kette verpackten Separatoren liegen bereits als
echte (Proxy-)Separator_SP-Items vor (der LISP-Export fuegt je verpacktem
Symbol eine temporaere Kopie ein). map_separator_kette_items erzeugt daher
KEINE neuen Zeilen mehr, sondern ordnet jedem sepliste-SEP das vorhandene
Separator-Item zu (Match ueber Wrapper-ID + INSERT-Punkt) und traegt es in
sep_kette_by_key ein; die AS/ES-Zeilen kommen weiter aus
build_strecke_schleus_items. resolve_umlauf_und_sepliste_nachbarn setzt
dann die Nachbarn als ECHTE TeileIds (nicht die rohen sep_kette-Schluessel,
frueherer Bug)."""
def _sepliste_kette(self):
"""Ein Kreisel + eine Gefaellestrecke mit sepliste AS(1) -> SEP(2) ->
ES(3) PLUS das bereits vorhandene Proxy-Separator-Item fuer den SEP
(wie es der freie-Separator-Pfad in process_blocks erzeugt: TeileId,
Merkmal "Zuordnung" = Wrapper-ID, INSERT-Punkt _x/_y an der sepliste-
SEP-Koordinate). "nachbarn" auf der Strecke simuliert das Ergebnis von
compute_neighbor_ids (BBox-Ueberschneidung Strecke <-> Kreisel) - die
Vorbedingung, unter der compute_strecke_kreisel_schleus die AS/ES-
Schleuselemente erzeugt."""
k = _kreisel("0001", 0.0, 0.0, 0.0, abstand=1000.0)
strecke = {
"teileart": "ILS 2.0 Gefaellestrecke",
"teileid": "0010",
"x": 5000.0, "y": 0.0, "z": 0.0,
"nachbarn": "0001",
"_bbox": {"cx": 5000.0, "cy": 0.0, "cz": 0.0, "dx": 8000.0, "dy": 300.0},
"k1": "", "k2": "",
"sepliste": [
{"typ": "AS", "lfdnr": 1, "x": 1500.0, "y": 0.0},
{"typ": "SEP", "lfdnr": 2, "x": 5000.0, "y": 0.0},
{"typ": "ES", "lfdnr": 3, "x": 8500.0, "y": 0.0},
],
}
# Proxy-Separator-Item (bereits vorhanden), Zuordnung = Wrapper-ID 0010,
# INSERT-Punkt an der sepliste-SEP-Koordinate (5000, 0).
proxy_sep = {
"teileart": "ILS 2.0 Separator",
"teileid": "0055",
"merkmale": {"Zuordnung": "0010"},
"_x": 5000.0, "_y": 0.0,
"_bbox": {"cx": 5000.0, "cy": 0.0, "cz": 0.0, "dx": 150.0, "dy": 210.0},
"nachbarn": "",
}
return [k, strecke, proxy_sep]
def test_keine_rohen_sep_kette_schluessel_in_nachbarn(self):
from export_neighbors import compute_sep_kette
items = self._sepliste_kette()
sep_kette = compute_sep_kette(items)
assert set(sep_kette) == {"0010#1", "0010#2", "0010#3"}
touch_switches = []
weiche_by_key = {}
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette, tolerance_mm=50.0, durchmesser_mm=DURCHMESSER_MM,
touch_switches=touch_switches)
sep_kette_by_key = {}
build_strecke_schleus_items(
items, None, 100, sep_kette=sep_kette,
sep_kette_by_key=sep_kette_by_key)
map_separator_kette_items(
items, sep_kette, sep_kette_by_key=sep_kette_by_key)
resolve_umlauf_und_sepliste_nachbarn(
items, kreisel_umlauf, sep_kette, sep_kette_by_key, weiche_by_key)
# AS/ES (neu erzeugt) + der bereits vorhandene Proxy-Separator.
sepliste_items = [it for it in items
if it.get("teileart") in
("ILS 2.0 Separator", "ILS Ausschleuselement", "ILS Einschleuselement")]
assert len(sepliste_items) == 3, "AS/SEP/ES nicht alle vorhanden"
for it in sepliste_items:
for nachbar in it["nachbarn"].split(","):
nachbar = nachbar.strip()
if not nachbar:
continue
assert "#" not in nachbar, (
f'{it["teileart"]} {it["teileid"]}: "Nachbarn" enthaelt einen '
f'unaufgeloesten sep_kette-Schluessel ({nachbar!r}) statt '
f'einer TeileId')
def test_sep_zeile_hat_as_und_es_als_nachbarn(self):
"""Der Proxy-Separator (SEP-Position) muss nach der Aufloesung sowohl die
TeileId der AS- als auch der ES-Zeile als Nachbarn tragen."""
from export_neighbors import compute_sep_kette
items = self._sepliste_kette()
sep_kette = compute_sep_kette(items)
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette, tolerance_mm=50.0, durchmesser_mm=DURCHMESSER_MM,
touch_switches=[])
sep_kette_by_key = {}
build_strecke_schleus_items(
items, None, 100, sep_kette=sep_kette,
sep_kette_by_key=sep_kette_by_key)
map_separator_kette_items(
items, sep_kette, sep_kette_by_key=sep_kette_by_key)
resolve_umlauf_und_sepliste_nachbarn(
items, kreisel_umlauf, sep_kette, sep_kette_by_key, {})
as_item = sep_kette_by_key["0010#1"]
sep_item = sep_kette_by_key["0010#2"]
es_item = sep_kette_by_key["0010#3"]
# Der SEP-Schluessel muss auf das vorhandene Proxy-Separator-Item zeigen.
assert sep_item["teileid"] == "0055", "sepliste-SEP nicht auf Proxy-Separator gemappt"
sep_nachbarn = {n.strip() for n in sep_item["nachbarn"].split(",") if n.strip()}
assert sep_nachbarn == {as_item["teileid"], es_item["teileid"]}, (
f'SEP-Zeile hat Nachbarn {sep_nachbarn!r}, erwartet '
f'{{{as_item["teileid"]!r}, {es_item["teileid"]!r}}}')