Files
dxfmakros/doc/csv-export.md
T

288 lines
16 KiB
Markdown

# 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 <export_raw.json> <data_dir> <output.csv>`.
`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 (`"<strecke_id>#<lfdnr>"`) 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).