diff --git a/doc/csv-export.md b/doc/csv-export.md new file mode 100644 index 0000000..119f38b --- /dev/null +++ b/doc/csv-export.md @@ -0,0 +1,287 @@ +# CSV-Export (EXPORTCSV) — Ablauf und Architektur + +Beschreibt den kompletten Weg vom `EXPORTCSV`-Befehl in BricsCAD bis zur +fertigen CSV: welche Daten LISP sammelt, was Python daraus berechnet, und wie +die "Nachbarn"-Spalte zustande kommt. Stand: `Lisp/export.lsp`, +`lib/export_csv.py`, `lib/export_neighbors.py`. + +## Ueberblick: zwei Phasen, eine JSON-Datei dazwischen + +``` +BricsCAD (LISP) Python +───────────────── ────── +c:EXPORTCSV + -> csv:run-export + -> Proxies fuer verpackte + Separatoren einfuegen + -> IDs vergeben (ssg-id-check-all) + -> Separator-/Scanner-Zuordnung + (count_sep_scan.lsp) + -> REGENALL + -> csv:collect-export-blocks export_raw.json lib/export_csv.py::main + -> je Block csv:block-to-json ───────────────> -> process_blocks + -> Nachbarschaft/ + Umlauf/Zuordnung + -> CSV schreiben +``` + +LISP sammelt reine Zeichnungsdaten (Position, Rotation, Attribute, +Bounding-Box, Anschluss-Koordinatensysteme) und schreibt sie als flaches +JSON-Array (`results/export_raw.json`). Python bekommt keine BricsCAD-API zur +Verfuegung — es rechnet ausschliesslich mit den Zahlen aus diesem JSON. Alles, +was "Nachbar X kennt Nachbar Y" bedeutet, wird also in Python aus Geometrie +(Bounding-Box-Ueberschneidung, Distanz, Winkel) oder aus mitgelieferten +Reihenfolge-Daten (sepliste, siehe unten) rekonstruiert — LISP selbst kennt +diese Beziehungen beim Bauen der Einzelketten nicht. + +## Phase 1 (LISP): `csv:run-export` — was vor dem JSON passiert + +Datei: `Lisp/export.lsp`. `c:EXPORTCSV` ruft `csv:run-export` mit +`include-bbox=T` auf (im Unterschied zu `c:EXPORTSIVAS`, das keine +Bounding-Boxen braucht). + +1. **`csv:sep-proxies-erzeugen`** — Separator_SP-Symbole, die beim + Zusammenbau von `KREISEL_n`/`ECKRAD_n`/`VF_n`/`GF_n` in die + Compound-Blockdefinition verpackt wurden, sind fuer `(ssget "X" ...)` + unsichtbar. Diese Funktion fuegt fuer jeden Fund eine ECHTE, temporaere + Kopie als top-level INSERT an derselben Weltposition ein. Die Kopie + durchlaeuft danach ID-Vergabe und Export ganz normal ueber den freien- + Separator-Pfad (Blockname-Muster `Separator_SP*` trifft zu) und bekommt + dadurch eine echte `vla-getboundingbox`-Box, die das verpackte Original nie + haben koennte. Rueckgabe: Liste `(proxy-ename . wrapper-ename)`. +2. **`ssg-id-check-all`** — vergibt fehlende IDs (inkl. der frisch erzeugten + Proxies) und korrigiert Duplikate. Muss NACH den Proxies laufen, sonst + wuerden diese Kopien numerisch nie erfasst. +3. **`csv:sep-proxies-zuordnung-setzen`** — jetzt steht die ID jedes Wrappers + fest: die Proxy-Kopie bekommt `ZUORDNUNG = Wrapper-ID` fest zugewiesen + (nicht geraten) und wird zusaetzlich in `*cs-sep-fix-by-handle*` vermerkt, + damit `count_sep_scan.lsp` diesen Wert spaeter nicht per Geometrie + ueberschreibt. +4. **`omni:update-all-attribs`** — Hoehe/Drehung aller Omniflo-Elemente vor + dem Export aktualisieren. +5. **`count_sep_scan.lsp::cs-zuordnung-lauf`** (nur bei Bedarf, siehe + `cs-zuordnung-noetig-p`) — ordnet frei platzierte Separator/Scanner-Symbole + per Bounding-Box-Ueberschneidung bzw. Distanz einem Kreisel/VF/GF zu und + schreibt `ANZAHL_SEPARATOR`/`ANZAHL_SCANNER` an den Carrier. +6. **`REGENALL`** — erzwingt eine vollstaendige Regeneration der Zeichnung. + Notwendig, weil `vla-getboundingbox` sonst in der Praxis fehlschlagen kann + (realer Fall: ein Export ohne vorheriges REGENALL lieferte fuer ALLE 75 + Bloecke keine Bounding-Box). `csv:get-bbox` ruft zusaetzlich pro Objekt + `vla-update` auf, das allein reichte in diesem Fall nicht aus. +7. **`csv:collect-export-blocks`** — sammelt alle relevanten top-level + INSERTs (Kreisel, Omniflo, VF/GF-Wrapper, Separator/Scanner, + BTMT-Stationen) ueber die Blockname-Muster aus `cfg/export.cfg` + `[blockpattern]`. +8. **`csv:block-to-json`** je Fund — siehe naechster Abschnitt. + +### `csv:block-to-json` — Inhalt eines JSON-Eintrags + +Pro INSERT wird ein JSON-Objekt geschrieben: + +| Feld | Quelle | Bemerkung | +|---|---|---| +| `block_name`, `layer`, `handle` | direkt vom INSERT | | +| `x`, `y`, `z`, `rotation` | `(assoc 10)` / `(assoc 50)` | roher Einfuegepunkt/-winkel, KEIN `vla-getboundingbox` | +| `attribs` | alle ATTRIB-Werte | | +| `insertpoint` | `csv:kos-encode` | Position+Rotation als 24-Zeichen-Base64 (Quaternion) | +| `k1`..`k4` | `csv:get-k-kos-strings` bzw. Fallbacks | Anschluss-Koordinatensysteme, siehe unten | +| `bbox` | `csv:get-bbox` (`vla-getboundingbox`) | NUR wenn `include-bbox=T` und der Aufruf gelingt; fehlt sonst komplett im JSON | +| `warnung` | `*cs-scanner-warnung-by-handle*` | strittige Scanner-Zuordnung aus `cs-zuordnung-lauf` | +| `zuordnung_fix` | `*cs-sep-fix-by-handle*` | nur bei Proxy-Kopien: die feststehende Wrapper-ID | +| `sepliste` | `ssg-sepliste-xdata-lesen` | nur bei VF_n/GF_n, siehe eigener Abschnitt | + +**K1-K4 je Blocktyp:** normale ILS-Bloecke fuehren echte K1-K4-Sub-Bloecke. +Omniflo-Geraden (`AP110*`) haben keine echten K-Bloecke — K1/K2 werden aus +Laenge+Rotation synthetisiert (`csv:gerade-k-kos-strings`). VF_n/GF_n leiten +K1 (Eingang) und K2 (Ausgang) aus den eingebetteten AS_Element/ES_Element ab +(`csv:vfgf-k-kos-strings`), damit die Kreisel<->Strecke-Kollisionspruefung in +Python mit einer kleinen festen Box an der AS/ES-Position arbeiten kann statt +mit der ganzen (oft sehr langen) Wrapper-Box. + +**Die `bbox` ist das einzige Feld, das `vla-getboundingbox` braucht** — alle +anderen Felder kommen aus rohen DXF-Gruppencodes oder XDATA. Ein Ausfall von +`vla-getboundingbox` zeigt sich also NICHT im ganzen JSON-Eintrag, sondern nur +im fehlenden `bbox`-Objekt. `csv:get-bbox` protokolliert einen Fehlschlag seit +Kurzem in `*csv-bbox-fail-count*`/`*csv-bbox-fail-first*`; `csv:run-export` +meldet nach dem JSON-Schreiben eine Warnung (`exp-bbox-fail`), falls das +vorkam. + +### Die `sepliste` — Reihenfolge-Information einer VF_n/GF_n-Kette + +Waehrend eine Gefaellestrecke (`GEFAELLESTRECKE`) oder ein VarioFoerderer +(`FOERDERANLAGE`) gebaut wird, sammelt `ssg-sepliste-sammeln` +(`Lisp/ssg_ks_insert.lsp`) alle currently frisch erzeugten `AS_Element_*`-, +`Staustrecke_Separator_SP_300_mm*`- und `ES_Element_*`-Sub-INSERTs in +Baureihenfolge und schreibt sie als XDATA (App `SSG_VF_SEP`/`SSG_GF_SEP`) auf +den fertigen Wrapper: `[(typ lfdnr x y), ...]` mit `typ` = `"AS"`/`"SEP"`/ +`"ES"`, `lfdnr` 1-basiert. Das ist reine Bauabfolge, keine Weltkoordinate im +strengen Sinn — je nach Bauphase koennen einzelne Eintraege noch im +Baukoordinatensystem stehen, bevor die Kette an ihre finale Position +verschoben wurde (siehe Abschnitt "sepliste-Koordinaten" unten). + +Die sepliste beschreibt NUR die Reihenfolge innerhalb einer Kette +(AS -> Sep1 -> ... -> SepN -> ES). Sie sagt nichts darueber, an welchen +Kreisel die Kette andockt — das ergibt sich erst aus der geometrischen Lage +in der fertigen Zeichnung (siehe Python-Teil). + +**Wichtig:** die in einer Kette verpackten Separatoren sind KEIN doppeltes +Konzept zur sepliste — es sind dieselben physischen Symbole, nur aus zwei +Blickwinkeln: `csv:sep-proxies-erzeugen` liefert sie als echte, exportierbare +INSERT-Kopie (mit Bounding-Box und ID); die sepliste liefert dieselben +Separatoren als Eintraege mit Vorgaenger-/Nachfolger-Wissen. Python fuehrt +beides zusammen (siehe `map_separator_kette_items` unten) statt sie doppelt +zu zaehlen. + +## Phase 2 (Python): `lib/export_csv.py::process_blocks` + +Aufruf: `python export_csv.py `. +`main()` laedt das JSON, den Omniflo-Katalog (Boegen/Weichen) und ruft +`process_blocks(blocks, lookup)` auf, das die eigentliche Item-Liste baut. + +### 1. Blöcke klassifizieren + +Eine grosse Schleife über alle rohen JSON-Blöcke ordnet jeden Block anhand +der `cfg/export.cfg [blockpattern]`-Muster einer TeileArt zu (Kreisel, +Eckrad, Omniflo Bogen/Weiche/Gerade, VF-Strecke, Gefaellestrecke, +Strecke-Modul, Separator, Scanner, BTMT Be-/Entladung) und baut daraus ein +Item-dict mit `teileart`, `teileid`, `bezeichnung`, `planquadrat`, +`merkmale` und den bbox-/KOS-Spalten (`bbox_columns`). VF_n/GF_n-Items +bekommen dabei zusaetzlich `x`/`y` (Kettenanfang, fuer die Schleuselement- +Berechnung) und `sepliste` (fuer die Ketten-Nachbarschaft) durchgereicht. + +Separator/Scanner-Items ohne Bounding-Box (weil `vla-getboundingbox` +scheiterte) bekommen einen Fallback: `_fallback_separator_bbox` baut aus der +festen Symbol-Groesse (`cfg/export.cfg [Boundingbox] separator_box_*`, +Default 210x150x14mm) und der bekannten Position/Rotation eine synthetische +`_bbox`, damit der Separator trotzdem an der Nachbarschaftserkennung +teilnimmt. + +### 2. `compute_neighbor_ids` — die generische Bounding-Box-Nachbarschaft + +Datei `lib/export_neighbors.py`. Testet drei Gruppen gegeneinander (NICHT +alle Elemente gemeinsam — das waere bei grossen Omniflo-Anlagen zu teuer bzw. +fachlich falsch): + +1. Kreisel/Eckrad gegen Kreisel/Eckrad. Ein echter Kreisel wird dafuer in + eine linke und rechte Haelfte gesplittet (`kreisel_half_bboxes`) — die + Trennlinie ist die Achse durch Antriebs- und Spannstation. +2. Kreisel/Eckrad gegen Gefaellestrecke/Foerderer/Strecke-Modul. Fuer + GF/VF wird dabei NICHT die grosse Wrapper-Box getestet, sondern je eine + kleine feste Box an der AS- (K1) und ES-Position (K2) — nur dort beruehrt + eine Strecke tatsaechlich einen Kreisel. +3. Omniflo-Elemente gegen Omniflo-Elemente (Bogen/Weiche/Gerade), per + STRtree-Broad-Phase + KOS-Verfeinerung ueber K1-K4. + +Ergebnis ist die rohe `Nachbarn`-Spalte fuer Kreisel/Eckrad/GF/VF (fuer +Separator/Scanner bewusst NICHT — die werden unten anders behandelt) sowie +die `Fehler`-Spalte (`compute_neighbor_errors`: "unverbunden"/"nur ein +Partner", je nach Mindestanzahl Partner pro TeileArt). + +### 3. Synthetische Zeilen an den Uebergangsstellen + +Drei Arten von "Zeilen ohne eigene Zeichnungsgeometrie" werden aus der +Geometrie der bereits klassifizierten Items abgeleitet: + +- **`compute_kreisel_touch_switches`** — praezise Kapsel-Geometrie (nicht nur + Bounding-Box) findet jeden Punkt, an dem sich zwei echte Kreisel wirklich + beruehren, und erzeugt dort eine `"ILS Weiche"`-Zeile + (`build_kreisel_weiche_items`). Das ist der Verzweigungspunkt, an dem + Material von einem Kreisel zum anderen wechseln kann. +- **`compute_strecke_kreisel_schleus`** — an jedem Streckenende (AS/ES, + ueber K1/K2 bzw. als Fallback die Wrapper-Enden), das einen Kreisel + beruehrt (siehe Schritt 2), entsteht ein `"ILS Ausschleuselement"` + (Anfang) bzw. `"ILS Einschleuselement"` (Ende), leicht zum Kreisel hin + versetzt (`build_strecke_schleus_items`). +- **`map_separator_kette_items`** — ordnet jedem `"SEP"`-Eintrag einer + sepliste das dazu passende, bereits vorhandene Separator-Item zu (siehe + naechster Abschnitt). Erzeugt bewusst KEINE eigenen Zeilen. + +### 4. Der Kreisel-Umlauf — wie Nachbarn ueber einen Kreisel hinweg entstehen + +`compute_kreisel_umlauf` ist der zentrale Baustein, der Ketten-Enden, +freie Separatoren und Kreisel-Kreisel-Weichen zu einer widerspruchsfreien +Nachbarschaft zusammenfuehrt. Fuer jeden echten Kreisel werden alle Punkte +gesammelt, die dort geometrisch liegen: + +- freie Separatoren, deren Bounding-Box die Kreisel-Box ueberschneidet, +- die Kreisel-Kreisel-Weichen aus Schritt 3 (in BEIDEN beteiligten + Kreisel-Umlaeufen, da eine Weiche der gemeinsame Verzweigungspunkt ist — + man kann von Kreisel A nach B und zurueck), +- die AS/ES-Enden andockender VF_n/GF_n-Ketten (aus der `sepliste`, + zugeordnet zum geometrisch naechstgelegenen Kreisel). + +Alle Punkte werden nach Winkel um die Kreiselachse sortiert (Richtung ueber +das `DREHRICHTUNG`-Attribut, UZS/GUZ) — das ergibt eine geschlossene, +zirkulaere Reihenfolge. Vorgaenger/Nachfolger in dieser Reihenfolge werden +zu `Nachbarn`. + +Die Matching-Box eines Kreisels ist dabei die tatsaechliche, um die Drehung +korrigierte Kapsel-Bounding-Box (nicht "Laenge entlang Welt-X" angenommen) — +ein 90 Grad gedrehter, langer Kreisel haette sonst eine falsch orientierte +(zu breite/zu schmale) Box und faengt Punkte faelschlich ein oder verpasst +echte Nachbarn. + +### 5. Ketteninterne Nachbarn — `compute_sep_kette` + Aufloesung + +`compute_sep_kette` liest jede `sepliste` und baut daraus einen Schluessel +je Eintrag (`"#"`) mit `vorgaenger`/`nachfolger` +(wieder solche Schluessel, `None` am Kettenende). Das ist eine reine +Uebersetzung der LISP-Baureihenfolge — kein Rateverfahren. + +**Warum die Zeilen erst NACH allen Schluesseln aufgeloest werden:** ein +Schluessel kann auf eine Zeile zeigen, die zum Zeitpunkt seiner Vergabe noch +gar nicht existiert (eine SEP in der Mitte einer Kette hat ihre AS als +Vorgaenger, aber die AS-Zeile entsteht erst in `build_strecke_schleus_items`, +das VOR der SEP-Zuordnung laeuft). Darum sammeln +`build_strecke_schleus_items` und `map_separator_kette_items` ihre Treffer +zunaechst nur in einem gemeinsamen `sep_kette_by_key`-dict +(Schluessel -> Item), OHNE die `Nachbarn`-Spalte zu setzen. +`resolve_umlauf_und_sepliste_nachbarn` loest danach in einem einzigen Schritt +sowohl die Kreisel-Umlauf-Punkte als auch die sepliste-Schluessel gegen einen +gemeinsamen Lookup (freie Separatoren nach TeileId, Weichen nach Schluessel, +sepliste-Zeilen nach Schluessel) zu echten TeileIds auf. + +**`map_separator_kette_items` erzeugt keine neuen Zeilen** (Architektur- +entscheidung, siehe Docstring in `lib/export_csv.py`): der verpackte +Separator liegt durch `csv:sep-proxies-erzeugen` (Phase 1, Schritt 1) bereits +als echtes Separator-Item vor. Eine zweite, aus der sepliste erzeugte Zeile +waere derselbe physische Separator ein zweites Mal. Die Zuordnung +sepliste-Eintrag -> vorhandenes Item laeuft ueber die Wrapper-ID (Item- +Merkmal `"Zuordnung"`, von `csv:sep-proxies-zuordnung-setzen` fest gesetzt) +plus den naechstgelegenen rohen INSERT-Punkt (Item-Felder `_x`/`_y` vs. die +sepliste-Koordinate) — beide stammen aus derselben Bauphase und liegen daher +nah beieinander, auch wenn sie nicht exakt uebereinstimmen. + +### 6. Sensor-Zuordnung, Ausgabe + +`compute_sensor_zuordnung` bestimmt fuer jeden Separator/Scanner ohne +bekannte (fixierte) Zuordnung das Traegerelement (Gefaellestrecke > Foerderer +> Kreiselhaelfte, per Bounding-Box). `compute_scanner_nearest_separator` +haengt jedem Scanner den raeumlich naechsten Separator an. Am Ende werden +alle Items nach TeileId sortiert und als CSV-Zeilen ausgegeben +(`format_csv_line`). + +## Warum eine Zeile keine Nachbarn hat — Diagnose-Leitfaden + +| Symptom | Wahrscheinlichste Ursache | +|---|---| +| ALLE Zeilen ohne Nachbarn, auch Kreisel | `bbox`-Feld fehlt im JSON komplett (`vla-getboundingbox` scheiterte global) — pruefen, ob `csv:run-export` eine `exp-bbox-fail`-Warnung ausgegeben hat | +| Nur GF/VF ohne Nachbarn, Separatoren teils auch | `sepliste` wurde nicht ans Item durchgereicht, oder die Strecke beruehrt geometrisch keinen Kreisel (`Fehler = "unverbunden"`) | +| Ein Separator/Scanner ohne Nachbarn, Rest ok | Separator ist weder Teil einer sepliste-Kette noch nah genug an einem Kreisel (freier, isoliert platzierter Sensor) — legitimer Fall, keine Fehlkonfiguration | +| Zwei sich beruehrende Kreisel haben falsche/keine Nachbarn am Beruehrpunkt | Pruefen, ob `compute_kreisel_touch_switches` ueberhaupt eine Weiche erzeugt (Toleranz `[Nachbarschaft] toleranz_mm`) und ob sie im Kreisel-Umlauf beider Kreisel auftaucht | +| `Nachbarn` enthaelt einen Wert wie `"0026#3"` statt einer TeileId | Regression im Aufloese-Schritt — ein sep_kette-Schluessel wurde nicht durch `resolve_umlauf_und_sepliste_nachbarn` ersetzt (sollte nicht mehr vorkommen, siehe Tests `test_export_kreisel_umlauf.py`) | + +Das Kollisions-/Nachbarschafts-Debugprotokoll (`cfg/export.cfg` +`[Nachbarschaft] debug_log=1`) schreibt eine `.dbg`-Datei mit jeder +Klassifikation, Gruppenzuordnung und jedem Ueberschneidungstest — der +schnellste Weg, einer fehlenden Nachbarschaft auf den Grund zu gehen. + +## Relevante Tests + +- `tests/test_export_kreisel_umlauf.py` — Kreisel-Kreisel-Weiche im Umlauf, + rotationsunabhaengige Kreisel-Matching-Box, sepliste-Nachbarn-Aufloesung + ohne rohe Schluessel in der Ausgabe. +- `tests/test_export_separator_bbox.py` — feste Separator-Symbol-Box + (Geometrie + Fallback bei fehlender `vla-getboundingbox`-Box). + +Beide laufen komplett ohne BricsCAD (reine Python-Geometrie/-Logik).