diff --git a/CLAUDE.md b/CLAUDE.md index e3808d3..5f2af40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,8 +81,9 @@ Die `start_briscad.bat` ruft `setenv.bat` auf und startet BricsCAD. | Omniflo (komplett) | `OmniModulInsert` + `GeruestInsert` | OMNI_APB_*, OMNI_W*_*, OMNI_TEF_*, OMNI_TV_*, OMNI_LADESCHLEIFE_*, OMNI_EDIT; OMNI_FAHRSTRECKE / OMNI_FAHRSTRECKE_BESCHRIFTEN (GeruestInsert, Linien-Logik wie GERUEST_TRAEGER) | | Geruest | `GeruestInsert` | GERUEST_SAEULEN, GERUEST_ROHRABHAENGER, GERUEST_WANDBEFESTIGUNG (je eigener Dialog aus der Vorlage geruest.dcl, keine Umschaltung), GERUEST_TRAEGER (eigener Dialog), GERUEST_TRAEGER_BESCHRIFTEN (Untermenue Geruest -> Funktionen -> Traeger beschriften; alle Traeger der Zeichnung neu beschriften); Gattertraeger noch Platzhalter auf GERUEST (zeigt nur einen Hinweis) | | Connections | `Connection_Edit` + `SSG_LIB_Commands` | CONNECTION_INSERT, CONNECTION_EDIT, ILS_EINGANG_INSERT, ILS_AUSGANG_INSERT | -| Lager | `LagerInsert` + `LagerPruefung` | LG_GESCHOSS, LG_GESCHOSS_AKTIV, LG_GESCHOSS_KOPIEREN, LG_BEREICH, LG_REIHE, LG_GANG, LG_EDIT, LG_REGALE, LAGER_EXPORT, LG_REGELN | | HW Planung | `HwPlanung` | HW_MATERIALFLUSS (Materialfluss erzeugen), HW_SYMBOLE (Symbole erzeugen, noch Platzhalter) | +| Lager | `LagerInsert` + `LagerPruefung` | LG_GESCHOSS, LG_GESCHOSS_AKTIV, LG_GESCHOSS_KOPIEREN, LG_BEREICH, LG_REIHE, LG_GANG, LG_EDIT, LG_REGALE, LAGER_HAND_YAML, LAGER_EXPORT, LG_REGELN | +| HW Planung | `HwPlanung` | HW_MATERIALFLUSS (Materialfluss erzeugen), HW_SYMBOLE (Symbole erzeugen), HW_NUMMERIEREN (Symbole nummerieren). Einzelbefehle HW_SYM_VENTILE/_SENSOREN/_MOTOREN/_MOTORSCHUTZ/_ENCODER/_SCANNER und HW_SYM_LOESCHEN bewusst NICHT im Menue. Ablauf: `doc/hw_planung.md` | | Export | `export` | EXPORTSIVAS, EXPORTCSV, CALLPYTHON | | Werkzeuge | `ssg_blocktools`, `ssg_texttools`, `ssg_stueckliste`, `DinFormate` | SSG_BLOCK_COUNT, SSG_BLOCK_DELETE, SSG_ATTR_ERSETZEN, SSG_TEXT_ERSETZEN, SSG_TEXT_PARALLEL, SSG_TEXT_HOEHE, SSG_TEXT_STIL, SSG_FAEHNCHEN_STUECKLISTE, DIN_FORMATE | | Konfiguration | `SSG_LIB_Commands` + `DrawPlanquadrate` | SSG_DIM_SWITCH, SSG_SPRACHE, PLANQUADRATE_TOGGLE (DRAW_PLANQUADRATE/DELETE_PLANQUADRATE) | @@ -123,7 +124,7 @@ Detaillierte Dokumentation aller Funktionen und Befehle: siehe `Lisp/README.md`. | `TEFInsert.lsp` | TEF_FOERDERER_LINIE | Linie zwischen zwei Punkten zeichnen und mit 1000-mm-TEF-Geraden (`0_B10030`) belegen; Rest ueber ein zurueckversetztes Element, damit der Foerderer exakt am Linienende endet; geneigte Linien per Rz*Ry wie `ssg_ks_insert.lsp` | | `GeruestInsert.lsp` | GERUEST, GERUEST_SAEULEN, GERUEST_ROHRABHAENGER, GERUEST_WANDBEFESTIGUNG, GERUEST_TRAEGER, GERUEST_TRAEGER_BESCHRIFTEN, OMNI_FAHRSTRECKE, OMNI_FAHRSTRECKE_BESCHRIFTEN | Geruest-Baugruppen aus `data/json/geruest.json` konfigurieren und einfuegen. **Ein Dialog je Bereich**: Rohrabhaenger / Saeulen / Wandbefestigung oeffnen NUR ueber ihren eigenen Menuebefehl (`geruest-starten id`, Dialogtitel aus dem ASCII-Feld `titel`); eine Umschaltung im Dialog gibt es nicht (DCL hat kein Reiter-Tile), `GERUEST` ohne Bereich zeigt nur einen Hinweis. Der Dialogstand beginnt je Aufruf frisch. 1"- und 2"-Saeulen liegen in EINER Baugruppe `Saeulen` mit gemeinsamer Anbauteil-Liste; **`teileart` und `dummy` duerfen je Geometrie-Variante ueberschrieben werden** (`geruest-variante-wert`, Variante gewinnt vor Baugruppe; fuer `dummy` ueber `geruest-dummy-wahl`: fehlt die DWG der Variante, Meldung + Rueckfall auf den Baugruppen-Dummy; ist der Dummy zugleich eine Teile-DWG, wird er per `geruest-ohne-pfad` nicht doppelt eingefuegt) - so liefert `R 1"` weiterhin TEILEART `Geruest Saeule 1 Zoll` und die `R 2"`-Varianten `Geruest Saeule 2 Zoll`, die Groessen bleiben im Export also unterscheidbar. **Das Layout steht als Vorlage in `dcl/geruest.dcl`** (bzw. `dcl/geruest_traeger.dcl`); nur die datenabhaengigen Bloecke - ein `radio_button` je Variante (Key `v`, `geruest-radio-key`/`-index`), Mengenfeld + Ankreuzfeld je Anbauteil - werden zur Laufzeit an Markerzeilen `//@NAME@` eingesetzt (`geruest-dcl-ersetzungen` -> `ssg-dcl-vorlage-fuellen` in ssg_core, Temp-Datei, nach `unload_dialog` geloescht). Grund: DCL kann Tiles weder zur Laufzeit erzeugen noch ausblenden, Radiobutton-Labels lassen sich per `set_tile` nicht aendern, und eine `list_box` kann keine Eingabefelder enthalten. Varianten bewusst als Radiobuttons statt `list_box`: BricsCAD zeichnet die Listenspalte nur halb so breit, und ein `tabs`-Wert passt nur schrift-/skalierungsabhaengig. Inhalt: Basis-Variante aus `geometrie_varianten` (Radiobuttons, siehe oben), **Winkel Platte** (0/90 Grad, `*geruest-winkel-werte*`): Ausrichtung der Anbauteile zur Basis - die Teile-DWGs aus oben/unten/sonstiges werden um diesen Winkel um Z gedreht in den Block eingefuegt, Basis und Dummy bleiben ungedreht (`geruest-teile-dwgs-rot`; in Konfigurationen als echter Wert `winkel_platte`, kein Block-Attribut), Laenge (gegen `attribute.Laenge` min/max geprueft, Retry-Schleife), Farbe (`attribute.Teilefarbe`, Freitext bei `frei_option`) und **eine Befestigungsspalte** "Befestigung oben und unten" mit allen `anbaugruppen.befestigung` - die Anzahl ist die GESAMTanzahl je Teil (oben und unten zusammen; Tile-Keys `b`/`bm`, Zustand `*geruest-dlg-bef*`). Unter jedem Teil (auch in Sonstiges) zeigt eine Textzeile "Art.-Nr. ..." die Artikelnummer(n) (`geruest-artnr-anzeige`: Einzelteil -> `sivas_id`, Gruppe -> eindeutige Nummern der Teile; DCL kennt keine Schriftgroesse, darum normale Groesse in eigener Zeile). `anbaugruppen` ist ein Objekt mit zwei Listen: `befestigung` (Befestigungsspalte) und `sonstiges` (Teile, die weder oben noch unten sitzen, z.B. die Saeulen-Haltearme) - letztere bekommen eine eigene Zeile darunter (nur wenn nicht leer), in ARTINR/BEZEICHNUNG nach "unten" als "sonstiges" (`geruest-anbau-liste`; ein altes flaches Array gilt als `befestigung`). **Zwei Mengenfelder im JSON, bewusst verschieden benannt**: `stueckzahl` = wie oft ein Teil TATSAECHLICH geschrieben wird (je Variante bzw. je Satz/Anbauteil, fehlt = 1), `anzahl_vorgabe` = NUR die Vorbelegung des Anzahl-Feldes im Dialog (Anbaugruppen, fehlt = 1); ein Anbauteil wird Anzahl x `stueckzahl` mal geschrieben, bei einer Gruppe (`teile`) zaehlt die Anzahl die Saetze (`geruest-stueckzahl`/`geruest-anzahl-vorgabe`). In Konfigurationen (`befestigung`/`sonstiges`, dazu `winkel_platte`) heisst der gespeicherte Dialogwert `anzahl`. Aeltere Konfigurationen mit getrennten `oben`/`unten`-Listen laden weiter: deren Mengen werden zur Gesamtanzahl addiert (`geruest-konfig-befestigung`), Sonstiges-Teile darin still in die Sonstiges-Zeile umgehaengt (`geruest-konfig-sonstiges`); beim naechsten Speichern steht alles im neuen Format. Baugruppen ohne `attribute`-Block (Wandbefestigung) bekommen Laenge/Farbe gesperrt, Baugruppen ohne Anbauteile bekommen gar keine Spalten (ein leerer DCL-Cluster ist unzulaessig). Im generierten Label wird `"` zu `''` ersetzt (`geruest-dcl-label`) - fuenf Artikelnamen enthalten ein Anfuehrungszeichen, das das DCL-Label sonst vorzeitig beendet und die Datei zerstoert. **Konfigurationen**: gepflegte Bibliothek je Baugruppe unter `konfigurationen` in `geruest.json` (schreibgeschuetzt) plus eigene in `cfg/geruest_user.json` (gitignored, `geruest-user-schreiben`; getrennte Datei, damit Benutzerdaten und versionierte Bibliothek nicht kollidieren). Eigene Eintraege sind in der Liste mit `*` markiert und nur diese loeschbar. **Geaenderte Konfiguration**: beim Anwenden wird der Dialogstand als Vergleichsbasis gemerkt (`*geruest-dlg-konfig-basis*`); jede Eingabe vergleicht den aktuellen Record damit (`geruest-dlg-aenderung-pruefen`, Vergleich statt Flag - Zuruecknehmen hebt die Markierung wieder auf) und kennzeichnet den Listeneintrag mit "(geaendert)" plus Statuszeile `kstatus`. Speichern bietet bei einer gewaehlten EIGENEN Konfiguration deren Namen als Vorgabe an (Enter = ueberschreiben, `geruest-konfig-name-vorgabe`); Bibliotheks-Konfigurationen brauchen einen neuen Namen. Konfigurationen verweisen ueber den ANZEIGENAMEN auf Variante und Anbauteile; ein unbekannter Name wird gemeldet und uebersprungen, statt die Konfiguration scheitern zu lassen. Speichern/Loeschen laufen ueber `done_dialog 2`/`3` (Dialog schliessen, Aktion, neu zeigen) - `getstring` funktioniert aus einem `action_tile`-Rueckruf heraus nicht. Der Block wird - wie KREISEL_n/VF_n - SELBST zusammengebaut: Dummy-DWG aus `data/geruest` liefert nur Geometrie, das Attribut-Schema kommt aus `*geruest-attrib-defs*` (ID, ARTINR, LAENGE, BEZEICHNUNG, TEILEART, TEILEFARBE, LAYER) und wird per `ssg-attrib-merge` mit den Dummy-Tags zusammengefuehrt - sonst verschluckt `ssg-attrib-set-on` stillschweigend jeden Tag, den die Dummy-DWG nicht als ATTDEF kennt. **Teile-Geometrie**: jede ausgewaehlte Artikelnummer (Basis/oben/unten inkl. aufgeloester Gruppen), fuer die `data/geruest/.dwg` existiert, wird zusaetzlich einmal bei 0,0,0 mit in den Block eingefuegt (`geruest-teile-dwgs`, dedupliziert, fehlende DWGs still uebergangen) - reine visuelle Rueckmeldung, die Attribut-Logik bleibt davon unberuehrt. **TEILEFARBE** ist eine kommagetrennte Liste parallel zu ARTINR (z.B. `,,RAL7030,,RAL7030`): Teile mit `"farbig": true` in `geruest.json` (je Artikel; eine Gruppe vererbt es an ihre Teile, fehlend = false) bekommen die im Dialog gewaehlte Baugruppenfarbe, alle anderen einen leeren Eintrag. ARTINR und TEILEFARBE entstehen aus demselben Durchlauf (`geruest-teile-paare`/`-paare-n`, `geruest-farbliste`), sind also garantiert gleich lang. `export_sivas.py` schreibt daraus je Artikelnummer das Merkmal `{"108": ""}` in `details` (Nummer aus `cfg/export.cfg` `[Sivas] merkmal_teilefarbe`). Blockname `GR__` (Kollisionszaehler wie in OmniModulInsert). **Das `GR_`-Praefix ist nicht kosmetisch**: `SSG_BLOCKEDIT` haelt JEDEN Block mit TEILEART-Attribut fuer ein Omniflo-Element, deshalb steht in `SSG_LIB_Commands.lsp` ein `GR_*`-Zweig VOR dem Omniflo-Zweig. ARTINR listet jede Sivas-ID so oft, wie Anzahl x `stueckzahl` es vorgeben - erst Basis, dann Befestigung, dann Sonstiges; bei einer benannten Gruppe (`teile`) wird das ganze Set n-mal wiederholt, die Mengen INNERHALB des Sets bleiben dabei erhalten (`geruest-artikelnummern-n`). BEZEICHNUNG nennt Position und Menge je Teil ("Befestigung 4x ...", "sonstiges 1x ...") und verkettet mit `" + "`, NICHT mit Komma - die Artikelbezeichnungen enthalten selbst Kommas ("ROHR R 1\" 33,7 x 2,6"). Zuordnung Menue -> JSON laeuft ueber das ASCII-Feld `id`, weil die aeusseren JSON-Schluessel Umlaute bzw. Anfuehrungszeichen tragen. CSV-/Sivas-Export: `pattern_geruest = GR_*` (cfg/export.cfg), `csv:collect-export-blocks`/`ssg-id-collect-blocks` sammeln GR_-Bloecke wie BTMT eigenstaendig; `*geruest-attrib-defs*` traegt zusaetzlich ein leeres `ID`-ATTDEF (Voraussetzung fuer `ssg-id-check-all`, das nur bestehende ATTRIBs beschreiben kann). ARTINR/BEZEICHNUNG/TEILEART/LAENGE landen in export_csv.py/export_sivas.py in festen Spalten, TEILEFARBE/LAYER (+ ARTINR bei export_csv.py) im Merkmale-/details-JSON. **Attribut-Keys in `geruest.json` sind GROSS** (`LAENGE`, `TEILEFARBE`, ...) = die Block-Attribut-Tags; `ssg-json-get` unterscheidet Gross/Klein. **Rohr-Geruest-Traeger** (`GERUEST_TRAEGER`): Traeger-Baugruppen mit Linien-Logik liegen in einer EIGENEN Datei `data/json/geruest_traeger.json` (gleicher Aufbau wie geruest.json, eigener Lader/Cache `geruest-traeger-daten`, Suche `geruest-baugruppe id daten`; gemeinsamer Lader `geruest-json-laden-cache`) - sie tauchen darum nicht im Bereichs-Dropdown von GERUEST auf; dort kann spaeter auch der Gattertraeger hin. eigener Dialog aus der Vorlage `dcl/geruest_traeger.dcl` (Variante als Radiobuttons, JEDE Variante - auch die mit Muffe - mit eigenem Button, Beschriftung "Name (Artikelnummern)" ueber `geruest-varianten-label`; Varianten mit `"disabled": true` erscheinen in allen drei Geruest-Dialogen ausgegraut und nicht waehlbar (`geruest-variante-gesperrt-p`, `geruest-varianten-sperren` per `mode_tile`; Vorauswahl ueber `geruest-variante-waehlbar`/`-erste-freie-variante`; eine Konfiguration mit gesperrter Variante laesst die Auswahl unveraendert und meldet `gr-konfig-variante-gesperrt`) - bleiben aber in den Daten, damit Konfigurationen und Neubeschriften sie weiter ueber den Namen finden; eine Muffen-Checkbox gab es zwischenzeitlich, sie wurde wieder entfernt, weil die Artikelnummer der Muffen-Variante dann im Dialog nicht sichtbar war; Farbe, je eine Checkbox + Feld fuer `LAENGENABZUGANFANG`/`LAENGENABZUGENDE`, Vorgabe `vorgabe`/`aktiv` aus dem attribute-Block, je Variante ueberschreibbar via `geruest-attr-feld`); danach Einfuege-Schleife bis ESC/Enter: Startpunkt, dann Endpunkt ODER getippte Laenge (+ Richtung). Aktive Abzuege ruecken die Linie beidseitig ein (Strecke 1000, Abzug Anfang 20/Ende 35 -> Start 20 mm hinter dem Klick, Linie 945 mm); `LAENGE` = Netto-Laenge, gegen `LAENGE.min/max` geprueft. KEINE Dummy-DWG: die Geometrie ist eine per `entmake` erzeugte LINE (0,0,0)-(L,0,0) auf dem `layer` der Variante, eingefuegt mit dem Richtungswinkel (ANGBASE/ANGDIR/AUNITS dafuer kurz auf 0). **Querstreben + Muffe** als verschachtelte Dummy-DWGs im selben Block, reine Grafik ohne Export: `querstrebe.dummy` (`data/geruest/dummy_Strebe_30x4.dwg`, Basispunkt Strebenmitte, 30 mm laengs X) an jeder Strebenposition, NUR bei Varianten mit ausdruecklichem `"mit_querstrebe": true` (z.B. nicht beim `Rohr 1"`); fehlt die DWG, Rueckfall auf je eine 25-mm-Querlinie an Strebenanfang/-ende (`laenge`/`querlinie`, Standard 30/25 nur im Code). `muffe.dummy` (`dummy_Muffe_1zoll.dwg`, Basispunkt Muffenmitte) am Linienende, NUR bei Varianten mit ausdruecklichem `"mit_muffe": true` (nicht aus dem Namen geraten; bei "mit 2 Muffen" trotzdem nur eine am Ende); `querstrebe`/`muffe` per Variante ueberschreibbar (`geruest-variante-wert`). Dummys werden ohne Drehung eingefuegt (Traeger laeuft im Block entlang +X), ab dem zweiten Mal per Blockname (`geruest-dummy-einfuegen`). Verteilungs-Konfiguration `querstrebe` (`abstand`/`rand_min`/`rand_max`, Variante vor Baugruppe), Plan `geruest-traeger-streben-plan` -> (rand positionen): der Abstand zwischen zwei Streben ist IMMER genau `abstand`, beide Raender gleich (netto = k*abstand + 2*rand); gewaehlt wird die Strebenzahl, deren Rand am naechsten an `rand_min`..`rand_max` liegt (Gleichstand -> weniger Streben). Mit 500/100..250 geht das nur auf, wenn netto mod 500 = 0 oder in [200, 500) liegt (z.B. 1300 -> 150|500|500|150); sonst (1001..1199, unter 200 mm) liegt der Rand ausserhalb (1100 -> 300|500|300) - `geruest-traeger-rand-pruefen` zeigt dann eine Messagebox (ohne GUI: Konsole), der Traeger wird trotzdem eingefuegt. Schema `*geruest-traeger-attrib-defs*` = `*geruest-attrib-defs*` + die zwei Abzug-Tags; gemeinsamer Block-Schluss mit den anderen Bereichen in `geruest-block-abschliessen`. Reine Berechnung (`geruest-traeger-laenge`/`-start`/`-pruefen`, `geruest-mm-zahl`) in `tests/test_unit.lsp`. **Traeger-Beschriftung** (Checkbox im Dialog, Standard an; z.B. `RT 80/80 L=2650`): EIGENES TEXT-Objekt neben dem Block, abgeleitet NUR aus dem fertigen Block (Einfuegepunkt, DXF 50, Attribute `LAENGE` + `VARIANTE`) - darum nutzen Einfuegen und `GERUEST_TRAEGER_BESCHRIFTEN` dieselbe Funktion `geruest-traeger-beschriften`, und ein von Hand verschobener/gedrehter Traeger wird beim Neubeschriften korrekt erfasst. Verknuepfung Text -> Block per XDATA `SSG_GR_BESCHR` (1005 = Block-Handle): Neubeschriften ersetzt statt verdoppelt, der Sammelbefehl entfernt verwaiste Texte. JSON `beschriftung` (je FELD Variante vor Baugruppe, `geruest-obj-feld`): Inhalt `format` (`%1` Text, `%2` Laenge auf ganze mm) + `text` (fehlt -> Variantenname) und `layer` (je Variante verschieden, darum in JEDER Variante gepflegt - keine Baugruppen-Vorgabe; fehlt er doch, aktueller Layer); Formatierung NUR `stil`/`ausrichtung`/`hoehe` - alles andere regelt der Textstil ausserhalb, es wird kein Stil angelegt (fehlt er: Meldung, aktueller TEXTSTYLE). `ausrichtung` = Codes von TEXT "Ausrichten" (T/M/B + L/C/R), Bezugspunkt auf der Traegerlinie, L/R in LESErichtung. Textwinkel entlang des Traegers, DIN: fuer 90 < w <= 270 Grad um 180 gedreht (`geruest-text-winkel`). **Omniflo-Fahrstrecke** (`OMNI_FAHRSTRECKE`, Daten `data/json/omniflo_fahrstrecke.json`, Vorlage `dcl/fahrstrecke.dcl`): dieselbe Linien-Logik wie der Traeger, Code geteilt - `geruest-linien-starten id daten dcl-datei dlg-name` (Dialog `geruest-traeger-dialog` mit Vorlage + Dialogname als Argument, Einfuegen `geruest-traeger-einfuegen`), Beschriftung `geruest-traeger-beschriften blockEnt bg` mit der Baugruppe als Argument, Sammelbefehle je Typ ueber `geruest-linien-beschriften id daten` (Bloecke `GR__*`; verwaiste Texte werden typunabhaengig entfernt). Unterschiede stehen nur in den Daten: keine Querstreben (kein `mit_querstrebe`), Muffen-Dummy wie beim Traeger ueber den `muffe`-Block der Baugruppe (nur Varianten mit `"mit_muffe": true`), `farbig` nur bei ST/STL, Muffen-Varianten nur fuer ST/STL. Der Dialogstand bleibt je Baugruppe erhalten (`*geruest-tr-id*`; Wechsel Traeger <-> Fahrstrecke setzt ihn zurueck). Meldungstexte `gr-tr-*` nennen den Bauteilnamen aus `titel` als Platzhalter. In der Stueckliste eigene Klasse `fahrstrecke` (`GR_Fahrstrecke_*` vor dem allgemeinen `GR_*`). Noch NICHT enthalten: Edit-Pfad (GERUEST_EDIT) | | `KettenFoerderer.lsp` | KETTENFOERDERER, KF_EDIT, KF_KS_PRUEFEN | Kettenfoerderer/Schraegfoerderer Stahl: baut aus `KF_UM_` -> `KF_LAENGE` -> `KF_AN_` (je KS_AUS auf KS_EIN) plus der Draufsicht `KF_2D` EINEN Block `KF_`. Daten `data/json/kettenfoerderer.json`, Dialog `dcl/kettenfoerderer.dcl`. **Geometrie**: SEITENANSICHT in der XY-Ebene (Hoehe = Y), ungedreht und nicht aufgerichtet, Einfuegepunkt = KS_EIN der Umlenkung; jeder Baustein bekommt nur den aus den KS gemessenen Verkettungswinkel (`insert-block-by-ks`/`insert-block-ks-skaliert`). Die Neigung (26/30 Grad) ist KEIN Rechenparameter: sie steckt in den Varianten-Bloecken, der Einbauwinkel jedes Folgebausteins wird zur Laufzeit aus der RICHTUNG des KS_AUS seines Vorgaengers gemessen (`ks-xy-winkel`/`kf-hz-folge`) - keine gepflegte Winkeltabelle. `KF_LAENGE` wird in X skaliert, die Einbauhoehe H bestimmt seine Laenge ueber die Hoehenbilanz (`kf-laenge-berechnen`). **Hauptblock + Hilfsblock**: `KF_` traegt Beschriftung (`KF_1 26 Grad H=..mm L_horizontal=..mm`, Layer `kettenfoerderer_beschriftung`) und ein Hoehenmass (ActiveX `AddDimRotated`, scheitert es, wird ohne Mass gebaut) und ist der EINZIGE exportierte Block. Die Draufsicht `KF_2D` ist ein getrennter Hilfsblock `KF2D_` (kein Objekt, nur zum Anschliessen von Schienen): flach, auf die PROJIZIERTE Laenge skaliert (`kf-waagerecht-laenge`), beim ersten Einfuegen `ansicht_2d.versatz_y` (-1000) mm unter dem Hauptblock, danach frei verschieb- und loeschbar, gleiche Beschriftung. Beide Bloecke tragen denselben Schluessel als XDATA `SSG_KF_EDIT` (`kf-xdata-key`); KF_EDIT findet damit den - auch verschobenen - Hilfsblock (`kf-hilfsblock-finden`), behaelt dessen Lage und erzeugt ihn bei jedem Edit neu (auch wenn er geloescht war). Der Hilfsblock passt nicht aufs Export-Muster `KF_*`; `export_sivas.py` nimmt ausserdem nur `KF_`. Eine von Hand gesetzte Drehung des Hauptblocks (DXF 50) wird beim Edit wieder aufgesetzt. **Attribut-Schema** `*kf-attrib-defs-liste*` ist EIGENSTAENDIG und bewusst NICHT von `ssg-strecke-attrib-defs` abgeleitet (sonst ~24 geerbte VF-Tags am Block); die Reihenfolge folgt dem Sivas-Merkmalstamm. FOERDERERTYP = Bauart im Klartext, WINKEL = nackte Zahl 26/30 (der Klartext mit Gradzeichen entsteht erst ueber `[kf_typ_anzeige]` in cfg/sivas.cfg, damit Attributwerte ASCII bleiben); kein TEILEART (Festwert `KF_TEILEART` im Export). `L_KF_mm` ist die WAAGERECHTE Laenge des ganzen Foerderers (der Draufsicht `KF_2D`, `kf-waagerecht-laenge`), nicht die Laenge des schraegen Mittelstuecks; sie geht NICHT in die Sivas-Zeile (Spalte Laenge bleibt leer) - Sivas berechnet ZL, EL und Kettenlaenge selbst, uebernommen wird nur die Hoehe (360). Alle Auswahllisten stehen mit ihren Sivas-Optionstexten in der JSON. **Das `KF_`-Praefix ist wie `GR_` nicht kosmetisch**: `SSG_BLOCKEDIT` haelt JEDEN Block mit TEILEART-Attribut fuer ein Omniflo-Element, darum steht ein `KF_*`-Zweig VOR dem Omniflo-Zweig (Doppelklick -> KF_EDIT, der die vorgewaehlte Auswahl nutzt und nur echte `KF_` zulaesst - die Bausteine `KF_UM_26`, `KF_2D` usw. tragen dasselbe Praefix). **Die Blocknummer wird ueber die Blocktabelle vergeben** (`kf-naechste-nummer` ueberspringt jede Nummer mit vorhandener Block-DEFINITION, wie bei `GF_n`/`VF_n`): nur ueber die Referenzen im Modell zu zaehlen war ein realer Fehler - `KF_EDIT` loescht den alten Block, die Definition lebt aber weiter, `_.-BLOCK` traf auf den vorhandenen Namen, die "Neu definieren?"-Abfrage verschluckte die Folge-Eingaben, und die Einzelteile blieben lose liegen ("Block aufgeloest"). **Sivas-Export** (`[kettenfoerderer]` in cfg/sivas.cfg, `build_kettenfoerderer_details` in export_sivas.py): baugleiche Foerderer werden zu EINER Zeile zusammengefasst (`kettenfoerderer_signature`), die Gruppengroesse traegt Merkmal 353 (entsteht erst aus der Gruppierung, daher Code); Farbe (354) und Antriebsmotor (355) sind ZWEI Merkmale, und jedes merkmalbildende Attribut steht auch in der Signatur, sonst verschmelzen unterschiedlich ausgefuehrte Foerderer. Reine Kerne in `tests/test_unit.lsp` (`tu-tests-kf`); das Modul wird in der MNL vorgeladen, Laufzeit-Messung ueber den dbg-Schalter `kf-zeit` | -| `HwPlanung.lsp` | HW_MATERIALFLUSS, HW_SYMBOLE | Menue "HW Planung" (zwischen Lager und SPS). HW_MATERIALFLUSS ruft EXPORTCSV auf (alte `_export.csv` wird vorher geloescht, damit kein veralteter Stand gezeichnet wird) und danach `lib/hw_materialfluss.py` ueber `export:run-python` (Log `DXFM_LOG/hw_materialfluss.log`); Ergebnis ist eine NEUE Datei `DXFM_RESULTS/_HW.dxf` (`hw-dxf-name`, getestet), die offene Zeichnung bleibt unveraendert. HW_SYMBOLE ist noch ein Platzhalter (Hinweis) | +| `HwPlanung.lsp` | HW_MATERIALFLUSS, HW_SYMBOLE, HW_SYM_VENTILE, HW_SYM_SENSOREN, HW_SYM_MOTOREN, HW_SYM_MOTORSCHUTZ, HW_SYM_ENCODER, HW_SYM_SCANNER, HW_SYM_LOESCHEN, HW_NUMMERIEREN | Menue "HW Planung" (zwischen Lager und SPS), Ablauf und Renamer-Regeln in `doc/hw_planung.md`. **Ausgabeziel per Dialog** `dcl/hw_ausgabe.dcl` vor jedem Erzeugungsbefehl (`hw-ausgabe-waehlen`, letzte Wahl je Sitzung in `*hw-ausgabe-letzt*`, ohne GUI Standarddatei `_HW.dxf`): **aktuelle Zeichnung** (Python schreibt eine EINHEITENLOSE Zwischendatei `_HW_tmp.dxf` (`--einheitenlos`, sonst skaliert BricsCAD beim Einfuegen), `hw-in-aktuelle-zeichnung` fuegt sie per `vla-InsertBlock` bei 0,0 ein, loest sie per `vla-Explode` auf und loescht ERST DANACH die fruehere Ausgabe - `hw-alte-ausgabe` ueber XDATA `SSG_HW_MF` (Materialfluss) bzw. `SSG_HW_SYM` je Praefix), **bestehende Datei ergaenzen** (getfiled ueber `done_dialog 2`, da getfiled aus einem action_tile nicht geht) oder **neue Datei** (Name, Ordner DXFM_RESULTS; existiert sie, bleibt der Dialog mit Hinweis offen). Reine Kerne `hw-name-ohne-dxf`/`hw-name-gueltig-p`/`hw-neu-pfad`/`hw-ausgabe-pruefen` getestet. In einer Datei ersetzt jeder Befehl nur seinen eigenen Anteil (Materialfluss = XDATA `SSG_HW_MF` bzw. HW_*-Layer, Symbole = XDATA `SSG_HW_SYM` je Praefix) - vom Anwender Gezeichnetes (Renamer-Bereiche) bleibt; die Ursprungsdatei auf der Platte wird nie geaendert. Python-Aufrufe zentral ueber `hw-python` (Log je Befehl in DXFM_LOG, `ok-codes` fuer create_numbers' Exit 1 bei Planungsfehlern). HW_MATERIALFLUSS: EXPORTCSV (alte CSV vorher geloescht) -> `lib/hw_materialfluss.py`. **Symbole**: Regelwerk `cfg/io_regeln.json` (reduzierte Kopie aus kabellaengen: Rollen mit Praefix/Symbolblock, Bauteile, `io_vorlage`; Erkennungs-Regex/Radien/Nummernpaare/altZuNeu entfernt). **Aussehen und Attribute der Symbole stehen in `cfg/hw_symbole.json`** (Vorlagen mit Rahmen/Schraffur + Attribut-Lage/-Hoehe/-Stil, je Symbol Layer/Farbe/`versatz`/Abweichungen je Tag/Werte) - es gibt KEINE Symbol-Bibliothek als Referenzdokument. Die Symbole tragen DIESELBEN Blocknamen wie die ILS-Katalogsymbole in kabellaengen und sind ihnen exakt nachgebaut (inkl. Einfuegepunkt: Eingaenge/Motoren haengen nach unten) - `test_symbole_gleichen_dem_katalog` prueft das gegen `kabellaengen/doc/Symbole/ILS-Symbole.dxf`; aendert sich dort ein Symbol, faellt der Test und die Konfiguration wird nachgezogen. `hw-sym-bauteile-sammeln` findet die Mechanik-Bloecke der Ursprungszeichnung top-level UND verschachtelt in VF_n/GF_n/KREISEL_n (Weltlage ueber `csv:wrapper-welt-records`), `hw-sym-plan` legt je Rolle Symbolblock, flachen ZIELPUNKT (Bauteil-Einfuegepunkt, `hw-sym-ziel`) und ID-VORLAGE (`hw-sym-io-vorlage`, z.B. `MB1@@@`) fest; bei Alternativen in `benoetigt` die erste. `hw-sym-plan->json` (Zahlen ueber `hw-json-zahl`, DIMZIN-unabhaengig) -> `_hw_symbole.json` -> `lib/hw_symbole.py`; der Plan ist IMMER vollstaendig (alle Praefixe), `praefixe` sagt nur, was eingefuegt wird - so haengt die Lage nicht vom Einzelbefehl ab. Die LAGE rechnet Python (kennt die Symbolgeometrie), nicht LISP. Die Nummern vergibt NICHT das Erzeugen, sondern HW_NUMMERIEREN in der geoeffneten _HW.dxf: QSAVE, `lib/create_numbers.py --dryrun --info`, dann `hw-num-anwenden` (Zuordnung je Attribut mit `@` ueber Wert + Einfuegepunkt, `hw-num-eintrag-p`) in die offene Zeichnung. Reine Kerne in `tu-tests-hw` | | `menu-inventory.lsp` | MenuInventory | BricsCAD Diagnostik (Menuegruppen, Plugins, Startdateien) | | `LagerInsert.lsp` | LG_GESCHOSS, LG_GESCHOSS_AKTIV, LG_GESCHOSS_KOPIEREN, LG_BEREICH, LG_REIHE, LG_GANG, LG_EDIT, LG_REGALE, LAGER_EXPORT | Lagerplanung GOH-Buehnenlager -> technische Daten als JSON fuer die RFQ-Dokumentation (Phase 0+1: Mengen, 2D; Konzept/Schema/Kennwerte in `doc/plan/lager_rfq.md`). Bloecke `LG_GESCHOSS` (Kopfsymbol, XDATA `SSG_LG` `kontur` -> Belagskontur), `LG_BEREICH` (Kontur + Parameter, fuellt sich selbst), `LG_REIHE__`/`LG_GANG___` (Definition je Geometrie wiederverwendet, Werte in den Attributen der Referenz; Kinder tragen XDATA `bereich`; der Belag steht im Gang-Blocknamen, weil die Definition seine Schraffur enthaelt - Muster aus `lager.json belag_schraffur`, per `vla-AddHatch` auf Layer 0/VONBLOCK, `hallenboden` ohne; BricsCAD-`iso.pat` kennt kein `NET`, Gitterrost = `GRID`). Alle tragen `ID`, `GESCHOSS`, `LAYER`. Blockdefinitionen per `entmake` (BLOCK...ENDBLK), Einfuegen per `vla-InsertBlock` in WCS - kein `_.-BLOCK`/`_.INSERT`. **Attribut-Tags haben gemischte Schreibweise** (`HOEHE_BELAG_mm`) - beim Setzen ueber `vla-get-TagString` daher case-insensitiv zuordnen (`lg-wert-fuer-tag`), sonst bleiben diese Attribute still leer (realer Fehler beim Bau). Layer je Geschoss = Basis aus `cfg/layer.cfg [lager]` + `_G`; `LG_GESCHOSS_AKTIV` friert die uebrigen (2D-Grundrisse liegen uebereinander), aktives Geschoss als `vlax-ldata` (Dictionary `SSG_LAGER`). Geschosszuordnung `lg-geschoss-fuer-punkt-kern`: eine Kontur -> eindeutig, mehrere nur ueber Z unterscheidbar, wenn die Konturen auf verschiedenen Hoehen liegen (3D), sonst nil. Jede Kontur gehoert genau einem Block (vergebene Kontur wird kopiert). Auto-Fuellung `lg-fuellung-berechnen` (reiner Kern, Muster Reihe|Gang|Reihe Ruecken an Ruecken bzw. Reihe|Gang, nur volle Module, Streifen an Unter-/Mittel-/Oberkante gegen die Kontur geschnitten, Quergaenge ausgerichtet und nicht am Reihenende); Bereichs-Edit = Neuaufbau aller Kinder. **Randgaenge** `RANDGANG_N/O/S/W_mm` (Nord = +Y Welt) laufen entlang der echten Konturkanten (`lg-randgang-streifen`, Himmelsrichtung der Aussennormale ueber `lg-kompass`, auch L-Form; Ecken: N/S besitzen sie - konvex O/W kuerzen, einspringend N/S verlaengern) und werden wie manuelle Gaenge (`LG_GANG`, `QUELLE=manuell`, Kern `lg-gang-einfuegen`; per Linie oder Option `Rechteck` = Flaeche mit dem echten BricsCAD-Befehl RECHTECK gezeichnet, inkl. aller seiner Optionen (`lg-rechteck-zeichnen`: `_.RECTANG` + `(command pause)`-Schleife bei CMDECHO 1, Polylinie danach ueber `lg-rechteck-ecken-aus-poly` gelesen und geloescht; nur 4 Ecken ohne Boegen, sonst Meldung), Breite/Lage aus dem Rechteck ueber `lg-gang-aus-ecken`, Schleife `lg-linien-schleife` mit `rechteck-fn`) als **Sperrflaechen** aus Reihen/Haupt-/Quergaengen ausgeschnitten (`ssg-poly-band-x`). **Von Hand Gesetztes loest KEINE Neuberechnung aus**: `LG_REIHE`/`LG_GANG` (`lg-manuell-einfuegen`) und das Bearbeiten einer Reihe/eines Gangs (`lg-linie-neu-aufbauen-sperre`) schneiden nur die eigene Flaeche aus den rangniedrigeren Objekten heraus (`lg-sperre-anwenden`, reiner Kern `lg-linie-zuschnitt`; Rang `lg-quelle-rang`: auto < auto_geaendert < manuell) - frueher fuellte ein manueller Gang den ganzen Bereich neu, dabei kamen geloeschte Objekte zurueck und geaenderte wurden ersetzt. Die ausdrueckliche Neuberechnung (`lg-bereich-fuellen`, Bereich bearbeiten) entfernt nur `QUELLE=auto`; `manuell`/`auto_geaendert`-Kinder und unverknuepfte manuelle Objekte in der Kontur (`lg-manuelle-in-kontur`) bleiben und sperren ihre Flaeche. `lg-blockdef` ergaenzt bei Altbestand fehlende ATTDEFs (Neudefinition + `_.ATTSYNC`). `LAGER_EXPORT` schreibt NUR `_lager.json` nach `DXFM_RESULTS` (Format `ssg-lager` Version 2, kein Python-Schritt; das RFQ-Projekt uebernimmt Mapping und Hand-Abgleich): alle Geschosse, Bereiche, Reihen und Gaenge mit Attributen und voller Geometrie (Konturpunkte, Start/Ende/Mitte, 4 Ecken) plus `kennwerte` je Geschoss und `lager` gesamt (lichte Hoehe, Regal-/Schienenlaenge, Haengeebenen, Gaenge je Art und Breite, Belags-/Gitterrostflaeche, Gitterkorb, Regalrohr = Laenge x Haengeebenen), je Geschoss-Kennwert `herkunft` (Formel + Quelle). **`null` = fehlt (offener Punkt), nicht Zutreffendes wird weggelassen** (Marker `'ENTFAELLT`, `lg-json-ohne-entfallend`). Plausibilitaetswarnungen je Geschoss (`lg-plausibilitaet`, Grenzwerte aus dem Regelprofil). Das Reihen-Attribut `SCHIENEN` ist entfallen; `SCHIENEN_JE_GANG` am Bereich (Trolley) wird noch nicht ausgewertet. Eigener JSON-Schreiber `lg-json-*` ueber getaggte Werte `(OBJ ...)`/`(ARR ...)` (`lg-json-text` rueckt ein, Nicht-ASCII als `\uXXXX`), Kennwerte in reinen Kernen (`lg-kennwerte-geschoss`/`-lager`, `lg-haengeebenen-summen`, `lg-gaenge-je-art`, `lg-reihen-offene-kanten`, `lg-reihen-gitterkorb-berechnen`). **Absturzsicherung unter den Reihen** je Bereich (`UNTER_REIHE` = `gitterrost`/`gitterkorb`/`keine`, Vorgabe `lager.json bereich.unter_reihe`, nur auf Buehnen wirksam): Gitterrost zaehlt die Reihengrundflaeche zur Gitterrostflaeche, Gitterkorb liefert Boden (Laenge x Tiefe) und Seitenflaeche = OFFENE Kantenlaenge x `GITTERKORB_HOEHE_mm` (Kanten an einer direkt anliegenden Nachbarreihe desselben Geschosses, z.B. Ruecken an Ruecken, zaehlen nicht; die Korbhoehe soll spaeter aus den Attributen berechnet werden). Regelwerk-Werte stehen noch nicht im Export (`doc/todo.md`). Befehl `LG_REGALE` (Menue Lager -> Regale zaehlen) zaehlt die Regalreihen `LG_REIHE_*` (Anzahl, Laenge, Laenge x Haengeebenen je Geschoss aus den Attributen GESCHOSS/LAENGE/HAENGEEBENEN, reine Kerne `lg-reihe-eintrag`/`lg-reihen-auswerten`). **Der parametrische BricsCAD-Block `goh_rack` wird nicht mehr unterstuetzt** (veraltet, 2026-10-30 aus Zaehlung, Export und Geschoss-Kopie entfernt). Vorgaben aus `data/json/lager.json`. Dialoge `dcl/lager.dcl` ueber den generischen Treiber `lg-dialog` (Feldlisten `*lg-felder-*`, Konsolen-Rueckfall `lg-konsole`, ohne GUI Vorgaben). **Geschoss kopieren** (`LG_GESCHOSS_KOPIEREN`, Dialog `lg_geschoss_kopie`, Kern `lg-geschosse-kopieren`): Quelle (Vorgabe oberstes Geschoss, Erweitern G3 -> G4), Anzahl, Geschosshoehe je Kopie, Belag, Versatz X/Y je Kopie (0 = deckungsgleich; 3D-Zeichnung zusaetzlich +Z). Kein BricsCAD-KOPIEREN (XDATA-Verweise zeigten aufs Original): jedes Objekt entsteht ueber die normalen Einfuegefunktionen neu (`lg-geschoss-anlegen`, `lg-bereich-block-anlegen` = Bereich ohne Fuellung, `lg-linie-einfuegen`), Kinder 1:1 inkl. `auto_geaendert`/manuell, kein Neufuellen; Belaege gleich dem Quell-Geschossbelag folgen dem neuen (`lg-kopie-belag`). Tests: `tu-tests-lager` (test_unit) und `tests/test_lager.lsp` | | `LagerPruefung.lsp` | LG_REGELN | Regelprofile der Lagerplanung: `cfg/stahlbau/regelwerk/.json` laden, `erbt_von` aufloesen (`lg-profil-mischen`), je Regel den STRENGSTEN Wert aller zutreffenden Quellen (`lg-regel-aufloesen`: `art` min/max/wert, `bedingung` gegen einen Kontext - fehlt der Schluessel, konservativ einbezogen -, `tabelle` + `tabellen_schluessel`) mit Protokoll. Jede Pruefregel der Lagerplanung wird genau hier implementiert (Python rendert nur). Alle Startwerte `verifiziert: false` | @@ -152,6 +153,7 @@ Detaillierte Dokumentation aller Funktionen und Befehle: siehe `Lisp/README.md`. | `fahrstrecke.dcl` | VORLAGE fuer OMNI_FAHRSTRECKE (GeruestInsert.lsp, `geruest-traeger-dialog`), gleiche Tile-Keys wie `geruest_traeger.dcl`, Platzhalter `//@VARIANTEN@` | | `kettenfoerderer.dcl` | KETTENFOERDERER/KF_EDIT (KettenFoerderer.lsp, `kf-dialog`); VORLAGE mit Markerzeile `//@VARIANTEN@` (Typ als Radiobuttons, gefuellt per `ssg-dcl-vorlage-fuellen`), die uebrigen Auswahllisten sind `popup_list`-Tiles, die per `start_list`/`add_list` aus `data/json/kettenfoerderer.json` gefuellt werden. Der Knopf `hoehe_messen` schliesst den Dialog per `done_dialog 2` (Hoehe aus 2 Punkten messen, danach erneut zeigen) | | `dinformate.dcl` | DIN_FORMATE (DinFormate.lsp) | +| `hw_ausgabe.dcl` | Ausgabeziel vor HW_MATERIALFLUSS/HW_SYMBOLE/HW_SYM_* (HwPlanung.lsp, `hw-ausgabe-waehlen`): Radiogruppe `modus` (`aktuell`/`ergaenzen`/`neu`), Dateiauswahl `datei` + Knopf `waehlen` (`done_dialog 2`), Name `name`, Fehlerzeile `error` | | `lager.dcl` | LG_GESCHOSS/LG_GESCHOSS_KOPIEREN/LG_BEREICH/LG_REIHE/LG_GANG + Edit (LagerInsert.lsp, generischer Treiber `lg-dialog`); Dialoge `lg_geschoss`, `lg_geschoss_kopie`, `lg_bereich` (inkl. Randgaenge N/O/S/W), `lg_reihe`, `lg_gang`, Tile-Keys = Feldlisten `*lg-felder-*` | | `vfl_edit.dcl` | Linienzug-Editieren/Fortsetzen (vf_linienzug.lsp, `vfl-dlg-position`, aufgerufen aus `vfl-edit-ent`) | | `vfl_edit_modus.dcl` | Linienzug-Edit: Auswahl des Edit-Modus (vf_linienzug.lsp) | @@ -221,8 +223,14 @@ Abhaengigkeiten: `ezdxf`, Standard-Library. Benoetigen Umgebungsvariablen (`DXFM | `dxf_vf_abbild.py` | Abbild der `VF_`/`GF_`-Wrapperbloecke einer Testzeichnung: Attribute UND Bausteinzusammensetzung je Kette, Ketten ueber den Einfuegepunkt der Spec zugeordnet (der Blockname taugt nicht - die VF-Nummer beginnt in jeder Zeichnung neu bei 1). Regressionsnetz fuer Umbauten am Bau-Ablauf: `--referenz` vergleicht gegen `tests/reference/hundm05_attribute.json` (Exit 1 bei Abweichung), `--schreibe-referenz` frischt sie nach einem GEPRUEFTEN Lauf auf. Faengt genau das, was der Ergebnis-Record nicht sieht - eine vertauschte `vfl-acc-*`-Reihenfolge (Komma-Listen `L_VF_m`/`L_GF_m`/`GF_WINKEL`, in der Zeichnung unsichtbar, im Sivas-Export falsch) und eine falsche Blockvariante. Liest ASCII-DXF streamend; binaere DXF kann `dxf_scan_components.py`. Genutzt von `tests/test_vf_geometrie.py` | | `vf_spec_export.py` | Uebersetzt ein Eingabe-Journal in eine **Spec** und zurueck (`spec_aus_journal` / `journal_aus_spec`) - Gegenstueck zu `vsp-journal` in `Lisp/vf_spec.lsp`, beide muessen dieselbe Tokenfolge liefern. Der Rundlauf `journal_aus_spec(spec_aus_journal(tok)) == tok` laeuft in `tests/test_vf_spec.py` gegen die 5 echten HundM-Ketten und beweist, dass die Spec das Journal verlustfrei abbildet. Schreibt das flache Spec-JSON (vom LISP-Parser lesbar). Objekte der Ziel-Datei, die keine Kette beschreiben (von Hand ergaenzte Anlagenteile mit `function`, z.B. Kreisel), werden beim Neuschreiben unveraendert ans Dateiende uebernommen - sonst waeren sie beim naechsten Lauf weg. Aufruf: `python vf_spec_export.py ` — erzeugt `tests/testdata/hundm05.json` (Spec, Eingabe fuer `TEST_HUNDM05`/`VF_SPEC_BAU`) aus `tests/testdata/hm_recformat.json` (Aufzeichnung) | | `dxf_anlage_export.py` | Gegenstueck zum Scanner: liest eine mit unseren Makros GEBAUTE (und von Hand nachbearbeitete) Anlage aus der Zeichnung zurueck in Nachbau-Testdaten - je Wrapper genau die Parameter, die auch dessen Edit-Befehl liest (KREISEL_n: Einfuegepunkt, `abstand` exakt aus der Blockdefinition, `drehrichtung` aus DREHRICHTUNG; GF_n: SSG_GF_EDIT + SEITE_AS/ES, endet die GF auf einer Kreisel-Kette, wird dessen Hoehe `hoehe_ende_mm`; VF_n `standard`/`etage`: mubea-Schema wie `vfs-edit-basis-lesen`; VF_n `linienzug`: Journal UNVERAENDERT als `"typ": "linienzug_journal"` - bewusst keine Spec, weil der Bau-Dialog neuer ist als die Spec-Grammatik; uebrige Bloecke als Einzel-Insert ohne `_2D`/`_3D`-Suffix). Reihenfolge Kreisel, VF, GF, Einzelbloecke (VF vor GF wie mubea). Aufruf: `python dxf_anlage_export.py [--projekt NAME]` — erzeugt `tests/testdata/dematic_arvato.json` aus `Dematic_TST.dxf`. Test `tests/test_dxf_anlage_export.py` | -| (`io_zuordnung_pruefen.py`) | **Nicht hier**: die Zuordnung E-Geraete (Sensoren/Aktoren/Motoren) -> Anlagenbauteile (Separator, AS, Kreisel AN8/SP8, Vario-Stationen, Scanner) liegt im Kabeltool-Repo `kabellaengen`: Pruefskript `lib/io_zuordnung_pruefen.py` (`bin\io_zuordnung_pruefen.bat`), Konverter alter `io`-Symbole `lib/altZuNeu.py` (`bin\altZuNeu.bat`), gemeinsames Regelwerk `cfg/io_regeln.json`, Doku `doc/Anwenderdoku_altZuNeu.md`. Die dort erkannten Mechanik-Bloecke sind die dxfmakros-Bloecke (`Separator_SP*`, `AS_Element*`, `AN8_*`/`SP8_*`, `Vario_Motorstation*`/`Vario_Umlenkstation*`, `Scanner*`) - wer sie umbenennt, muss `bauteile` in `io_regeln.json` mitziehen | -| `hw_materialfluss.py` | HW-Planung: Materialfluss-Skizze aus dem CSV-Export (EXPORTCSV) als neue DXF, flach in XY. Kreisel/Eckrad = geschlossenes Rechteck (Position/Boundingbox, Merkmal `Drehung`; schraeg: Abstand + 800 mm), Punktfolge in Drehrichtung (UZS = Uhrzeigersinn) plus Pfeile; Strecke/GF = Polylinie AS -> Separatoren -> ES ueber `TrackIds` (ES vorn -> umgedreht) plus Endpfeil; `ILS Weiche` = kleiner Kreis; BTMT Beladung = grosser Kreis K1 + kleiner Kreis K2 + Linie, Entladung = kleiner Kreis K1 + grosser Kreis K2 + Linie (K1/K2 per `decode_trans` aus `export_graph.py`, dessen `load_elements` auch die CSV liest). Layer/Farben aus `cfg/layer.cfg` `[hw_planung]` (eigener Mini-Parser, Vorgaben im Code). Test `tests/test_hw_materialfluss.py`. Aufruf: `python hw_materialfluss.py [--layer-cfg ...]` | +| (`io_zuordnung_pruefen.py`) | **Nicht hier**: die Zuordnung E-Geraete (Sensoren/Aktoren/Motoren) -> Anlagenbauteile (Separator, AS, Kreisel AN8/SP8, Vario-Stationen, Scanner) liegt im Kabeltool-Repo `kabellaengen`: Pruefskript `lib/io_zuordnung_pruefen.py` (`bin\io_zuordnung_pruefen.bat`), Konverter alter `io`-Symbole `lib/altZuNeu.py` (`bin\altZuNeu.bat`), gemeinsames Regelwerk `cfg/io_regeln.json`, Doku `doc/Anwenderdoku_altZuNeu.md`. Die dort erkannten Mechanik-Bloecke sind die dxfmakros-Bloecke (`Separator_SP*`, `AS_Element*`, `AN8_*`/`SP8_*`, `Vario_Motorstation*`/`Vario_Umlenkstation*`, `Scanner*`) - wer sie umbenennt, muss `bauteile` in `io_regeln.json` mitziehen - in BEIDEN Repos: dxfmakros fuehrt fuer `HW_SYMBOLE` (HwPlanung.lsp) eine reduzierte Kopie `cfg/io_regeln.json`. Die Symbol-NUMMERIERUNG (`create_numbers.py`, `cfg/enumerate.cfg`) liegt dagegen seit 2026-11-03 nur noch hier | +| `hw_materialfluss.py` | HW-Planung: Materialfluss-Skizze aus dem CSV-Export (EXPORTCSV) als neue DXF, flach in XY. Kreisel/Eckrad = geschlossenes Rechteck (Position/Boundingbox, Merkmal `Drehung`; schraeg: Abstand + 800 mm), Punktfolge in Drehrichtung (UZS = Uhrzeigersinn) plus Pfeile; Strecke/GF = Polylinie AS -> Separatoren -> ES ueber `TrackIds` (ES vorn -> umgedreht) plus Endpfeil; `ILS Weiche` = kleiner Kreis; BTMT Beladung = grosser Kreis K1 + kleiner Kreis K2 + Linie, Entladung = kleiner Kreis K1 + grosser Kreis K2 + Linie (K1/K2 per `decode_trans` aus `export_graph.py`, dessen `load_elements` auch die CSV liest). Layer/Farben aus `cfg/layer.cfg` `[hw_planung]` (eigener Mini-Parser, Vorgaben im Code). Alle erzeugten Objekte tragen XDATA `SSG_HW_MF`; ein Folgelauf ersetzt nur diese (bzw. bei Altdateien die HW_*-Layer). `--einheitenlos` setzt `$INSUNITS = 0` fuer die Zwischendatei der aktuellen Zeichnung (gilt ebenso fuer `hw_symbole.py`). Test `tests/test_hw_materialfluss.py`. Aufruf: `python hw_materialfluss.py [--layer-cfg ...]` | +<<<<<<< Updated upstream +======= +| `hw_symbole.py` | HW-Planung: setzt die E-Geraete-Symbole aus dem Plan `_hw_symbole.json` (HW_SYMBOLE, HwPlanung.lsp) in die `_HW.dxf` (`hw_materialfluss.hw_dokument_oeffnen`: vorhandene Datei ergaenzen statt ueberschreiben). Entfernt vorher nur die erzeugten Symbole der Praefixe des Plans (XDATA `SSG_HW_SYM`), baut die Blockdefinitionen bei jedem Lauf neu aus `cfg/hw_symbole.json` (`block_aufbauen`: Rahmen, ATTDEFs, Textstil, Layer), **Lage** per `platzieren`: Marke `REALE_POSITION` mit x genau auf dem Zielpunkt, Kasten daneben, Symbole eines Bauteils luekenlos uebereinander (Reihenfolge `benoetigt`), Gruppe um das Ziel zentriert; Ueberlappung mit schon gesetzten Rahmen (2D-Bounding-Boxen, `platzierung` in hw_symbole.json) wird NUR in y ausgewichen, x bleibt auf dem Ziel; Plan immer vollstaendig, eingefuegt nur `praefixe`. IO = Vorlage, Platzhalter-ID in VERW/BEZEICHNUNG ersetzt (`attribut_werte`). Test `tests/test_hw_symbole.py` (inkl. Abdeckung io_regeln -> hw_symbole und Kette Symbole -> Renamer-Polylinie -> create_numbers). Aufruf: `python hw_symbole.py --symbole cfg/hw_symbole.json` | +| `create_numbers.py` | Nummerierung der Symbol-Vorlagen (`@`) entlang von Renamer-Bereichen (Rechteck mit Richtung oder `POLYLINE_PATH`), Layer/Parameter aus `cfg/enumerate.cfg`; Fehler/Warnungen als zwei einfache Listen `(art, text)` (`neue_meldungen`/`meldungen_als_dict`, Format der Fehlerdatei unveraendert) - KEINE Kopie von kabellaengens `error_collector.py`/`utils.py`; die wenigen Helfer daraus stehen einmalig im Modul. 2026-11-03 aus kabellaengen umgezogen (dort entfernt), die benoetigten Helfer aus kabellaengen/lib/utils.py stehen direkt im Modul, `--all-examples` entfallen. Nummer wird OKTAL gebildet (`oct(counter)`, so gewollt - nicht auf dezimal "korrigieren"). Genutzt von HW_NUMMERIEREN. Test `tests/test_create_numbers.py`. Aufruf: `python create_numbers.py --filename [--config ...] [--info ...] [--errorfile ...] [--dryrun\|--overwrite]` | +| `lager_yaml_export.py` | Lagerplanung: `_lager_raw.json` (LAGER_EXPORT) -> `_drawing.yaml` (Overlay im Schema der RFQ-Hand-YAML, nur Zeichnungsfelder, plus `_provenance`/`_conflicts`/`_warnings`) und optional `--nachweis` HTML. `--hand ` gleicht ab (Hand gewinnt; Konflikte bei > 1 % bzw. 0,005). Aggregiert nur, Regeln leben in LISP. Braucht `pyyaml`. Test `tests/test_lager_yaml.py`. Aufruf: `python lager_yaml_export.py [--hand ...] [--nachweis ...]` | +>>>>>>> Stashed changes | `dxf_scan_components.py` | Erkennt ILS-/Omniflo-Komponenten in einer **fremden** Projektzeichnung (ohne unsere Wrapper-Bloecke) und schreibt sie im JSON-Schema aus `doc/json-input-lisp.md` (Nachfolger des geloeschten `tests/testdata/object_data.md`) — Grundlage fuer Nachbau-Testfaelle wie `tests/testdata/mubea.json`. Liest binaere DXF im GB-Bereich streamend per mmap (ezdxf-DOM waere zu gross), loest INSERTs inkl. OCS/Extrusionsrichtung rekursiv in Weltkoordinaten auf, paart AN8/SP8 zu Kreiseln und Antrieb/Umlenkung zu Foerderern. Gefaellestrecken, die als 3D-LINE auf einem Staustrecken-Layer gezeichnet sind (`GF_LINIE_LAYER`, z.B. `6-SP`), werden in einem zweiten Lesedurchgang zu Modus-1-Eintraegen: Start/Ende werden auf die Ketten der anschliessenden Kreisel verlaengert (gezeichnet ist nur die Staustrecke; `deltaL` reicht von AS- bis ES-Ursprung), AS-Seite aus dem Ausschleuselement am oberen Ende; dieses und Separatoren auf der Achse entfallen als Einzel-Insert (die GF baut beide selbst). Sitzt der AS am UNTEREN Ende, ist es ein Steigfoerderer und wird nur gemeldet (`gf_eintraege_aus_linien`, Test `tests/test_dxf_scan_gf.py`). `--ohne-hoehen` setzt alle Z auf 0, `--kreisel-hoehe X Y Z` gibt einem Kreisel seine Hoehe - die daran haengenden GF bekommen daraus `start_mm.z`/`hoehe_ende_mm`, den Winkel rechnet der Treiber (`dematic_arvato:gf-geometrie`: atan ueber die HORIZONTALE Laenge, ES-Laenge UND -Abfall bei der Einlaufneigung gemessen und iteriert, Staustrecke daraus - die flache ES-Laenge liess steile GF ~144 mm vor der Kette enden - NICHT `gf-winkel-berechnen`, dessen asin bei steilen GF wie im Showroom ~50 statt ~37 Grad liefert; `dematic_arvato:gf-endhoehe-pruefen` meldet `warnung` bei > 5 mm Abweichung von HOEHE_BIS). Flache 2D-Plaene: `--flach` (waagrechte Staustrecke mit AS an genau einem Ende = GF, zerstueckelte Linien werden vereint, ES am unteren Ende wird verbraucht, AS/ES-Seite ohne Namensangabe aus der Lage zur Fahrtrichtung), `--kreisel-achse X1 Y1 X2 Y2` (Kreisel ohne AN8/SP8), Einzelteile auf einer Kreisel-Kette erben dessen Anwender-Hoehe (`sensor_hoehen_von_kreiseln`). Kreisel-Paarung mit Tausch-Nachbesserung auf minimale Abstandssumme (zwei Kreisel auf einer Achse). Genutzt fuer `tests/testdata/showroom_ils.json` (`TEST_SHOWROOM_ILS`). Aufruf: `python dxf_scan_components.py [--fenster X0 Y0 X1 Y1]` — ohne `--fenster` liefert eine Anlagenzeichnung leicht ueber 1300 Eintraege, zuviel fuer einen Testlauf | ### Entwickler-Tools (`lib/`) - kein ezdxf, reine Text-/AutoLISP-Verarbeitung diff --git a/Lisp/HwPlanung.lsp b/Lisp/HwPlanung.lsp index 9325183..2c161e6 100644 --- a/Lisp/HwPlanung.lsp +++ b/Lisp/HwPlanung.lsp @@ -1,40 +1,426 @@ ;; ============================================================ ;; HwPlanung.lsp - Hardware-Planung (Menue "HW Planung") ;; -;; HW_MATERIALFLUSS ("Materialfluss erzeugen"): -;; 1. exportiert das aktuelle Hardware-Layout per EXPORTCSV -;; (csv:run-export, export.lsp) nach DXFM_RESULTS/_export.csv -;; 2. ruft lib/hw_materialfluss.py auf, das daraus die Materialfluss- -;; Skizze (Kreisel-Rechtecke in Drehrichtung, Strecken-Polylinien -;; AS -> Separatoren -> ES, Weichen, Be-/Entladungen) zeichnet -;; 3. Ergebnis: DXFM_RESULTS/_HW.dxf (neue Datei, die offene -;; Zeichnung bleibt unveraendert) +;; Ziel der Ausgabe: vor jedem Erzeugungsbefehl fragt der Dialog +;; dcl/hw_ausgabe.dcl (hw-ausgabe-waehlen), wohin: +;; - aktuelle Zeichnung: Python schreibt eine einheitenlose Zwischendatei +;; _HW_tmp.dxf, die als Block bei 0,0 eingefuegt und aufgeloest wird +;; (hw-in-aktuelle-zeichnung); die fruehere Ausgabe (XDATA SSG_HW_MF bzw. +;; SSG_HW_SYM) wird danach entfernt +;; - bestehende Datei ergaenzen (Dateiauswahl) +;; - neue Datei (Name, Ordner DXFM_RESULTS) +;; In einer Datei ersetzt jeder Befehl nur seinen eigenen Anteil - was der +;; Anwender dort ergaenzt (Renamer-Rahmen, Polylinien), bleibt erhalten. ;; -;; HW_SYMBOLE ("Symbole erzeugen"): noch Platzhalter, zeigt nur einen Hinweis. +;; HW_MATERIALFLUSS ("Materialfluss erzeugen", in der Ursprungszeichnung): +;; EXPORTCSV -> lib/hw_materialfluss.py zeichnet Kreisel-Rechtecke in +;; Drehrichtung, Strecken-Polylinien AS -> Separatoren -> ES, Weichen und +;; Be-/Entladungen auf die HW_*-Layer der _HW.dxf. +;; +;; HW_SYMBOLE ("Symbole erzeugen", in der Ursprungszeichnung): sucht die +;; Mechanik-Bauteile nach cfg/io_regeln.json (auch verschachtelt in +;; VF_n/GF_n/KREISEL_n), schreibt den Symbolplan als JSON und laesst +;; lib/hw_symbole.py die Symbole in die _HW.dxf setzen - deren Aussehen und +;; Attribute stehen in cfg/hw_symbole.json (keine Symbol-Bibliothek). Die Geraete-ID ist noch eine VORLAGE (z.B. MB1@@@) - +;; nummeriert wird erst mit HW_NUMMERIEREN, entlang der Rahmen/Polylinien, +;; die der Anwender in der _HW.dxf zieht. Einzelbefehle HW_SYM_VENTILE usw. +;; +;; HW_NUMMERIEREN ("Symbole nummerieren", in der geoeffneten _HW.dxf): +;; speichert die Zeichnung, laesst lib/create_numbers.py (Renamer-Bereiche, +;; cfg/enumerate.cfg) die Nummern berechnen und traegt sie in die offene +;; Zeichnung ein. ;; ;; Setzt export.lsp voraus (laedt die MNL vor). ;; ============================================================ (setq *hw-skript* "hw_materialfluss.py") +(setq *hw-sym-skript* "hw_symbole.py") +(setq *hw-num-skript* "create_numbers.py") -;; Name der Ausgabedatei aus dem Zeichnungsnamen (reiner Kern, getestet): -;; "Mubea.dwg" -> "Mubea_HW.dxf" +;; XDATA der erzeugten Objekte (gesetzt von lib/hw_materialfluss.py bzw. +;; lib/hw_symbole.py) - daran erkennt hw-alte-ausgabe die fruehere Ausgabe +(setq *hw-mf-app* "SSG_HW_MF") +(setq *hw-sym-app* "SSG_HW_SYM") + +;; Letzte Wahl im Ausgabe-Dialog (modus datei name), je Sitzung +(if (not (boundp '*hw-ausgabe-letzt*)) (setq *hw-ausgabe-letzt* nil)) + +;; Geraeteart -> Praefix (Einzelbefehle unten, noch nicht im Menue) +(setq *hw-sym-arten* + '(("ventile" "MB") ("sensoren" "BG") ("motoren" "MA") + ("motorschutz" "FC") ("encoder" "GF") ("scanner" "BX"))) + +;; ------------------------------------------------------------ +;; Reine Kerne (tests/test_unit.lsp, tu-tests-hw) +;; ------------------------------------------------------------ + +;; Name der Ausgabedatei aus dem Zeichnungsnamen: "Mubea.dwg" -> "Mubea_HW.dxf" (defun hw-dxf-name (dwgname) (strcat (vl-filename-base dwgname) "_HW.dxf") ) -(defun c:HW_MATERIALFLUSS ( / label out-dir csv-pfad dxf-pfad py-skript - py-exe log-pfad rc) - (setq label "HW_MATERIALFLUSS") - (if (null (getenv "DXFM_LIB")) - (princ (ssg-textf "exp-dxfm-lib-missing" (list label))) +;; Zahl fuer JSON, unabhaengig von DIMZIN/LUPREC (rtos wuerde bei DIMZIN- +;; Unterdrueckung der fuehrenden Null ".5" liefern - kein gueltiges JSON). +;; Auf 0.001 gerundet: (hw-json-zahl -12.5) -> "-12.500" +(defun hw-json-zahl (x / n r s) + (setq n (fix (+ (* (abs x) 1000.0) 0.5)) + r (itoa (rem n 1000))) + (while (< (strlen r) 3) (setq r (strcat "0" r))) + (setq s (strcat (itoa (/ n 1000)) "." r)) + (if (and (< x 0.0) (> n 0)) (strcat "-" s) s) +) + +(defun hw-json-text (s) + (strcat "\"" (ssg-json-escape s) "\"") +) + +;; Rollen-Objekt zum Rollennamen +(defun hw-sym-rolle (regeln name / erg) + (foreach r (ssg-json-get regeln "rollen") + (if (and (null erg) (= (ssg-json-get r "rolle") name)) (setq erg r))) + erg +) + +;; Rollennamen eines Bauteils - je Eintrag in "benoetigt" die erste Alternative +(defun hw-sym-bauteil-rollen (bauteil) + (mapcar 'car (ssg-json-get bauteil "benoetigt")) +) + +;; wcmatch-Muster aller Mechanik-Bloecke (gross, kommagetrennt) +(defun hw-sym-muster (regeln / muster) + (foreach b (ssg-json-get regeln "bauteile") + (setq muster (append muster (ssg-json-get b "bloecke")))) + (strcase (ssg-join muster ",")) +) + +;; Bauteil-Objekt zum Blocknamen (erster Treffer) oder nil +(defun hw-sym-bauteil-fuer-block (regeln blockname / erg) + (foreach b (ssg-json-get regeln "bauteile") + (if (and (null erg) + (wcmatch (strcase blockname) + (strcase (ssg-join (ssg-json-get b "bloecke") ",")))) + (setq erg b))) + erg +) + +;; Symbol (Blockname) einer Rolle, ggf. abhaengig vom Blocknamen des Bauteils +;; (block_nach_name, z.B. Vario-Motorstation rechts -> Motor VARIO rechts) +(defun hw-sym-block-fuer (rolle bauteil-blockname / erg) + (foreach v (ssg-json-get rolle "block_nach_name") + (if (and (null erg) + (wcmatch (strcase bauteil-blockname) (strcase (ssg-json-get v "muster")))) + (setq erg (ssg-json-get v "block")))) + (cond (erg) ((ssg-json-get rolle "block"))) +) + +;; IO-Vorlage eines Symbols: Praefix + io_vorlage, z.B. "MB" + "1@@@" -> "MB1@@@" +(defun hw-sym-io-vorlage (regeln praefix) + (strcat praefix (cond ((ssg-json-get regeln "io_vorlage")) ("1@@@"))) +) + +;; Zielpunkt eines Bauteils in der _HW.dxf: flach (Z = 0) - die _HW.dxf ist +;; ein Grundriss, die Nummerierung arbeitet in XY. +(defun hw-sym-ziel (pt) + (list (car pt) (cadr pt) 0.0) +) + +;; Plan aus Bauteil-Instanzen. instanz = (bauteil blockname pt key). +;; Rueckgabe: Liste von Records, je Rolle in der Reihenfolge von "benoetigt" +;; (= Stapelfolge der Symbole am Bauteil, erstes oben): +;; (praefix io-vorlage symbolblock zielpunkt rolle key bauteilname) +;; Den Einfuegepunkt rechnet lib/hw_symbole.py aus dem Zielpunkt und der +;; Symbolgeometrie (Marke REALE_POSITION auf dem Ziel, ohne Ueberlappung). +(defun hw-sym-plan (regeln instanzen / erg rolle) + (foreach ins instanzen + (foreach name (hw-sym-bauteil-rollen (car ins)) + (if (setq rolle (hw-sym-rolle regeln name)) + (setq erg (cons (list (ssg-json-get rolle "praefix") + (hw-sym-io-vorlage regeln (ssg-json-get rolle "praefix")) + (hw-sym-block-fuer rolle (nth 1 ins)) + (hw-sym-ziel (nth 2 ins)) + name + (nth 3 ins) + (ssg-json-get (car ins) "bauteil")) + erg))))) + (reverse erg) +) + +;; Sortierung der Instanzen: Bauteil-Reihenfolge im Regelwerk, dann x, dann y +(defun hw-sym-instanzen-sortieren (regeln instanzen / bauteile) + (setq bauteile (mapcar '(lambda (b) (ssg-json-get b "bauteil")) + (ssg-json-get regeln "bauteile"))) + ;; Rang vorab anhaengen (rang . instanz), sortieren, wieder abnehmen + (mapcar 'cdr + (vl-sort + (mapcar '(lambda (ins) + (cons (vl-position (ssg-json-get (car ins) "bauteil") bauteile) ins)) + instanzen) + '(lambda (a b) + (cond ((/= (car a) (car b)) (< (car a) (car b))) + ((not (equal (car (nth 3 a)) (car (nth 3 b)) 0.5)) + (< (car (nth 3 a)) (car (nth 3 b)))) + (t (< (cadr (nth 3 a)) (cadr (nth 3 b)))))))) +) + +;; Symbolplan als JSON-Zeilen fuer lib/hw_symbole.py. praefixe = die +;; Geraetearten, die dieser Lauf einfuegt bzw. ersetzt; der Plan selbst ist +;; immer vollstaendig, damit die Lage nicht davon abhaengt. +(defun hw-sym-plan->json (praefixe plan / zeilen) + (setq zeilen + (mapcar + '(lambda (r) + (strcat " {\"praefix\": " (hw-json-text (nth 0 r)) + ", \"io\": " (hw-json-text (nth 1 r)) + ", \"block\": " (hw-json-text (nth 2 r)) + ", \"x\": " (hw-json-zahl (car (nth 3 r))) + ", \"y\": " (hw-json-zahl (cadr (nth 3 r))) + ", \"rolle\": " (hw-json-text (nth 4 r)) + ", \"key\": " (hw-json-text (nth 5 r)) + ", \"bauteil\": " (hw-json-text (nth 6 r)) "}")) + plan)) + (append + (list "{" + (strcat " \"praefixe\": [" (ssg-join (mapcar 'hw-json-text praefixe) ", ") "],") + " \"symbole\": [") + (if zeilen + (append (mapcar '(lambda (z) (strcat z ",")) (reverse (cdr (reverse zeilen)))) + (list (last zeilen)))) + (list " ]" "}")) +) + +;; Name einer neuen Ausgabedatei: Leerzeichen am Rand und eine Endung .dxf +;; werden entfernt (" Anlage_HW.DXF " -> "Anlage_HW") +(defun hw-name-ohne-dxf (name / n) + (setq n (vl-string-trim " " (cond (name) ("")))) + (if (wcmatch (strcase n) "*.DXF") + (substr n 1 (- (strlen n) 4)) + n) +) + +;; Gueltiger Dateiname: nicht leer, keine Pfad- oder Sonderzeichen +(defun hw-name-gueltig-p (name / n) + (setq n (hw-name-ohne-dxf name)) + (and (> (strlen n) 0) + (not (wcmatch n "*[\\/:`*?\"<>|]*"))) +) + +(defun hw-neu-pfad (ordner name) + (strcat ordner "/" (hw-name-ohne-dxf name) ".dxf") +) + +;; Eingaben des Ausgabe-Dialogs pruefen. datei-da / neu-da = ob die gewaehlte +;; bzw. die neue Datei schon existiert. Rueckgabe: nil oder ssg-text-Key. +(defun hw-ausgabe-pruefen (modus datei-da name neu-da) + (cond + ((= modus "ergaenzen") (if datei-da nil "hw-aus-datei-fehlt")) + ((= modus "neu") + (cond ((not (hw-name-gueltig-p name)) "hw-aus-name-ungueltig") + (neu-da "hw-aus-name-existiert"))) + ((= modus "aktuell") nil) + (t "hw-aus-datei-fehlt")) +) + +;; Nummerierung uebertragen: passt ein Eintrag aus der create_numbers-Info +;; (old_value + Einfuegepunkt) auf ein Attribut? pos/wert aus der Zeichnung. +(defun hw-num-eintrag-p (eintrag wert pos / p) + (setq p (ssg-json-get eintrag "position")) + (and (= (ssg-json-get eintrag "old_value") wert) + (equal (ssg-json-get p "x") (car pos) 0.01) + (equal (ssg-json-get p "y") (cadr pos) 0.01)) +) + +;; ------------------------------------------------------------ +;; Gemeinsam: Ausgabeordner und Python-Aufruf +;; ------------------------------------------------------------ + +;; DXFM_RESULTS, ausser waehrend TEST_EXPORT_ALL (*export-test-override*) +(defun hw-ausgabe-ordner ( / d) + (setq d (if *export-test-override* *export-test-override* (getenv "DXFM_RESULTS"))) + (if (and d (not (vl-file-directory-p d))) (vl-mkdir d)) + d +) + +;; Python-Skript aus DXFM_LIB synchron ausfuehren, Log nach DXFM_LOG. +;; Rueckgabe: Exitcode (0 = ok) oder nil, wenn gar nicht gestartet. +;; ok-codes = Exitcodes, die KEIN Python-Fehler sind (create_numbers endet +;; mit 1, wenn es Planungsfehler meldet - die stehen in seiner Fehlerdatei). +(defun hw-python (label skript args log-name ok-codes / log-pfad rc) + (setq log-pfad (strcat (cond ((getenv "DXFM_LOG")) ((hw-ausgabe-ordner))) "/" log-name)) + (setq rc (export:run-python (export:python-exe) + (strcat (getenv "DXFM_LIB") "/" skript) args log-pfad)) + (if (null rc) (princ (ssg-textf "exp-python-call-failed" (list label)))) + (export:log-ausgeben log-pfad 15) + (if (and rc (not (member rc (cons 0 ok-codes)))) + (princ (ssg-textf "exp-python-error" (list label rc log-pfad)))) + rc +) + +(defun hw-umgebung-ok-p (label) + (if (getenv "DXFM_LIB") + T + (progn (princ (ssg-textf "exp-dxfm-lib-missing" (list label))) nil)) +) + +;; ------------------------------------------------------------ +;; Ausgabeziel: Dialog dcl/hw_ausgabe.dcl +;; ------------------------------------------------------------ + +(defun hw-ausgabe-modus-tiles (modus) + (mode_tile "datei" (if (= modus "ergaenzen") 0 1)) + (mode_tile "waehlen" (if (= modus "ergaenzen") 0 1)) + (mode_tile "name" (if (= modus "neu") 0 1)) +) + +;; OK-Knopf: Eingaben pruefen, bei Fehler im Dialog bleiben. Arbeitet auf den +;; Variablen von hw-ausgabe-waehlen (modus datei name ordner pfad - AutoLISP +;; bindet dynamisch, der Rueckruf laeuft waehrend dessen start_dialog). +(defun hw-ausgabe-ok ( / fehler) + (setq modus (get_tile "modus") + datei (get_tile "datei") + name (get_tile "name")) + (setq fehler (hw-ausgabe-pruefen modus + (if (and (/= datei "") (findfile datei)) T) + name + (if (findfile (hw-neu-pfad ordner name)) T))) + (if fehler + (set_tile "error" (ssg-text fehler)) (progn - (setq out-dir (if *export-test-override* - *export-test-override* - (getenv "DXFM_RESULTS"))) + (setq pfad (cond ((= modus "aktuell") + (strcat ordner "/" (vl-filename-base (getvar "DWGNAME")) "_HW_tmp.dxf")) + ((= modus "ergaenzen") datei) + (t (hw-neu-pfad ordner name)))) + (done_dialog 1))) +) + +;; Ziel der Ausgabe erfragen. aktion = Text oben im Dialog. +;; Rueckgabe: (modus pfad) mit modus "aktuell"/"ergaenzen"/"neu" - bei +;; "aktuell" ist pfad die Zwischendatei - oder nil bei Abbruch. Ohne GUI +;; (automatische Tests): Standarddatei _HW.dxf, anlegen oder ergaenzen. +(defun hw-ausgabe-waehlen (aktion / ordner modus datei name pfad dcl dat r f erg weiter) + (setq ordner (hw-ausgabe-ordner)) + (cond + ((not (ssg-gui-p)) + (list "ergaenzen" (strcat ordner "/" (hw-dxf-name (getvar "DWGNAME"))))) + ((or (null (getenv "DXFM_DCL")) + (not (findfile (setq dcl (strcat (getenv "DXFM_DCL") "/hw_ausgabe.dcl")))) + (< (setq dat (load_dialog dcl)) 0)) + (princ (ssg-textf "hw-aus-dcl-fehlt" (list (cond (dcl) ("hw_ausgabe.dcl"))))) + nil) + (t + (setq modus (cond ((car *hw-ausgabe-letzt*)) ("neu")) + datei (cond ((cadr *hw-ausgabe-letzt*)) + ((strcat ordner "/" (hw-dxf-name (getvar "DWGNAME"))))) + name (cond ((caddr *hw-ausgabe-letzt*)) + ((vl-filename-base (hw-dxf-name (getvar "DWGNAME"))))) + weiter T) + (while weiter + (if (not (new_dialog "hw_ausgabe" dat)) + (setq weiter nil) + (progn + (set_tile "aktion" aktion) + (set_tile "modus" modus) + (set_tile "datei" datei) + (set_tile "name" name) + (set_tile "ordner" (ssg-textf "hw-aus-ordner" (list ordner))) + (hw-ausgabe-modus-tiles modus) + (action_tile "modus" "(setq modus $value) (hw-ausgabe-modus-tiles modus) (set_tile \"error\" \"\")") + ;; getfiled geht nicht aus einem Rueckruf: Dialog schliessen, + ;; waehlen, Dialog neu zeigen + (action_tile "waehlen" + "(setq datei (get_tile \"datei\") name (get_tile \"name\")) (done_dialog 2)") + (action_tile "accept" "(hw-ausgabe-ok)") + (action_tile "cancel" "(done_dialog 0)") + (setq r (start_dialog)) + (cond + ((= r 2) + (if (setq f (getfiled (ssg-text "hw-aus-waehlen") + (if (and (/= datei "") (findfile datei)) datei (strcat ordner "/")) + "dxf" 0)) + (setq datei f modus "ergaenzen"))) + ((= r 1) (setq weiter nil erg (list modus pfad))) + (t (setq weiter nil)))))) + (unload_dialog dat) + (if erg (setq *hw-ausgabe-letzt* (list modus datei name))) + erg)) +) + +;; Zieldatei fuer Python vorbereiten: die Zwischendatei der aktuellen +;; Zeichnung wird jedesmal frisch angelegt. Rueckgabe: Pfad. +(defun hw-ziel-pfad (ziel) + (if (and (= (car ziel) "aktuell") (findfile (cadr ziel))) + (vl-file-delete (cadr ziel))) + (cadr ziel) +) + +;; Zusatzargumente fuer Python: Zwischendatei ohne Einheit (sonst skaliert +;; BricsCAD sie beim Einfuegen in eine Zeichnung mit anderer Einheit) +(defun hw-zusatz-args (ziel) + (if (= (car ziel) "aktuell") '("--einheitenlos")) +) + +;; Fruehere Ausgabe in der aktuellen Zeichnung: alle Objekte mit XDATA app, +;; bei praefixe nur die, deren erster 1000er-Wert (Praefix) darin steht. +(defun hw-alte-ausgabe (app praefixe / ss i e xd erg) + (if (setq ss (ssget "_X" (list (list -3 (list app))))) + (progn + (setq i 0) + (while (setq e (ssname ss i)) + (setq xd (cdr (assoc app (cdr (assoc -3 (entget e (list app))))))) + (if (or (null praefixe) (member (cdr (assoc 1000 xd)) praefixe)) + (setq erg (cons e erg))) + (setq i (1+ i))))) + erg +) + +;; Zwischendatei als Block bei 0,0 einfuegen und aufloesen; erst wenn das +;; geklappt hat, die fruehere Ausgabe (alte) loeschen. Rueckgabe: T/nil. +(defun hw-in-aktuelle-zeichnung (pfad alte / doc obj teile blk leer) + (setq doc (vla-get-ActiveDocument (vlax-get-acad-object))) + (setq obj (vl-catch-all-apply 'vla-InsertBlock + (list (vla-get-ModelSpace doc) (vlax-3D-point '(0 0 0)) pfad 1.0 1.0 1.0 0.0))) + (if (vl-catch-all-error-p obj) + nil + (progn + ;; Leere Zwischendatei (HW_SYM_LOESCHEN, keine Bauteile): nichts + ;; aufzuloesen - ein Explode wuerde scheitern, gilt aber als Erfolg + (setq blk (vl-catch-all-apply 'vla-Item (list (vla-get-Blocks doc) (vl-filename-base pfad)))) + (setq leer (and (not (vl-catch-all-error-p blk)) (= (vla-get-Count blk) 0))) + (if (not leer) (setq teile (vl-catch-all-apply 'vla-Explode (list obj)))) + (vla-Delete obj) + (if (not (vl-catch-all-error-p blk)) (vl-catch-all-apply 'vla-Delete (list blk))) + (if (vl-catch-all-error-p teile) + nil + (progn + (foreach e alte (if (entget e) (entdel e))) + (vl-file-delete pfad) + T)))) +) + +;; Ergebnis eines erfolgreichen Python-Laufs melden bzw. in die aktuelle +;; Zeichnung uebernehmen. app/praefixe: welche fruehere Ausgabe ersetzt wird. +(defun hw-ergebnis-uebernehmen (ziel app praefixe meldung / alte) + (if (= (car ziel) "aktuell") + (progn + (setq alte (hw-alte-ausgabe app praefixe)) + (if (hw-in-aktuelle-zeichnung (cadr ziel) alte) + (princ (ssg-textf "hw-fertig-aktuell" (list (itoa (length alte))))) + (princ (ssg-textf "hw-aktuell-fehler" (list (cadr ziel)))))) + (princ (ssg-textf meldung (list (cadr ziel))))) +) + +;; ------------------------------------------------------------ +;; MATERIALFLUSS +;; ------------------------------------------------------------ + +(defun c:HW_MATERIALFLUSS ( / label ziel out-dir csv-pfad dxf-pfad) + (setq label "HW_MATERIALFLUSS") + (if (and (hw-umgebung-ok-p label) + (setq ziel (hw-ausgabe-waehlen (ssg-text "hw-aus-materialfluss")))) + (progn + (setq out-dir (hw-ausgabe-ordner)) (setq csv-pfad (strcat out-dir "/" (export:dwg-praefix) (export:filename "csv_csv"))) - (setq dxf-pfad (strcat out-dir "/" (hw-dxf-name (getvar "DWGNAME")))) + (setq dxf-pfad (hw-ziel-pfad ziel)) ;; Alte CSV entfernen - sonst wuerde nach einem gescheiterten Export ;; still die Skizze aus dem vorigen Stand gezeichnet. (if (findfile csv-pfad) (vl-file-delete csv-pfad)) @@ -43,32 +429,179 @@ (if (not (findfile csv-pfad)) (princ (ssg-textf "hw-csv-fehlt" (list csv-pfad))) - (progn - (setq py-skript (strcat (getenv "DXFM_LIB") "/" *hw-skript*)) - (setq py-exe (export:python-exe)) - (setq log-pfad (strcat (cond ((getenv "DXFM_LOG")) (out-dir)) - "/hw_materialfluss.log")) - (setq rc (export:run-python py-exe py-skript - (list csv-pfad dxf-pfad) log-pfad)) - (cond - ((null rc) - (princ (ssg-textf "exp-python-call-failed" (list label)))) - ((/= rc 0) - (princ (ssg-textf "exp-python-error" (list label rc log-pfad))) - (export:log-ausgeben log-pfad 12)) - (t - (export:log-ausgeben log-pfad 12) - (princ (ssg-textf "hw-fertig" (list dxf-pfad)))) - ) - ) - ) - ) - ) + (if (equal 0 (hw-python label *hw-skript* + (append (list csv-pfad dxf-pfad) (hw-zusatz-args ziel)) + "hw_materialfluss.log" nil)) + (hw-ergebnis-uebernehmen ziel *hw-mf-app* nil "hw-fertig"))))) (princ) ) -(defun c:HW_SYMBOLE () - (alert (ssg-text "hw-symbole-offen")) +;; ------------------------------------------------------------ +;; SYMBOLE ERZEUGEN (HW_SYMBOLE + Einzelbefehle je Geraeteart) +;; ------------------------------------------------------------ + +(defun hw-sym-regeln-laden ( / pfad regeln) + (setq pfad (strcat (cond ((getenv "DXFM_CFG")) ("")) "/io_regeln.json")) + (setq regeln (if (findfile pfad) (ssg-json-laden pfad))) + (if (null regeln) (princ (ssg-textf "hw-sym-regeln-fehlt" (list pfad)))) + regeln +) + +;; Alle Bauteile der Ursprungszeichnung: top-level INSERTs und in VF_n/GF_n/ +;; KREISEL_n verschachtelte Mechanik-Bloecke (Weltlage ueber +;; csv:wrapper-welt-records, export.lsp). Katalogsymbole (ILS_*) werden +;; nicht durchsucht. Rueckgabe: sortierte Liste (bauteil blockname pt key). +(defun hw-sym-bauteile-sammeln (regeln / muster ss i ename ed name bt erg) + (setq muster (hw-sym-muster regeln)) + (if (setq ss (ssget "_X" '((0 . "INSERT") (410 . "Model")))) + (progn + (setq i 0) + (while (setq ename (ssname ss i)) + (setq ed (entget ename) + name (cdr (assoc 2 ed))) + (cond + ((wcmatch (strcase name) muster) + (if (setq bt (hw-sym-bauteil-fuer-block regeln name)) + (setq erg (cons (list bt name (trans (cdr (assoc 10 ed)) ename 0) + (cdr (assoc 5 ed))) + erg)))) + ((not (wcmatch (strcase name) "ILS_*,`**")) + (foreach rec (csv:wrapper-welt-records ename muster) + (if (setq bt (hw-sym-bauteil-fuer-block regeln (nth 2 rec))) + (setq erg (cons (list bt (nth 2 rec) (nth 3 rec) + (strcat (cdr (assoc 5 ed)) "/" + (cdr (assoc 5 (entget (nth 1 rec)))))) + erg)))))) + (setq i (1+ i))))) + (hw-sym-instanzen-sortieren regeln erg) +) + +(defun hw-sym-zaehlen (schluessel alist / alt) + (setq alt (assoc schluessel alist)) + (if alt + (subst (cons schluessel (1+ (cdr alt))) alt alist) + (cons (cons schluessel 1) alist)) +) + +;; Symbolplan als JSON schreiben und hw_symbole.py die Symbole der +;; angegebenen Praefixe ins gewaehlte Ziel setzen lassen (nil = alle Praefixe +;; des Regelwerks). mit-bauteilen = nil -> leerer Plan (nur entfernen). +(defun hw-sym-erzeugen (praefixe mit-bauteilen / label regeln instanzen plan out-dir + json-pfad dxf-pfad sym-pfad fh bt-zahl ziel) + (setq label "HW_SYMBOLE") + (if (and (hw-umgebung-ok-p label) + (setq regeln (hw-sym-regeln-laden)) + (setq ziel (hw-ausgabe-waehlen (ssg-text "hw-aus-symbole")))) + (progn + (if (null praefixe) + (foreach r (ssg-json-get regeln "rollen") + (if (not (member (ssg-json-get r "praefix") praefixe)) + (setq praefixe (append praefixe (list (ssg-json-get r "praefix"))))))) + (if mit-bauteilen + (progn + (setq instanzen (hw-sym-bauteile-sammeln regeln)) + (if (null instanzen) (princ (ssg-text "hw-sym-keine-bauteile"))) + (foreach ins instanzen + (setq bt-zahl (hw-sym-zaehlen (ssg-json-get (car ins) "bauteil") bt-zahl))) + (foreach z (reverse bt-zahl) + (princ (ssg-textf "hw-sym-bauteile" (list (car z) (itoa (cdr z)))))) + (setq plan (hw-sym-plan regeln instanzen)))) + (setq out-dir (hw-ausgabe-ordner) + json-pfad (strcat out-dir "/" (vl-filename-base (getvar "DWGNAME")) "_hw_symbole.json") + dxf-pfad (hw-ziel-pfad ziel) + sym-pfad (strcat (cond ((getenv "DXFM_CFG")) ("")) "/hw_symbole.json")) + (if (not (setq fh (open json-pfad "w"))) + (princ (ssg-textf "exp-file-open-error" (list label json-pfad))) + (progn + (foreach z (hw-sym-plan->json praefixe plan) (write-line z fh)) + (close fh) + (if (equal 0 (hw-python label *hw-sym-skript* + (append (list json-pfad dxf-pfad "--symbole" sym-pfad) + (hw-zusatz-args ziel)) + "hw_symbole.log" nil)) + (hw-ergebnis-uebernehmen ziel *hw-sym-app* praefixe "hw-fertig-symbole")))))) + (princ) +) + +(defun hw-sym-art (art) + (hw-sym-erzeugen (cdr (assoc art *hw-sym-arten*)) T) +) + +;; Menue: HW Planung -> Symbole erzeugen +(defun c:HW_SYMBOLE () (hw-sym-erzeugen nil T)) + +;; Einzelbefehle je Geraeteart - noch nicht im Menue, zum schrittweisen +;; Erzeugen und Pruefen. Jeder ersetzt in der _HW.dxf nur die Symbole +;; seiner Art. +(defun c:HW_SYM_VENTILE () (hw-sym-art "ventile")) +(defun c:HW_SYM_SENSOREN () (hw-sym-art "sensoren")) +(defun c:HW_SYM_MOTOREN () (hw-sym-art "motoren")) +(defun c:HW_SYM_MOTORSCHUTZ () (hw-sym-art "motorschutz")) +(defun c:HW_SYM_ENCODER () (hw-sym-art "encoder")) +(defun c:HW_SYM_SCANNER () (hw-sym-art "scanner")) + +;; Alle erzeugten Symbole aus dem gewaehlten Ziel entfernen +(defun c:HW_SYM_LOESCHEN () (hw-sym-erzeugen nil nil)) + +;; ------------------------------------------------------------ +;; NUMMERIEREN (in der geoeffneten _HW.dxf) +;; ------------------------------------------------------------ + +;; Nummern aus der create_numbers-Info in die offene Zeichnung eintragen: +;; jedes Attribut mit '@', dessen Wert und Einfuegepunkt zu einem Eintrag +;; passen, bekommt new_value. Rueckgabe: Anzahl geaenderter Attribute. +(defun hw-num-anwenden (eintraege / ss i e pos obj ed wert treffer n) + (setq n 0) + (if (setq ss (ssget "_X" '((0 . "INSERT") (66 . 1) (410 . "Model")))) + (progn + (setq i 0) + (while (setq e (ssname ss i)) + (setq pos (cdr (assoc 10 (entget e)))) + (setq obj (entnext e)) + (while (and obj (= (cdr (assoc 0 (setq ed (entget obj)))) "ATTRIB")) + (setq wert (cdr (assoc 1 ed))) + (if (vl-string-search "@" wert) + (progn + (setq treffer nil) + (foreach z eintraege + (if (and (null treffer) (hw-num-eintrag-p z wert pos)) (setq treffer z))) + (if treffer + (progn + (entmod (subst (cons 1 (ssg-json-get treffer "new_value")) (assoc 1 ed) ed)) + (setq n (1+ n)))))) + (setq obj (entnext obj))) + (entupd e) + (setq i (1+ i))))) + n +) + +(defun c:HW_NUMMERIEREN ( / label pfad basis info-pfad fehler-pfad rc info n) + (setq label "HW_NUMMERIEREN") + (setq pfad (strcat (getvar "DWGPREFIX") (getvar "DWGNAME"))) + (cond + ((not (hw-umgebung-ok-p label))) + ((or (= (getvar "DWGTITLED") 0) + (/= (strcase (vl-filename-extension pfad)) ".DXF")) + (princ (ssg-textf "hw-num-kein-dxf" (list (getvar "DWGNAME"))))) + (t + ;; create_numbers liest die Datei von der Platte - erst speichern + (command "_.QSAVE") + (setq basis (strcat (getvar "DWGPREFIX") (vl-filename-base pfad)) + info-pfad (strcat basis "_nummern.json") + fehler-pfad (strcat basis "_nummern_fehler.json")) + (foreach f (list info-pfad fehler-pfad) (if (findfile f) (vl-file-delete f))) + ;; --dryrun: create_numbers schreibt keine DXF, nur die Info - die + ;; Nummern werden hier in die OFFENE Zeichnung eingetragen. + (setq rc (hw-python label *hw-num-skript* + (list "--filename" pfad "--dryrun" + "--info" info-pfad "--errorfile" fehler-pfad) + "hw_nummerieren.log" '(1))) + (if (and rc (setq info (if (findfile info-pfad) (ssg-json-laden info-pfad)))) + (progn + (setq n (hw-num-anwenden (ssg-json-get info "renamed_symbols"))) + (princ (ssg-textf "hw-num-ergebnis" (list (itoa n)))))) + (if (findfile fehler-pfad) + (princ (ssg-textf "hw-num-fehler" (list fehler-pfad)))))) (princ) ) diff --git a/Lisp/README.md b/Lisp/README.md index 8fcc64a..d6383a8 100644 --- a/Lisp/README.md +++ b/Lisp/README.md @@ -926,6 +926,22 @@ Details (aufgerichtete Geometrie, Attribut-Schema, Sivas-Export): `../CLAUDE.md` --- +### `HwPlanung.lsp` – HW-Planung (Materialfluss, Symbole, Nummerierung) + +| Befehl | Beschreibung | +| --- | --- | +| `c:HW_MATERIALFLUSS` | In der Ursprungszeichnung: EXPORTCSV -> `lib/hw_materialfluss.py`, Materialfluss-Polylinien in `_HW.dxf` | +| `c:HW_SYMBOLE` | In der Ursprungszeichnung: Bauteile nach `cfg/io_regeln.json` suchen, `lib/hw_symbole.py` setzt die Symbole (Aussehen aus `cfg/hw_symbole.json`, IO-Vorlage `MB1@@@` usw.) in `_HW.dxf` | +| `c:HW_SYM_VENTILE` / `_SENSOREN` / `_MOTOREN` / `_MOTORSCHUTZ` / `_ENCODER` / `_SCANNER` | Nur eine Geraeteart (MB/BG/MA/FC/GF/BX) erzeugen bzw. ersetzen (nicht im Menue) | +| `c:HW_SYM_LOESCHEN` | Alle erzeugten Symbole aus `_HW.dxf` entfernen (nicht im Menue) | +| `c:HW_NUMMERIEREN` | In der geoeffneten `_HW.dxf`: speichern, `lib/create_numbers.py` nummeriert entlang der Renamer-Bereiche, Nummern werden in die offene Zeichnung eingetragen | + +Vor HW_MATERIALFLUSS, HW_SYMBOLE und HW_SYM_* fragt `dcl/hw_ausgabe.dcl`, wohin: aktuelle Zeichnung, bestehende Datei ergaenzen oder neue Datei. + +Ablauf, Renamer-Bereiche und Regelwerk: `../doc/hw_planung.md`. + +--- + ### Werkzeuge: `ssg_stueckliste.lsp`, `DinFormate.lsp` | Befehl | Beschreibung | diff --git a/cfg/enumerate.cfg b/cfg/enumerate.cfg new file mode 100644 index 0000000..c55029c --- /dev/null +++ b/cfg/enumerate.cfg @@ -0,0 +1,8 @@ +[Layers] +ILS_RENAMER +Omniflo_RENAMER + +[Parameters] +# Maximale Distanz (in DXF-Einheiten) eines Symbols zur Polylinie bei POLYLINE_PATH +# Symbole die weiter entfernt sind, werden nicht zur Nummerierung berücksichtigt +polyline_max_distance = 500.0 diff --git a/cfg/hw_symbole.json b/cfg/hw_symbole.json new file mode 100644 index 0000000..ca514bd --- /dev/null +++ b/cfg/hw_symbole.json @@ -0,0 +1,220 @@ +{ + "_kommentar": "Symboldefinitionen der HW-Planung. lib/hw_symbole.py baut daraus die Blockdefinitionen in der _HW.dxf selbst - es gibt keine Symbol-Bibliothek als Referenzdokument. Abgeleitet aus den ILS-Katalogsymbolen in kabellaengen/doc/Symbole/ILS-Symbole.dxf (Stand 2026-11-03); aendert sich dort ein Symbol, hier nachziehen. Welches Symbol an welchem Bauteil sitzt, steht in cfg/io_regeln.json (rollen -> block). Bei jedem Lauf wird die Blockdefinition neu aus dieser Datei aufgebaut.", + "_textstile": "Textstile der Attribute (Name -> Schriftdatei); werden in der _HW.dxf angelegt, falls sie fehlen. Ohne \"stil\" am Attribut gilt standard.", + "textstile": { + "standard": "ARIAL", + "schriften": {"ARIAL": "arial.ttf", "MtXpl_Arial_Narrow": "arialn.ttf"} + }, + "_platzierung": "Lage der Symbole in der _HW.dxf (lib/hw_symbole.py, platzieren): die Marke REALE_POSITION jedes Symbols liegt mit ihrem x genau auf dem Bauteil (Sensor, AS/ES-Element, Motor-/Kreiselzentrum), der Kasten daneben; mehrere Symbole eines Bauteils stehen uebereinander (Reihenfolge = benoetigt in cfg/io_regeln.json), als Gruppe um das Bauteil zentriert. Ueberlappt eine Gruppe mit einer schon gesetzten (Rahmen mit Mindestabstand abstand), wird sie in y um +-schritt, +-2*schritt ... bis max_verschiebung verschoben (mm).", + "platzierung": {"abstand": 20.0, "schritt": 50.0, "max_verschiebung": 3000.0}, + "_vorlagen": "Gemeinsames Aussehen mehrerer Symbole, uebernommen aus je einem Katalogsymbol (io_750: ILS_Separator-Ausgang, geraet_800: ILS_829624040-Encoder-800). rahmen = [Breite, Hoehe] ab Rahmen-Ecke links unten; attribute = Reihenfolge, Lage (x, y relativ zur Rahmen-Ecke), Texthoehe. Optional je Attribut: unsichtbar, farbe (Zahl; \"symbol\" = Farbe des Symbols, fehlt = VONLAYER), layer (fehlt = Layer des Symbols), ausrichtung (C/R = Grundlinie mittig/rechts, ML/MC/MR = Mitte links/mittig/rechts; fehlt = links), stil, breite (Breitenfaktor), wert (Vorgabe). schraffur = Fuellmuster im Rahmen.", + "vorlagen": { + "io_750": { + "rahmen": [750.0, 270.0], + "attribute": [ + {"tag": "SPS", "x": 0.0, "y": 130.4, "hoehe": 125.0, "farbe": "symbol"}, + {"tag": "IO", "x": 103.9, "y": 129.2, "hoehe": 125.0, "farbe": "symbol"}, + {"tag": "VERW", "x": 27.7, "y": 20.8, "hoehe": 70.0, "farbe": "symbol"}, + {"tag": "BEZEICHNUNG", "x": 1.1, "y": -83.8, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "KENNZEICHNUNG", "x": 1.1, "y": -163.8, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "ARTINR", "x": 1.1, "y": -248.6, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "BESCHR", "x": 1.1, "y": -336.9, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "TEXT-D", "x": 780.4, "y": 190.4, "hoehe": 60.0, "layer": "ILS_Text-D"}, + {"tag": "TEXT-E", "x": 772.9, "y": 110.4, "hoehe": 60.0, "layer": "ILS_Text-E"}, + {"tag": "TEXT-ES", "x": 780.4, "y": 30.4, "hoehe": 60.0, "layer": "ILS_Text-ES"}, + {"tag": "TEXT-F", "x": 780.4, "y": 30.4, "hoehe": 60.0, "layer": "ILS_Text-F"}, + {"tag": "TEXT-IT", "x": 780.4, "y": 30.4, "hoehe": 60.0, "layer": "ILS_Text-IT"}, + {"tag": "TEXT-CS", "x": 780.4, "y": 30.4, "hoehe": 60.0, "layer": "ILS_Text-CS"}, + {"tag": "ID", "x": 371.2, "y": 0.0, "hoehe": 80.0, "unsichtbar": true, "farbe": 7}, + {"tag": "REALE_POSITION", "x": 750.0, "y": 135.0, "hoehe": 50.0, "farbe": 7, "ausrichtung": "MR", "wert": "x"} + ] + }, + "geraet_800": { + "rahmen": [800.0, 350.0], + "attribute": [ + {"tag": "SPS", "x": 0.0, "y": 244.1, "hoehe": 140.0, "farbe": "symbol", "ausrichtung": "ML", "stil": "MtXpl_Arial_Narrow"}, + {"tag": "IO", "x": 400.0, "y": 244.1, "hoehe": 140.0, "farbe": "symbol", "ausrichtung": "MC", "stil": "MtXpl_Arial_Narrow"}, + {"tag": "VERW", "x": 400.0, "y": 77.2, "hoehe": 100.0, "farbe": "symbol", "ausrichtung": "MC", "breite": 0.8}, + {"tag": "KENNZEICHNUNG", "x": 10.3, "y": -253.8, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "ARTINR", "x": 10.3, "y": -88.9, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "BESCHR", "x": 10.3, "y": -173.8, "hoehe": 60.0, "unsichtbar": true, "farbe": 7}, + {"tag": "ID", "x": 0.0, "y": 0.0, "hoehe": 80.0, "unsichtbar": true, "farbe": 7}, + {"tag": "REALE_POSITION", "x": 800.0, "y": 175.0, "hoehe": 50.0, "farbe": 7, "ausrichtung": "MR", "wert": "x"} + ], + "schraffur": {"muster": "INSULATION", "massstab": 0.25} + } + }, + "_symbole": "Blockname -> vorlage, layer, farbe (fuer Attribute mit farbe \"symbol\"), versatz (Lage der Rahmen-Ecke zum Einfuegepunkt: Eingaenge/Motoren haengen im Katalog nach unten, [0, -270]), attribute (Abweichungen von der Vorlage je Tag), ohne (Vorlagen-Attribute, die dieses Symbol nicht hat), schraffur_layer/-farbe, werte. IO traegt die Platzhalter-ID (z.B. MB0): hw_symbole.py ersetzt sie in IO, VERW und BEZEICHNUNG durch die ID-Vorlage aus cfg/io_regeln.json (z.B. MB1@@@). Umlaute als \\u-Escape, damit die Datei ASCII bleibt.", + "symbole": { + "ILS_Separator-Ausgang": { + "vorlage": "io_750", + "layer": "ILS_Ausgang", + "farbe": 3, + "werte": {"SPS": "1", "IO": "MB0", "VERW": "Sep", "BEZEICHNUNG": "Separator ILS-VARIO (MA0)", "KENNZEICHNUNG": "=A01+UC0000-KF1DQ1", "ARTINR": "929012603", "BESCHR": "WEGEVENT.5/2 MONOSTABIL SY5120-5WAOU-C6F-Q"} + }, + "ILS_Weiche-Abzweig-Ausgang": { + "vorlage": "io_750", + "layer": "ILS_Ausgang", + "farbe": 3, + "versatz": [0.0, -0.7], + "attribute": { + "IO": {"y": 130.4}, + "VERW": {"x": 27.0}, + "BEZEICHNUNG": {"x": 2.0, "y": -91.2}, + "KENNZEICHNUNG": {"x": 2.0, "y": -171.2}, + "ARTINR": {"x": 2.0, "y": -247.8}, + "BESCHR": {"x": 2.0, "y": -327.8}, + "TEXT-E": {"y": 111.2}, + "TEXT-IT": {"layer": "TEXT-IT"}, + "ID": {"x": 0.0, "y": 0.7} + }, + "werte": {"SPS": "1", "IO": "MB0", "VERW": "Weiche Abzweig", "BEZEICHNUNG": "Weiche Abzweig ILS-VARIO", "KENNZEICHNUNG": "=A01+UC0000-KF1DQ1", "ARTINR": "929012603", "BESCHR": "WEGEVENT.5/2 MONOSTABIL SY5120-5WAOU-C6F-Q"} + }, + "ILS_720002003-Separator": { + "vorlage": "io_750", + "layer": "ILS_Eingang", + "farbe": 6, + "versatz": [0.0, -270.0], + "attribute": { + "VERW": {"x": 17.0}, + "BEZEICHNUNG": {"x": -5.7, "y": -83.0}, + "KENNZEICHNUNG": {"x": -5.7, "y": -171.3}, + "ARTINR": {"x": -5.7, "y": -258.1}, + "BESCHR": {"x": -5.7, "y": -338.1} + }, + "werte": {"SPS": "1", "IO": "BG0", "VERW": "In Sep", "BEZEICHNUNG": "in Separator ILS-VARIO (MA0)", "KENNZEICHNUNG": "=A01+UC0000-KF1DI1", "ARTINR": "720002003", "BESCHR": "Sensor Induk. M18x1, SA=12mm N-B\u00dc/NO, Kabel-150mm"} + }, + "ILS_720002011-Rad dreht": { + "vorlage": "io_750", + "layer": "ILS_Eingang", + "farbe": 6, + "attribute": { + "IO": {"y": 130.4}, + "VERW": {"x": 23.0}, + "BEZEICHNUNG": {"x": 0.0, "y": -67.0}, + "KENNZEICHNUNG": {"x": 0.0, "y": -147.0}, + "ARTINR": {"x": 0.0, "y": -227.0}, + "BESCHR": {"x": 0.0, "y": -307.0}, + "TEXT-IT": {"farbe": 7}, + "ID": {"x": 0.0, "y": 0.7} + }, + "werte": {"SPS": "1", "IO": "BG0", "VERW": "Rad dreht", "BEZEICHNUNG": "Rad dreht ILS-KR (MA0)", "KENNZEICHNUNG": "=A01+UC0000-KF1DI1", "ARTINR": "720002011", "BESCHR": "Sensor Induk. M18 8mm b\u00fcndig/PNP-NO/ST M12"} + }, + "ILS_200000375-Finger in Position": { + "vorlage": "io_750", + "layer": "ILS_Eingang", + "farbe": 6, + "attribute": { + "SPS": {"x": 10.0}, + "IO": {"y": 130.4}, + "VERW": {"x": 29.3, "breite": 0.85}, + "BEZEICHNUNG": {"x": -1.0, "y": -67.0}, + "KENNZEICHNUNG": {"x": -1.0, "y": -149.7}, + "ARTINR": {"x": -1.0, "y": -239.4}, + "BESCHR": {"x": -1.0, "y": -319.4}, + "TEXT-F": {"layer": "TEXT-F"}, + "TEXT-IT": {"farbe": 7}, + "ID": {"x": 400.0, "ausrichtung": "R"} + }, + "werte": {"SPS": "1", "IO": "BG0", "VERW": "Finger in Position", "BEZEICHNUNG": "Finger in Position", "KENNZEICHNUNG": "=A01+UC0000-KF1DI1", "ARTINR": "200000375", "BESCHR": "Halter mit Sensor f\u00fcr Abfrage VARIO Finger"} + }, + "ILS_200000248-PC in Position": { + "vorlage": "io_750", + "layer": "ILS_Eingang", + "farbe": 6, + "attribute": { + "VERW": {"x": 18.4}, + "BEZEICHNUNG": {"x": 0.0, "y": -67.0}, + "KENNZEICHNUNG": {"x": 0.0, "y": -147.0}, + "ARTINR": {"x": 0.0, "y": -227.0}, + "BESCHR": {"x": 0.0, "y": -307.0}, + "ID": {"x": 375.0} + }, + "werte": {"SPS": "1", "IO": "BG0", "VERW": "PC in Position", "BEZEICHNUNG": "PC in Position (MA0)", "KENNZEICHNUNG": "=A01+UC0000-KF1DI1", "ARTINR": "200000248", "BESCHR": "Lichttaster LP+SP als Staumelder"} + }, + "ILS_Motorschutz": { + "vorlage": "io_750", + "layer": "ILS_Eingang", + "ohne": ["ARTINR", "BESCHR"], + "attribute": { + "IO": {"y": 130.4}, + "VERW": {"x": 21.4}, + "BEZEICHNUNG": {"x": -10.1, "y": -67.0}, + "KENNZEICHNUNG": {"x": -10.1, "y": -155.3}, + "TEXT-E": {"x": 780.4}, + "ID": {"x": 375.0} + }, + "werte": {"SPS": "1", "IO": "FC0", "VERW": "MS FC0", "BEZEICHNUNG": "MSS ausgel\u00f6st FC0", "KENNZEICHNUNG": "=A01+UH01-KF1DI1"} + }, + "ILS_Motor-400102811-Kreisel": { + "vorlage": "io_750", + "layer": "ILS_Motor", + "versatz": [0.0, -270.0], + "attribute": { + "IO": {"y": 130.4}, + "VERW": {"x": 6.9, "breite": 0.9}, + "BEZEICHNUNG": {"x": 0.0, "y": -79.3}, + "KENNZEICHNUNG": {"x": 0.0, "y": -171.6}, + "ARTINR": {"x": 0.0, "y": -250.7}, + "BESCHR": {"x": 0.0, "y": -334.5}, + "ID": {"x": 375.0} + }, + "werte": {"SPS": "1", "IO": "MA0", "VERW": "ILS-KR-MA0", "BEZEICHNUNG": "Motor ILS-KR-MA0", "KENNZEICHNUNG": "=A01+UH01-KF1FDQ1", "ARTINR": "400102811", "BESCHR": "Kegelradgetriebemotor KF47 DRN80MK4/ASE1/Z Pos.1"} + }, + "ILS_Motor-200000020-VARIO-links": { + "vorlage": "io_750", + "layer": "ILS_Motor", + "versatz": [0.0, -270.0], + "attribute": { + "IO": {"y": 130.4}, + "VERW": {"x": 6.9, "breite": 0.85}, + "BEZEICHNUNG": {"x": 0.0, "y": -90.5}, + "KENNZEICHNUNG": {"x": 0.0, "y": -182.8}, + "ARTINR": {"x": 0.0, "y": -278.8}, + "BESCHR": {"x": 0.0, "y": -362.6}, + "TEXT-E": {"x": 780.4}, + "ID": {"x": 375.0} + }, + "werte": {"SPS": "1", "IO": "MA0", "VERW": "ILS-VARIO-0,55kW", "BEZEICHNUNG": "Motor MA0", "KENNZEICHNUNG": "=A01+UH01-KF1FDQ1", "ARTINR": "200000020", "BESCHR": "Schneckengetriebem. SA57 DRN80MK4/ASE1 VARIO LI 0,55 KW, v=29 bei 50Hz, links"} + }, + "ILS_Motor-200000021-VARIO-rechts": { + "vorlage": "io_750", + "layer": "ILS_Motor", + "versatz": [0.0, -270.0], + "attribute": { + "IO": {"y": 130.4}, + "VERW": {"x": 6.9, "breite": 0.85}, + "BEZEICHNUNG": {"x": 0.0, "y": -90.5}, + "KENNZEICHNUNG": {"x": 0.0, "y": -182.8}, + "ARTINR": {"x": 0.0, "y": -278.8}, + "BESCHR": {"x": 0.0, "y": -362.6}, + "TEXT-E": {"x": 780.4}, + "ID": {"x": 375.0} + }, + "werte": {"SPS": "1", "IO": "MA0", "VERW": "ILS-VARIO-0,55kW", "BEZEICHNUNG": "Motor MA0", "KENNZEICHNUNG": "=A01+UH01-KF1FDQ1", "ARTINR": "200000021", "BESCHR": "Schneckengetriebem. SA57 DRN80MK4/ASE1 VARIO RE 0,55 KW, v=29 bei 50Hz, rechts"} + }, + "ILS_829624040-Encoder-800": { + "vorlage": "geraet_800", + "layer": "ILS_Encoder-800", + "farbe": 1, + "werte": {"IO": "GF0", "VERW": "192.168.1.100", "KENNZEICHNUNG": "=A01+UH01-KF1DQ1", "ARTINR": "829624040", "BESCHR": "Encoder f\u00fcr ILS 2.0 Rad-800"} + }, + "ILS_Scanner-829534306": { + "vorlage": "geraet_800", + "layer": "ILS_Scanner", + "schraffur_layer": "0", + "schraffur_farbe": 40, + "attribute": { + "SPS": {"y": 254.4, "hoehe": 125.0, "stil": null}, + "IO": {"y": 254.4, "hoehe": 150.0}, + "VERW": {"y": 81.3}, + "KENNZEICHNUNG": {"x": -1.0, "y": -245.7}, + "ARTINR": {"x": -1.0, "y": -85.7}, + "BESCHR": {"x": -1.0, "y": -165.7}, + "ID": {"x": 450.0, "ausrichtung": "R"}, + "REALE_POSITION": {"y": 135.0} + }, + "werte": {"IO": "BX0", "VERW": "192.168.1.100", "KENNZEICHNUNG": "=A01+UH01-KF1FDQ1", "ARTINR": "829534306", "BESCHR": "Camera DM280 m. Halter (ILS) - BG"} + } + } +} diff --git a/cfg/io_regeln.json b/cfg/io_regeln.json new file mode 100644 index 0000000..ee9efab --- /dev/null +++ b/cfg/io_regeln.json @@ -0,0 +1,42 @@ +{ + "_kommentar": "Regelwerk fuer die Symbolerzeugung der HW-Planung (Lisp/HwPlanung.lsp, HW_SYMBOLE): welche E-Geraete (Sensoren, Aktoren, Motoren) an welchem Anlagenbauteil sitzen und mit welchem Katalogsymbol sie gezeichnet werden. Uebernommen aus kabellaengen/cfg/io_regeln.json (dort Grundlage der Pruefung io_zuordnung_pruefen.py, abgeleitet aus Mubea, Stand 2026-10-30); hier nur noch die Teile, die zum Erzeugen noetig sind. Erkennungs-Regex, Suchradien, Nummernpaare und der Konverter-Abschnitt altZuNeu sind entfallen - die Nummern vergibt create_numbers.py entlang der Renamer-Bereiche.", + + "_io_vorlage": "Geraete-ID beim Erzeugen = praefix + io_vorlage (z.B. MB1@@@). Die '@' ersetzt erst die Nummerierung (HW_NUMMERIEREN, lib/create_numbers.py) entlang der Renamer-Bereiche; die Anzahl '@' muss zum NAME-Muster des Renamer-Blocks passen (z.B. BG-1@@@).", + "io_vorlage": "1@@@", + + "_rollen": "rolle -> Geraete-Praefix und Symbol (Blockname; Aussehen, Layer und Attribute stehen in cfg/hw_symbole.json). block_nach_name: wcmatch-Muster auf den Blocknamen des Bauteils -> abweichender Katalogblock (erster Treffer gewinnt).", + "rollen": [ + {"rolle": "sep_ventil", "praefix": "MB", "block": "ILS_Separator-Ausgang"}, + {"rolle": "sep_sensor", "praefix": "BG", "block": "ILS_720002003-Separator"}, + {"rolle": "ae_ventil", "praefix": "MB", "block": "ILS_Weiche-Abzweig-Ausgang"}, + {"rolle": "kreisel_motor", "praefix": "MA", "block": "ILS_Motor-400102811-Kreisel"}, + {"rolle": "vario_motor", "praefix": "MA", "block": "ILS_Motor-200000020-VARIO-links", + "block_nach_name": [{"muster": "*RECHTS*", "block": "ILS_Motor-200000021-VARIO-rechts"}]}, + {"rolle": "motorschutz", "praefix": "FC", "block": "ILS_Motorschutz"}, + {"rolle": "rad_dreht", "praefix": "BG", "block": "ILS_720002011-Rad dreht"}, + {"rolle": "encoder", "praefix": "GF", "block": "ILS_829624040-Encoder-800"}, + {"rolle": "finger", "praefix": "BG", "block": "ILS_200000375-Finger in Position"}, + {"rolle": "pc_in_pos", "praefix": "BG", "block": "ILS_200000248-PC in Position"}, + {"rolle": "scanner", "praefix": "BX", "block": "ILS_Scanner-829534306"} + ], + + "_bauteile": "Mechanik-Bloecke (wcmatch, Gross/Klein egal; auch verschachtelt in VF_n/GF_n/KREISEL_n) und die Rollen, die an jedem davon sitzen. Jeder Eintrag in benoetigt ist eine Liste von Alternativen - erzeugt wird die ERSTE. Die Reihenfolge ist zugleich die Stapelfolge der Symbole am Bauteil (erstes oben).", + "bauteile": [ + {"bauteil": "separator", "bloecke": ["Separator_SP*", "Separator_LP*", "S-SP", "S-LP"], + "benoetigt": [["sep_ventil"], ["sep_sensor"]]}, + {"bauteil": "ausschleuser", "bloecke": ["AS_Element*", "AE-D", "AE DS"], + "benoetigt": [["ae_ventil"]]}, + {"bauteil": "entladung", "bloecke": ["SC_Entladung*"], + "benoetigt": [["ae_ventil"]]}, + {"bauteil": "kreisel_antrieb", "bloecke": ["AN8", "AN8_*"], + "benoetigt": [["kreisel_motor"], ["motorschutz"]]}, + {"bauteil": "kreisel_spannstation", "bloecke": ["SP8", "SP8_*"], + "benoetigt": [["rad_dreht", "encoder"]]}, + {"bauteil": "vario_motorstation", "bloecke": ["Vario_Motorstation*", "Motorstation*"], + "benoetigt": [["vario_motor"], ["motorschutz"]]}, + {"bauteil": "vario_umlenkstation", "bloecke": ["Vario_Umlenkstation*", "Umlenkstation*"], + "benoetigt": [["finger"], ["pc_in_pos"]]}, + {"bauteil": "scanner", "bloecke": ["Scanner*", "SCAN"], + "benoetigt": [["scanner"]]} + ] +} diff --git a/dcl/hw_ausgabe.dcl b/dcl/hw_ausgabe.dcl new file mode 100644 index 0000000..dd60f04 --- /dev/null +++ b/dcl/hw_ausgabe.dcl @@ -0,0 +1,52 @@ +// ============================================================ +// hw_ausgabe.dcl - Ziel der HW-Planung waehlen +// Aufruf ueber hw-ausgabe-waehlen (Lisp/HwPlanung.lsp) vor +// HW_MATERIALFLUSS ("Materialfluss erzeugen") und HW_SYMBOLE +// ("Symbole erzeugen") samt Einzelbefehlen. +// +// Der Knopf "Auswaehlen..." schliesst den Dialog per done_dialog 2 +// (getfiled funktioniert nicht aus einem action_tile heraus), danach +// wird der Dialog mit dem gewaehlten Pfad erneut gezeigt. +// ============================================================ + +hw_ausgabe : dialog { + label = "HW Planung - Ausgabe"; + + : text { key = "aktion"; width = 60; } + + : boxed_radio_column { + label = "Erzeugen in"; + key = "modus"; + : radio_button { + key = "aktuell"; + label = "Aktuelle Zeichnung (eigene Layer, fruehere Ausgabe wird ersetzt)"; + } + : radio_button { + key = "ergaenzen"; + label = "Bestehende Datei ergaenzen"; + } + : radio_button { + key = "neu"; + label = "Neue Datei"; + } + } + + : boxed_column { + label = "Bestehende Datei"; + key = "box_datei"; + : row { + : edit_box { key = "datei"; edit_width = 48; } + : button { key = "waehlen"; label = "Auswaehlen..."; fixed_width = true; } + } + } + + : boxed_column { + label = "Neue Datei"; + key = "box_neu"; + : edit_box { key = "name"; label = "Name:"; edit_width = 40; } + : text { key = "ordner"; width = 60; } + } + + errtile; + ok_cancel; +} diff --git a/doc/hw_planung.md b/doc/hw_planung.md new file mode 100644 index 0000000..303f55e --- /dev/null +++ b/doc/hw_planung.md @@ -0,0 +1,198 @@ +# HW-Planung: Materialfluss, Symbole, Nummerierung + +Menue **SSG_LIB -> HW Planung**. Aus dem mechanischen Layout (Ursprungszeichnung) +entstehen Materialfluss und E-Geraete-Symbole - je nach Wahl in der aktuellen +Zeichnung oder in einer eigenen Planungsdatei (Vorgabe `_HW.dxf` in +`DXFM_RESULTS`). Die Ursprungsdatei auf der Platte wird nie veraendert. + +## Ausgabeziel (Dialog vor dem Erzeugen) + +Vor "Materialfluss erzeugen" und "Symbole erzeugen" (auch vor den +Einzelbefehlen `HW_SYM_*`) fragt der Dialog `dcl/hw_ausgabe.dcl`, wohin: + +| Wahl | Ergebnis | +|---|---| +| **Aktuelle Zeichnung** | Materialfluss bzw. Symbole kommen in die offene Zeichnung, auf eigene Layer. Die fruehere Ausgabe desselben Befehls (erkannt an XDATA `SSG_HW_MF` bzw. `SSG_HW_SYM` je Geraeteart) wird ersetzt. Technisch: Python schreibt eine Zwischendatei `_HW_tmp.dxf` ohne Einheit, LISP fuegt sie bei 0,0 ein und loest den Block auf. Symbolbloecke, die es in der Zeichnung schon gibt, behalten ihre vorhandene Definition. | +| **Bestehende Datei ergaenzen** | Datei ueber "Auswaehlen..." waehlen; der Befehl ersetzt dort nur seinen eigenen Anteil. So landen Materialfluss und Symbole nacheinander in derselben Datei. | +| **Neue Datei** | Name eingeben (ohne Endung, Ordner `DXFM_RESULTS`). Gibt es die Datei schon, bleibt der Dialog mit einem Hinweis offen. | + +Die letzte Wahl wird fuer die Sitzung gemerkt. Die Datei muss beim Schreiben in +BricsCAD geschlossen sein. "Symbole nummerieren" arbeitet in einer geoeffneten +**DXF**-Zeichnung - bei Ausgabe in die aktuelle Zeichnung muss diese also eine +DXF sein. + +## Ablauf + +| Schritt | Menue / Befehl | Wo ausfuehren | Ergebnis | +|---|---|---|---| +| 1 | Materialfluss erzeugen (`HW_MATERIALFLUSS`) | Ursprungszeichnung | Polylinien des Materialflusses im gewaehlten Ziel | +| 2 | Symbole erzeugen (`HW_SYMBOLE`) | Ursprungszeichnung | E-Geraete-Symbole mit ID-Vorlage (z.B. `MB1@@@`) im gewaehlten Ziel | +| 3 | - | Zieldatei oeffnen | Renamer-Bereiche ziehen (Rahmen oder Polylinie, siehe unten) | +| 4 | Symbole nummerieren (`HW_NUMMERIEREN`) | Zieldatei (DXF) | `@` in den Symbolen durch laufende Nummern ersetzt | + +Schritt 1 und 2 duerfen in beliebiger Reihenfolge und beliebig oft laufen: jeder +ersetzt in der `_HW.dxf` nur seinen eigenen Anteil (Materialfluss = alles auf den +`HW_*`-Layern, Symbole = Bloecke mit XDATA `SSG_HW_SYM` der jeweiligen Geraeteart). +Was der Anwender selbst gezeichnet hat - Renamer-Bereiche, Polylinien, Notizen - +bleibt stehen. Dafuer muss die `_HW.dxf` waehrend Schritt 1/2 in BricsCAD +**geschlossen** sein (sonst ist sie gesperrt; das Skript meldet das). + +## 1. Materialfluss (`lib/hw_materialfluss.py`) + +`HW_MATERIALFLUSS` ruft `EXPORTCSV` auf und zeichnet aus der CSV flach (Z = 0): + +| Element | Darstellung | Layer | +|---|---|---| +| Kreisel/Eckrad | geschlossenes Rechteck um die Laufbahn, Punktfolge in Drehrichtung, Pfeile | `HW_Kreisel`, `HW_Richtung` | +| Strecke / Gefaellestrecke | Polylinie AS -> Separatoren -> ES (Spalte `TrackIds`), Endpfeil | `HW_Materialfluss`, `HW_Richtung` | +| Weiche | kleiner Kreis am Beruehrpunkt zweier Kreisel | `HW_Weiche` | +| BTMT Beladung | grosser Kreis auf K1, kleiner Kreis auf K2, Linie | `HW_Beladung` | +| BTMT Entladung | kleiner Kreis auf K1, grosser Kreis auf K2, Linie | `HW_Entladung` | + +Layernamen/-farben: `cfg/layer.cfg`, Abschnitt `[hw_planung]`. + +## 2. Symbole (`Lisp/HwPlanung.lsp` + `lib/hw_symbole.py`) + +Regelwerk `cfg/io_regeln.json`: + +- **bauteile**: Mechanik-Bloecke (`Separator_SP*`, `AS_Element*`, `AN8_*`, `SP8_*`, + `Vario_Motorstation*`, ...) und die Rollen, die an jedem davon sitzen. Gefunden + werden sie auch, wenn sie in `VF_n`/`GF_n`/`KREISEL_n` verschachtelt sind. +- **rollen**: je Rolle Geraete-Praefix (MB, BG, MA, FC, GF, BX) und Symbol + (Blockname); `block_nach_name` waehlt z.B. fuer eine rechte Vario-Motorstation + den rechten Motor. +- **io_vorlage**: die ID beim Erzeugen ist ``, Vorgabe `1@@@` + -> `MB1@@@`. Die Platzhalter-ID des Katalogblocks in `VERW`/`BEZEICHNUNG` + (z.B. `Motor MA0`) wird ebenfalls durch die Vorlage ersetzt. +Symboldefinitionen `cfg/hw_symbole.json` - es gibt **keine Symbol-Bibliothek** +als Referenzdokument, `hw_symbole.py` baut die Bloecke bei jedem Lauf neu daraus: + +- **vorlagen**: gemeinsames Aussehen, derzeit `io_750` (750 x 270, Sensoren, + Ventile, Motoren, Motorschutz) und `geraet_800` (800 x 350, Encoder, Scanner, + mit Schraffur): Rahmen und je Attribut Lage, Texthoehe, Sichtbarkeit, Farbe, + Layer, Ausrichtung, Textstil, Breitenfaktor, Vorgabewert. +- **symbole**: je Blockname Vorlage, Layer, Farbe, `versatz` (Lage des Rahmens zum + Einfuegepunkt - Eingaenge und Motoren haengen im Katalog nach unten, + `[0, -270]`), `attribute` (Abweichungen von der Vorlage je Tag), `ohne` + (Attribute, die das Symbol nicht hat) und die Attributwerte (`IO` mit + Platzhalter-ID wie `MB0`, `VERW`, `BEZEICHNUNG`, `KENNZEICHNUNG`, `ARTINR`, + `BESCHR`, `SPS`). +- Die Symbole tragen **dieselben Blocknamen wie die ILS-Katalogsymbole** in + `kabellaengen/doc/Symbole/ILS-Symbole.dxf` und sind ihnen exakt nachgebaut + (Einfuegepunkt, Rahmen, Schraffur, Lage/Hoehe/Stil/Farbe aller Attribute, + Vorgabewerte), damit beim Mischen beider Fassungen in einer Zeichnung keine + still abweichende Definition gilt. `tests/test_hw_symbole.py` + (`test_symbole_gleichen_dem_katalog`) prueft das gegen den Katalog, sofern + kabellaengen daneben liegt (oder `ILS_SYMBOLE_KATALOG` gesetzt ist). Aendert + sich ein Katalogsymbol, schlaegt der Test fehl - dann hier nachziehen. +- Jeder Lauf baut die Blockdefinitionen in der `_HW.dxf` neu auf, Katalogfassungen + gleichen Namens werden dort also ueberschrieben. + +LISP sucht die Bauteile in der Ursprungszeichnung und schreibt je Rolle den +**Zielpunkt** des Bauteils nach `DXFM_RESULTS/_hw_symbole.json`: +Separator-Sensor, AS/ES-Element, Entladung, AN8/SP8-Zentrum (Kreisel-Motor bzw. +Spannstation), Vario-Motor-/Umlenkstation, Scanner - jeweils deren +Einfuegepunkt, flach auf Z = 0. `hw_symbole.py` rechnet daraus die Lage und setzt +die Symbole in die `_HW.dxf`. + +### Lage der Symbole + +- Die Marke **`REALE_POSITION`** (das kleine `x` am rechten Rahmenrand) liegt + mit ihrem **x genau auf dem Zielpunkt**, der Kasten daneben (links davon). +- Mehrere Symbole eines Bauteils stehen **luekenlos uebereinander**, in der + Reihenfolge von `benoetigt` (erstes oben, z.B. Ventil ueber Sensor am + Separator, Motor ueber Motorschutz), als Gruppe in y um das Ziel zentriert. + Ein einzelnes Symbol hat seine Marke damit genau auf dem Ziel. +- **Ohne Ueberlappung**: jede Gruppe wird gegen die 2D-Bounding-Boxen der schon + gesetzten Rahmen geprueft (Mindestabstand `platzierung.abstand`). Beruehrt sie + eine, wird sie nur in **y** verschoben (+-`schritt`, +-2*`schritt`, ... bis + `max_verschiebung`) - x bleibt auf dem Ziel. Findet sich keine freie Lage, + bleibt sie unverschoben und das Skript meldet die Anzahl. +- Die Lage wird immer aus dem **ganzen** Plan berechnet; ein Einzelbefehl wie + `HW_SYM_VENTILE` setzt die Ventile also genau dorthin, wo sie auch mit + `HW_SYMBOLE` laegen. +- Gegen die Materialfluss-Polylinien und andere Geometrie wird nicht geprueft, + nur Symbol gegen Symbol. + +Mubea: 70 von 83 Bauteilgruppen liegen genau auf ihrem Ziel, 13 weichen in y aus +(meist 50-600 mm, Scanner bis 1,7 m). + +Einzelbefehle (nicht im Menue, zum schrittweisen Erzeugen und Pruefen; jeder +ersetzt nur seine Geraeteart): `HW_SYM_VENTILE` (MB), `HW_SYM_SENSOREN` (BG), +`HW_SYM_MOTOREN` (MA), `HW_SYM_MOTORSCHUTZ` (FC), `HW_SYM_ENCODER` (GF), +`HW_SYM_SCANNER` (BX); `HW_SYM_LOESCHEN` entfernt alle erzeugten Symbole aus der +`_HW.dxf`. + +## 3. Renamer-Bereiche + +Ein Renamer-Bereich ist ein Block auf einem der Layer aus `cfg/enumerate.cfg` +(`ILS_RENAMER`, `Omniflo_RENAMER`), der eine **Polylinie** enthaelt und Attribute +traegt - direkt oder zweistufig (aeusserer Block mit Polylinie + innerer +Attribut-Block, wie die `ILS_Renamer-*`-Bloecke in +`kabellaengen/doc/Symbole/ILS-Symbole.dxf`): + +| Attribut | Bedeutung | +|---|---| +| `NAME` bzw. `NAME1`..`NAME3` | Nummernmuster, z.B. `BG-1@@@`; die Anzahl `@` bestimmt die Stellen und muss zur `io_vorlage` passen | +| `LAYER_NAME` bzw. `LAYER_NAME1`..`3` | Layer der Symbole, die mitgezaehlt werden (z.B. `ILS_Eingang`, `ILS_Ausgang`) | +| `DIRECTION` | Reihenfolge, siehe unten | +| `KENNZEICHNUNG` | Zuordnung zum Unterverteiler, z.B. `=A01+UC0100` | + +`DIRECTION`: + +- **Rechteck** (geschlossene Polylinie mit 4 Ecken): `LEFT_RIGHT`, `RIGHT_LEFT`, + `TOP_BOTTOM`, `BOTTOM_TOP` und die Kombinationen `TOP_BOTTOM/LEFT_RIGHT`, + `LEFT_RIGHT/TOP_BOTTOM` usw. (erste Angabe = Hauptsortierung). Mitgezaehlt + werden die Symbole innerhalb des Rechtecks. +- **`POLYLINE_PATH`**: Nummerierung **entlang der Polylinie** in Punktfolge. + Mitgezaehlt werden die Symbole hoechstens `polyline_max_distance` (Vorgabe 500 mm, + `cfg/enumerate.cfg`) von der Linie entfernt. Die Polylinie darf **kein Rechteck** + sein: offen braucht sie mindestens 5 Punkte (genau 4 offene Punkte gelten als + Rechteck), eine 2-Punkt-Linie wird nicht erkannt. + +Bereiche mit identischen Attributen (`NAME*`, `LAYER_NAME*`, `DIRECTION`, +`KENNZEICHNUNG`) bilden eine Gruppe und werden fortlaufend gemeinsam nummeriert. +Alle Symbole einer Gruppe teilen sich einen Zaehler, egal auf welchem Layer +(z.B. BG 1001, BG 1002, MB 1003 in Reihenfolge entlang der Linie). + +## 4. Nummerieren (`lib/create_numbers.py`) + +`HW_NUMMERIEREN` in der geoeffneten `_HW.dxf`: + +1. speichert die Zeichnung (`QSAVE`; sie muss eine DXF sein), +2. ruft `create_numbers.py --dryrun --info ... --errorfile ...` auf, +3. traegt die Nummern in die offene Zeichnung ein (Zuordnung ueber Wert und + Einfuegepunkt jedes Attributs mit `@`). + +Ergebnisdateien neben der Zeichnung: `_nummern.json` (alle Umbenennungen) und +- nur bei Problemen - `_nummern_fehler.json`. Gemeldet werden u.a.: Symbol mit +`@` ausserhalb jedes Bereichs, Symbol in mehreren Bereichen einer Gruppe, +ueberlappende Rechteck-Bereiche, Bereich ohne passende Symbole, `DIRECTION` passt +nicht zur Geometrie, Symbol im Bereich, aber auf einem nicht genannten Layer +(Warnung). + +Direktaufruf (z.B. fuer eine DXF ausserhalb von BricsCAD): + +```bat +python lib\create_numbers.py --filename Anlage_HW.dxf [--config cfg\enumerate.cfg] + [--info info.json] [--errorfile fehler.json] [--dryrun | --overwrite] [--show_symbol_frames] +``` + +Ohne `--dryrun`/`--overwrite` entsteht `_numbered.dxf`. + +Die Nummer wird **oktal** gebildet (`oct(counter)`, so gewollt): das 8. Symbol +einer Gruppe bekommt `...010`. + +## Herkunft + +`create_numbers.py`, `cfg/enumerate.cfg` und der Test +`tests/test_create_numbers.py` sind am 2026-11-03 aus dem Kabeltool-Repo +`kabellaengen` hierher umgezogen (dort entfernt). Es gibt nur diesen einen +Codestand: die benoetigten Helfer aus `kabellaengen/lib/utils.py` stehen direkt +in `create_numbers.py`, die Fehlersammlung `error_collector.py` ist durch zwei +einfache Listen ersetzt (gleiches Format der Fehlerdatei; gegen das Original an +sechs Testzeichnungen verglichen: identische Nummern und Fehlerdateien). +Bewusst doppelt bleibt nur die Konfiguration `cfg/io_regeln.json` (gekuerzt) - +beide Repos werden von verschiedenen Abteilungen gepflegt. Die Testszenen +`Nummerierung1/2`, `BGMG`, `POT` (Erzeugung per `kabellaengen/lib/create_example.py`) +bleiben in kabellaengen; der Modus `--all-examples` ist darum hier entfallen. diff --git a/lang/de_DE.json b/lang/de_DE.json index 9b96752..432f4da 100644 --- a/lang/de_DE.json +++ b/lang/de_DE.json @@ -1139,7 +1139,23 @@ "tt-stil-titel": "Textstil aendern", "hw-csv-fehlt": "\n[HW_MATERIALFLUSS] FEHLER: CSV-Export fehlt: %1 - Materialfluss wird nicht erzeugt.", "hw-fertig": "\n[HW_MATERIALFLUSS] Materialfluss geschrieben: %1", - "hw-symbole-offen": "HW Planung - Symbole erzeugen ist noch nicht umgesetzt.", + "hw-sym-regeln-fehlt": "\n[HW_SYMBOLE] FEHLER: Regelwerk nicht gefunden oder leer: %1", + "hw-sym-keine-bauteile": "\n[HW_SYMBOLE] Keine Bauteile aus dem Regelwerk in der Zeichnung gefunden.", + "hw-sym-bauteile": "\n[HW_SYMBOLE] Bauteil %1: %2", + "hw-fertig-symbole": "\n[HW_SYMBOLE] Symbole geschrieben: %1", + "hw-num-kein-dxf": "\n[HW_NUMMERIEREN] Nur in einer gespeicherten DXF-Zeichnung moeglich (z.B. _HW.dxf), nicht in %1.", + "hw-num-ergebnis": "\n[HW_NUMMERIEREN] %1 Attribute nummeriert.", + "hw-num-fehler": "\n[HW_NUMMERIEREN] Fehler/Warnungen der Nummerierung: %1", + "hw-aus-materialfluss": "Materialfluss erzeugen - wohin soll die Ausgabe?", + "hw-aus-symbole": "Symbole erzeugen - wohin soll die Ausgabe?", + "hw-aus-ordner": "Ordner: %1", + "hw-aus-waehlen": "Bestehende Datei waehlen", + "hw-aus-datei-fehlt": "Bitte eine bestehende DXF-Datei waehlen.", + "hw-aus-name-ungueltig": "Bitte einen gueltigen Dateinamen eingeben (ohne Pfad und ohne Sonderzeichen wie : * ? < > |).", + "hw-aus-name-existiert": "Die Datei gibt es schon - anderen Namen eingeben oder 'Bestehende Datei ergaenzen' waehlen.", + "hw-aus-dcl-fehlt": "\n[HW Planung] FEHLER: Dialog nicht gefunden: %1", + "hw-fertig-aktuell": "\n[HW Planung] In die aktuelle Zeichnung uebernommen, %1 Objekte der frueheren Ausgabe ersetzt.", + "hw-aktuell-fehler": "\n[HW Planung] FEHLER: Zwischendatei konnte nicht in die aktuelle Zeichnung eingefuegt werden: %1", "dinformate-dialog-fehlt": "DIN-Formate-Dialog nicht gefunden: %1", "dinformate-datei-fehlt": "DIN-Formate: Datei nicht gefunden: %1", "dinformate-prompt-rahmen": "\nEinfuegepunkt Rahmen (untere linke Ecke): ", diff --git a/lang/en_GB.json b/lang/en_GB.json index 4857b10..75e2036 100644 --- a/lang/en_GB.json +++ b/lang/en_GB.json @@ -1139,7 +1139,23 @@ "tt-stil-titel": "Change text style", "hw-csv-fehlt": "\n[HW_MATERIALFLUSS] ERROR: CSV export missing: %1 - material flow not created.", "hw-fertig": "\n[HW_MATERIALFLUSS] Material flow written: %1", - "hw-symbole-offen": "HW planning - create symbols is not implemented yet.", + "hw-sym-regeln-fehlt": "\n[HW_SYMBOLE] ERROR: rule file not found or empty: %1", + "hw-sym-keine-bauteile": "\n[HW_SYMBOLE] No components from the rule file found in the drawing.", + "hw-sym-bauteile": "\n[HW_SYMBOLE] Component %1: %2", + "hw-fertig-symbole": "\n[HW_SYMBOLE] Symbols written: %1", + "hw-num-kein-dxf": "\n[HW_NUMMERIEREN] Only possible in a saved DXF drawing (e.g. _HW.dxf), not in %1.", + "hw-num-ergebnis": "\n[HW_NUMMERIEREN] %1 attributes numbered.", + "hw-num-fehler": "\n[HW_NUMMERIEREN] Numbering errors/warnings: %1", + "hw-aus-materialfluss": "Create material flow - where should the output go?", + "hw-aus-symbole": "Create symbols - where should the output go?", + "hw-aus-ordner": "Folder: %1", + "hw-aus-waehlen": "Choose existing file", + "hw-aus-datei-fehlt": "Please choose an existing DXF file.", + "hw-aus-name-ungueltig": "Please enter a valid file name (no path and no special characters like : * ? < > |).", + "hw-aus-name-existiert": "The file already exists - enter another name or choose 'Add to existing file'.", + "hw-aus-dcl-fehlt": "\n[HW Planung] ERROR: dialog not found: %1", + "hw-fertig-aktuell": "\n[HW Planung] Added to the current drawing, %1 objects of the previous output replaced.", + "hw-aktuell-fehler": "\n[HW Planung] ERROR: intermediate file could not be inserted into the current drawing: %1", "dinformate-dialog-fehlt": "DIN formats dialog not found: %1", "dinformate-datei-fehlt": "DIN formats: file not found: %1", "dinformate-prompt-rahmen": "\nBorder insertion point (lower left corner): ", diff --git a/lib/create_numbers.py b/lib/create_numbers.py new file mode 100644 index 0000000..52c6cfb --- /dev/null +++ b/lib/create_numbers.py @@ -0,0 +1,1590 @@ +import argparse +import json +import os +import sys +from pathlib import Path + +import ezdxf +from ezdxf.lldxf.const import DXFStructureError + + +# ============================================================================ +# Meldungen: zwei einfache Listen von (art, text) - Fehler und Warnungen. +# Ersetzt die Fehlersammler-Klasse ErrorCollector aus kabellaengen (lib/error_collector.py), +# damit keine Kopie davon in diesem Repo liegt. +# ============================================================================ + +def neue_meldungen(): + """Leere Meldungssammlung: {"errors": [(art, text), ...], "warnings": [...]}.""" + return {"errors": [], "warnings": []} + + +def meldungen_als_dict(meldungen): + """Fuer die Fehlerdatei nach Art gruppiert, Format wie bisher: + {"errors": {art: [text, ...]}, "warnings": {...}} - leere Teile entfallen.""" + erg = {} + for teil in ("errors", "warnings"): + gruppen = {} + for art, text in meldungen[teil]: + gruppen.setdefault(art, []).append(text) + if gruppen: + erg[teil] = gruppen + return erg + + +def write_json_file(data, out_dir, filename): + """data als JSON (UTF-8, eingerueckt) nach out_dir/filename schreiben.""" + with open(Path(out_dir) / filename, 'w', encoding='utf-8') as fh: + json.dump(data, fh, indent=2, ensure_ascii=False, default=str) + + +# ============================================================================ +# Hilfsfunktionen - uebernommen aus kabellaengen/lib/utils.py (2026-11-03), +# als create_numbers.py nach dxfmakros umgezogen ist. Dort bleiben sie fuer +# die uebrigen Kabeltool-Module bestehen. +# ============================================================================ + +def dxf_is_binary(dxf_path: Path) -> bool: + """True, wenn die DXF-Datei im Binaerformat vorliegt.""" + with open(dxf_path, 'rb') as f: + header = f.read(22) + return b'AutoCAD Binary DXF' in header + + +def get_dxf_file(filepath: Path): + """DXF mit ezdxf laden; Exit 1 bei I/O-Fehler, Exit 2 bei defekter Datei.""" + try: + print("reading file ..", end='') + doc = ezdxf.filemanagement.readfile(filepath) + print("done") + except IOError: + print("Not a DXF file or a generic I/O error.") + sys.exit(1) + except DXFStructureError: + print("Invalid or corrupted DXF file.") + sys.exit(2) + return doc + + +def extract_block_attributes(doc, insert, meldungen=None) -> dict: + """ + Alle Attribute eines INSERT als {tag: text}. Unterstuetzt die zweistufige + Blockstruktur der Renamer-Bloecke (aeusserer Block mit LWPOLYLINE und einem + INSERT des Attribut-Blocks): ohne direkte Attribute zaehlen die des ersten + inneren INSERT mit Attributen. + """ + attributes = {} + if insert.dxftype() != 'INSERT': + return attributes + + direct_attribs = list(insert.attribs) + if direct_attribs: + for attrib in direct_attribs: + attributes[attrib.dxf.tag] = attrib.dxf.text + return attributes + + try: + block_layout = doc.blocks.get(insert.dxf.name) + for entity in block_layout: + if entity.dxftype() == 'INSERT': + inner_attribs = list(entity.attribs) + if inner_attribs: + for attrib in inner_attribs: + attributes[attrib.dxf.tag] = attrib.dxf.text + break + except Exception as e: + if meldungen: + error_msg = f"Fehler beim Extrahieren der Attribute aus Block '{insert.dxf.name}': {e}" + meldungen["warnings"].append(("attribute_extraction_error", error_msg)) + + return attributes + + +def draw_symbol_frames(doc, msp, symbols, symbol_width=1210, symbol_height=381, frame_layer="SYMBOL_FRAMES"): + """ + Zeichnet zur Kontrolle einen Rahmen um jedes Symbol (Position = linke + untere Ecke). Breite nach Laenge des IO-Werts: 6 Zeichen (MA-1@@) = 1210, + ab 7 Zeichen (BG-1@@@) = 1410. Rueckgabe: Anzahl der Rahmen. + """ + if frame_layer not in doc.layers: + doc.layers.add(frame_layer, color=3) + + frame_count = 0 + for symbol in symbols: + pos = symbol['position'] + attribs = symbol['attributes'] + io_value = attribs.get('IO', '') + if not io_value: + for key in ['KENNZEICHNUNG', 'NAME']: + if key in attribs and attribs[key]: + io_value = attribs[key] + break + width = 1410 if len(str(io_value)) >= 7 else symbol_width + x_min, y_min = pos[0], pos[1] + x_max, y_max = pos[0] + width, pos[1] + symbol_height + msp.add_lwpolyline([(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)], + close=True, dxfattribs={'layer': frame_layer, 'color': 3}) + frame_count += 1 + + print(f" {frame_count} Symbol-Rahmen gezeichnet") + return frame_count + + +""" +Dieses Programm liest eine dxf Datei und holt sich vom Layer RENAME die Angaben +für die Bereiche in der Anlage, welche Motoren, Sensoren und Aktoren welchem +Unterverteiler zugeordnet werden müssen. (Attribut KENNZEICHNUNG) +Alle Symbol Templates werden entsprechend der Richtungsangaben in DIRECTION nummeriert +DIRECTION kann TOP_BOTTOM, BOTTOM_TOP, LEFT_RIGHT oder RIGHT_LEFT sein + +Ein Renamer Symbol enthält z.B. die folgenden Angaben: +für POT-RA, POT-MA und POT-UC: + +NAME1=POT-RA@@ +NAME2=POT-MA@@ +NAME3=POT-UC@@ +KENNZEICHNUNG=A01+UC… +LAYER_NAME1=ILS_POT-RA +LAYER_NAME2=ILS_POT-MA +LAYER_NAME3=ILS_POT-UC +DIRECTION = LEFT_RIGHT + +für BG, MB: + +NAME1=BG-1@@@ +NAME2=MB-1@@@ +NAME3= +KENNZEICHNUNG= +LAYER_NAME1=ILS_Eingang +LAYER_NAME2=ILS_Ausgang +LAYER_NAME3= +DIRECTION = LEFT_RIGHT + +oder nur zur Nummerierung der MA Symbole +NAME=MA-1@@ +KENNZEICHNUNG=A01+UH00 +LAYER_NAME=ILS_MOTOR +DIRECTION = TOP_BOTTOM/LEFT_RIGHT + +Diese Symbole auf dem RENAMER Layer sind immer in einem Block mit einer Polylinie oder einen REchteck. +Alle Sensoren, Motoren, Aktoren etc. innerhalb der zugehörigen Polylinie nehmen an der Nummerierung teil, falls diese das @ Zeichen enthalten, also noch ein Template sind. + +""" + + +def read_config_layers(config_path: Path) -> list: + """ + Liest die enumerate.cfg und gibt die Layer zurück, auf denen nach Renamer-Blöcken gesucht werden soll. + Die Konfiguration hat eine einfache Liste unter [Layers]: + [Layers] + RENAMER + OTHER_LAYER + """ + layers = [] + + with open(config_path, 'r', encoding='utf-8') as f: + in_layers_section = False + for line in f: + line = line.strip() + + # Überspringe leere Zeilen und Kommentare + if not line or line.startswith('#') or line.startswith(';'): + continue + + # Prüfe ob wir in der [Layers] Sektion sind + if line.startswith('['): + in_layers_section = line.lower() == '[layers]' + continue + + # Wenn in [Layers], füge Layer hinzu + if in_layers_section: + layers.append(line) + + return layers + + +def read_config_parameters(config_path: Path) -> dict: + """ + Liest die enumerate.cfg und gibt die Parameter zurück. + Die Konfiguration hat Key-Value-Paare unter [Parameters]: + [Parameters] + polyline_max_distance = 500.0 + + Returns: + Dictionary mit Parametern und ihren Werten + """ + parameters = { + 'polyline_max_distance': 500.0 # Default-Wert + } + + with open(config_path, 'r', encoding='utf-8') as f: + in_parameters_section = False + for line in f: + line = line.strip() + + # Überspringe leere Zeilen und Kommentare + if not line or line.startswith('#') or line.startswith(';'): + continue + + # Prüfe ob wir in der [Parameters] Sektion sind + if line.startswith('['): + in_parameters_section = line.lower() == '[parameters]' + continue + + # Wenn in [Parameters], parse Key-Value-Paare + if in_parameters_section and '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Versuche den Wert als Float zu parsen + try: + parameters[key] = float(value) + except ValueError: + # Wenn nicht als Float parsbar, speichere als String + parameters[key] = value + + return parameters + + +def is_rectangle_polyline(points): + """ + Prüft, ob eine Liste von Punkten ein Rechteck darstellt. + Ein Rechteck hat genau 4 Eckpunkte (oder 5 wenn geschlossen mit identischem Start/End). + + Args: + points: Liste von Punkten (Tupel mit mindestens x, y Koordinaten) + + Returns: + True wenn es ein Rechteck ist, False sonst + """ + if len(points) < 4: + return False + + # Prüfe ob erster und letzter Punkt identisch sind (geschlossene Polylinie) + first = (points[0][0], points[0][1]) + last = (points[-1][0], points[-1][1]) + + if abs(first[0] - last[0]) < 0.001 and abs(first[1] - last[1]) < 0.001: + # Geschlossene Polylinie - sollte 5 Punkte haben (4 Ecken + 1 Schließung) + if len(points) == 5: + return True + # Wenn mehr als 5 Punkte, ist es kein einfaches Rechteck + return False + else: + # Offene Polylinie - sollte genau 4 Punkte haben + return len(points) == 4 + + +def get_boundary_geometry(doc, insert): + """ + Sucht im Block nach einem Rechteck oder einer geschlossenen Polylinie. + Unterstützt zweistufige Blockstruktur (äußerer Block mit LWPOLYLINE + INSERT zu Attribut-Block). + Gibt die Eckpunkte zurück. + + Returns: + Tupel (transformed_points, is_rectangle) oder (None, None) wenn nichts gefunden + """ + block_layout = doc.blocks.get(insert.dxf.name) + + # Suche nach LWPOLYLINE, POLYLINE oder geschlossenen Linien + for entity in block_layout: + if entity.dxftype() == 'LWPOLYLINE': + # Get points and transform them + points = list(entity.get_points()) + + # Prüfe ob geschlossen (entweder Flag gesetzt oder erster == letzter Punkt) + is_closed = entity.closed or (entity.dxf.flags & 1) + if not is_closed and len(points) > 1: + # Prüfe ob erster und letzter Punkt gleich sind + first = points[0][:2] + last = points[-1][:2] + if abs(first[0] - last[0]) < 0.001 and abs(first[1] - last[1]) < 0.001: + is_closed = True + + if is_closed or len(points) >= 4: + # Transform points relative to insert position + insert_point = insert.dxf.insert + transformed_points = [] + for p in points: + # Einfache Translation (ohne Rotation/Skalierung für ersten Ansatz) + transformed_points.append(( + insert_point[0] + p[0], + insert_point[1] + p[1] + )) + + # Prüfe ob es ein Rechteck ist + is_rect = is_rectangle_polyline(points) + return transformed_points, is_rect + + elif entity.dxftype() == 'POLYLINE': + if entity.is_closed: + points = [(v.dxf.location.x, v.dxf.location.y) for v in entity.vertices] + insert_point = insert.dxf.insert + transformed_points = [] + for p in points: + transformed_points.append(( + insert_point[0] + p[0], + insert_point[1] + p[1] + )) + + # Prüfe ob es ein Rechteck ist + is_rect = is_rectangle_polyline(points) + return transformed_points, is_rect + + return None, None + + +def point_in_polygon(point, polygon): + """ + Prüft, ob ein Punkt innerhalb eines Polygons liegt (Ray-casting Algorithmus). + """ + x, y = point + n = len(polygon) + inside = False + + p1x, p1y = polygon[0] + for i in range(1, n + 1): + p2x, p2y = polygon[i % n] + if y > min(p1y, p2y): + if y <= max(p1y, p2y): + if x <= max(p1x, p2x): + if p1y != p2y: + xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x + if p1x == p2x or x <= xinters: + inside = not inside + p1x, p1y = p2x, p2y + + return inside + + +def point_near_polyline(point, polyline_points, max_distance=500.0): + """ + Prüft ob ein Punkt in der Nähe einer Polylinie liegt. + + Args: + point: (x, y) Punkt + polyline_points: Liste von (x, y) Punkten der Polylinie + max_distance: Maximale Distanz zum nächsten Punkt auf der Polylinie + + Returns: + True wenn Punkt innerhalb von max_distance zur Polylinie liegt + """ + if not polyline_points or len(polyline_points) < 2: + return False + + min_dist_sq = float('inf') + + # Prüfe Distanz zu jedem Segment der Polylinie + for i in range(len(polyline_points) - 1): + p1 = polyline_points[i] + p2 = polyline_points[i + 1] + + # Berechne die Projektion des Punktes auf das Segment + dx = p2[0] - p1[0] + dy = p2[1] - p1[1] + segment_length_sq = dx * dx + dy * dy + + if segment_length_sq == 0: + # Segment ist ein Punkt + dist_sq = (point[0] - p1[0])**2 + (point[1] - p1[1])**2 + min_dist_sq = min(min_dist_sq, dist_sq) + continue + + # Parameter t für die Projektion (0 = p1, 1 = p2) + t = ((point[0] - p1[0]) * dx + (point[1] - p1[1]) * dy) / segment_length_sq + t = max(0.0, min(1.0, t)) # Clampe auf [0, 1] + + # Nächster Punkt auf dem Segment + proj_x = p1[0] + t * dx + proj_y = p1[1] + t * dy + + # Distanz vom Punkt zur Projektion + dist_sq = (point[0] - proj_x)**2 + (point[1] - proj_y)**2 + min_dist_sq = min(min_dist_sq, dist_sq) + + return min_dist_sq <= (max_distance * max_distance) + + +def find_symbols_in_boundary(doc, msp, boundary, target_layers, attributes, meldungen=None, polyline_max_distance=500.0): + """ + Findet alle Symbole (INSERT-Blöcke) innerhalb des angegebenen Bereichs auf den Ziel-Layern. + + Für POLYLINE_PATH: Symbole in der Nähe der Polylinie + Für Rechtecke: Symbole innerhalb des Polygons + + Args: + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + """ + symbols = [] + + # Bestimme welche Layer durchsucht werden sollen + search_layers = [] + for i in range(1, 4): # NAME1, NAME2, NAME3 + layer_key = f"LAYER_NAME{i}" + if layer_key in attributes and attributes[layer_key]: + search_layers.append(attributes[layer_key]) + + # Falls nur NAME/LAYER_NAME vorhanden + if "LAYER_NAME" in attributes and attributes["LAYER_NAME"]: + search_layers.append(attributes["LAYER_NAME"]) + + # Prüfe ob es POLYLINE_PATH ist + direction = attributes.get("DIRECTION", "") + is_polyline_path = (direction == "POLYLINE_PATH") + + # Durchsuche alle INSERT-Blöcke + for entity in msp.query('INSERT'): + if entity.dxf.layer not in search_layers: + continue + + # Prüfe ob der Block Attribute hat (direkt oder in zweistufiger Struktur) + # Verwende extract_block_attributes um beides zu unterstützen + symbol_attribs = extract_block_attributes(doc, entity, meldungen) + + if not symbol_attribs: + continue + + # Hole Position des Symbols + pos = entity.dxf.insert + point = (pos[0], pos[1]) + + # Prüfe ob innerhalb des Bereichs + is_in_boundary = False + if is_polyline_path: + # Für POLYLINE_PATH: Prüfe Nähe zur Polylinie + is_in_boundary = point_near_polyline(point, boundary, max_distance=polyline_max_distance) + else: + # Für Rechtecke: Prüfe ob innerhalb des Polygons + is_in_boundary = point_in_polygon(point, boundary) + + if is_in_boundary: + # Prüfe ob es ein Template ist (enthält @) + has_template = False + for value in symbol_attribs.values(): + if '@' in str(value): + has_template = True + break + + if has_template: + symbols.append({ + 'entity': entity, + 'position': point, + 'attributes': symbol_attribs, + 'layer': entity.dxf.layer + }) + + return symbols + + +def calculate_position_along_polyline(symbol_pos, polyline_points): + """ + Berechnet die Position eines Symbols entlang einer Polylinie. + + Die Position wird als kumulative Distanz vom Start der Polylinie bis zum + nächstgelegenen Punkt auf der Polylinie zum Symbol berechnet. + + Args: + symbol_pos: (x, y) Position des Symbols + polyline_points: Liste von (x, y) Punkten der Polylinie + + Returns: + Float: Kumulative Distanz entlang der Polylinie + """ + if not polyline_points or len(polyline_points) < 2: + return 0.0 + + min_dist = float('inf') + best_segment_idx = 0 + best_proj_dist = 0.0 + + # Finde das nächste Segment und die Projektion auf diesem Segment + for i in range(len(polyline_points) - 1): + p1 = polyline_points[i] + p2 = polyline_points[i + 1] + + # Berechne die Projektion des Symbols auf das Segment + # Vektor von p1 zu p2 + dx = p2[0] - p1[0] + dy = p2[1] - p1[1] + segment_length_sq = dx * dx + dy * dy + + if segment_length_sq == 0: + # Segment ist ein Punkt + dist_sq = (symbol_pos[0] - p1[0])**2 + (symbol_pos[1] - p1[1])**2 + if dist_sq < min_dist: + min_dist = dist_sq + best_segment_idx = i + best_proj_dist = 0.0 + continue + + # Parameter t für die Projektion (0 = p1, 1 = p2) + t = ((symbol_pos[0] - p1[0]) * dx + (symbol_pos[1] - p1[1]) * dy) / segment_length_sq + t = max(0.0, min(1.0, t)) # Clampe auf [0, 1] + + # Nächster Punkt auf dem Segment + proj_x = p1[0] + t * dx + proj_y = p1[1] + t * dy + + # Distanz vom Symbol zur Projektion + dist_sq = (symbol_pos[0] - proj_x)**2 + (symbol_pos[1] - proj_y)**2 + + if dist_sq < min_dist: + min_dist = dist_sq + best_segment_idx = i + best_proj_dist = t * (segment_length_sq ** 0.5) + + # Berechne kumulative Distanz bis zum besten Segment + Projektion auf dem Segment + cumulative_dist = 0.0 + for i in range(best_segment_idx): + p1 = polyline_points[i] + p2 = polyline_points[i + 1] + segment_length = ((p2[0] - p1[0])**2 + (p2[1] - p1[1])**2) ** 0.5 + cumulative_dist += segment_length + + cumulative_dist += best_proj_dist + + return cumulative_dist + + +def sort_symbols_by_direction(symbols, direction, polyline_points=None): + """ + Sortiert die Symbole nach der angegebenen Richtung. + + Unterstützte Richtungen (erste Angabe = Hauptsortierung): + TOP_BOTTOM/LEFT_RIGHT: Zeilenweise von oben nach unten, dann links nach rechts (primär Y, sekundär X) + TOP_BOTTOM/RIGHT_LEFT: Zeilenweise von oben nach unten, dann rechts nach links (primär Y, sekundär -X) + BOTTOM_TOP/LEFT_RIGHT: Zeilenweise von unten nach oben, dann links nach rechts (primär Y, sekundär X) + BOTTOM_TOP/RIGHT_LEFT: Zeilenweise von unten nach oben, dann rechts nach links (primär Y, sekundär -X) + + LEFT_RIGHT/TOP_BOTTOM: Spaltenweise von links nach rechts, dann oben nach unten (primär X, sekundär -Y) + LEFT_RIGHT/BOTTOM_TOP: Spaltenweise von links nach rechts, dann unten nach oben (primär X, sekundär Y) + RIGHT_LEFT/TOP_BOTTOM: Spaltenweise von rechts nach links, dann oben nach unten (primär -X, sekundär -Y) + RIGHT_LEFT/BOTTOM_TOP: Spaltenweise von rechts nach links, dann unten nach oben (primär -X, sekundär Y) + + Einfache Richtungen: + TOP_BOTTOM → TOP_BOTTOM/LEFT_RIGHT (zeilenweise von oben nach unten) + BOTTOM_TOP → BOTTOM_TOP/LEFT_RIGHT (zeilenweise von unten nach oben) + LEFT_RIGHT: Spaltenweise von links nach rechts (sortiert nach X, dann Y) + RIGHT_LEFT: Spaltenweise von rechts nach links (sortiert nach -X, dann Y) + + Spezielle Richtungen: + POLYLINE_PATH: Nummerierung entlang einer Polylinie in Reihenfolge der Polylinien-Punkte + + Args: + symbols: Liste von Symbol-Dictionaries mit 'position' (x, y) + direction: DIRECTION-Attribut (z.B. "LEFT_RIGHT", "TOP_BOTTOM/LEFT_RIGHT", "POLYLINE_PATH") + polyline_points: Optional - Liste von (x, y) Punkten für POLYLINE_PATH Richtung + + Returns: + Sortierte Liste von Symbolen + """ + # POLYLINE_PATH: Sortiere entlang der Polylinie + if direction == "POLYLINE_PATH": + if not polyline_points or len(polyline_points) < 2: + print(" WARNUNG: POLYLINE_PATH ohne gültige Polylinie - verwende Fallback-Sortierung") + return sorted(symbols, key=lambda s: (-s['position'][1], s['position'][0])) + + # Berechne Position entlang der Polylinie für jedes Symbol + symbols_with_dist = [] + for symbol in symbols: + dist = calculate_position_along_polyline(symbol['position'], polyline_points) + symbols_with_dist.append((dist, symbol)) + + # Sortiere nach Distanz entlang der Polylinie + symbols_with_dist.sort(key=lambda x: x[0]) + return [symbol for dist, symbol in symbols_with_dist] + + # Kombinierte Richtungen (vollständig spezifiziert) - Y/X Reihenfolge + if direction == "TOP_BOTTOM/LEFT_RIGHT" or direction == "TOP_BOTTOM": + # Von oben nach unten (-Y), in jeder Zeile von links nach rechts (X) + return sorted(symbols, key=lambda s: (-s['position'][1], s['position'][0])) + elif direction == "LEFT_RIGHT/TOP_BOTTOM" : + # Von links nach rechts (X), in jeder Spalte von oben nach unten (Y) + return sorted(symbols, key=lambda s: (s['position'][0], -s['position'][1])) + elif direction == "LEFT_RIGHT/BOTTOM_TOP": + # Von links nach rechts (X), in jeder Spalte von unten nach oben (Y) + return sorted(symbols, key=lambda s: (s['position'][0], s['position'][1])) + elif direction == "TOP_BOTTOM/RIGHT_LEFT": + # Von oben nach unten (-Y), in jeder Zeile von rechts nach links (-X) + return sorted(symbols, key=lambda s: (-s['position'][1], -s['position'][0])) + elif direction == "BOTTOM_TOP/LEFT_RIGHT" or direction == "BOTTOM_TOP": + # Von unten nach oben (Y), in jeder Zeile von links nach rechts (X) + return sorted(symbols, key=lambda s: (s['position'][1], s['position'][0])) + elif direction == "BOTTOM_TOP/RIGHT_LEFT": + # Von unten nach oben (Y), in jeder Zeile von rechts nach links (-X) + return sorted(symbols, key=lambda s: (s['position'][1], -s['position'][0])) + elif direction == "RIGHT_LEFT/BOTTOM_TOP": + # Von unten nach oben (Y), in jeder Zeile von rechts nach links (-X) + return sorted(symbols, key=lambda s: (-s['position'][0], s['position'][1])) + elif direction == "RIGHT_LEFT/TOP_BOTTOM": + # Von rechts nach links (-X), in jeder Spalte von oben nach unten (Y) + return sorted(symbols, key=lambda s: (-s['position'][0], -s['position'][1])) + + # Einfache Richtungen + elif direction == "LEFT_RIGHT": + # Spaltenweise von links nach rechts: Sortiere nach X, dann Y + return sorted(symbols, key=lambda s: (s['position'][0], s['position'][1])) + elif direction == "RIGHT_LEFT": + # Spaltenweise von rechts nach links: Sortiere nach -X, dann Y + return sorted(symbols, key=lambda s: (-s['position'][0], s['position'][1])) + else: + # Fallback: TOP_BOTTOM/LEFT_RIGHT + return sorted(symbols, key=lambda s: (-s['position'][1], s['position'][0])) + + +def enumerate_symbols(symbols, attributes): + """ + Nummeriert die Symbole durch und ersetzt die @@ Platzhalter. + """ + counter = 1 + renamed = [] + + for symbol in symbols: + layer = symbol['layer'] + + # Finde das passende NAME-Template für diesen Layer + name_template = None + for i in range(1, 4): + layer_key = f"LAYER_NAME{i}" + name_key = f"NAME{i}" + if layer_key in attributes and attributes[layer_key] == layer: + if name_key in attributes: + name_template = attributes[name_key] + break + + # Falls nur NAME/LAYER_NAME vorhanden + if not name_template and "LAYER_NAME" in attributes and attributes["LAYER_NAME"] == layer: + if "NAME" in attributes: + name_template = attributes["NAME"] + + if not name_template: + continue + + # Ersetze @-Zeichen durch Zahlen + # Zähle wie viele @ im Template sind + at_count = name_template.count('@') + number_str = oct(counter)[2:].zfill(at_count) + + # Aktualisiere Attribute im Symbol + for attrib in symbol['entity'].attribs: + old_value = attrib.dxf.text + if '@' in old_value: + # Ersetze @ durch die Nummer + new_value = old_value.replace('@' * at_count, number_str) + attrib.dxf.text = new_value + + renamed.append({ + 'position': symbol['position'], + 'layer': layer, + 'old_value': old_value, + 'new_value': new_value + }) + + counter += 1 + + return renamed + + +def get_layer_names_from_attributes(attributes): + """ + Extrahiert alle LAYER_NAME Attribute aus einem Renamer-Block. + + Args: + attributes: Dictionary mit Block-Attributen + + Returns: + Liste der Layer-Namen (z.B. ['ILS_Eingang', 'ILS_Ausgang']) + """ + layer_names = [] + if "LAYER_NAME" in attributes and attributes["LAYER_NAME"]: + layer_names.append(attributes["LAYER_NAME"]) + for i in range(1, 4): + layer_key = f"LAYER_NAME{i}" + if layer_key in attributes and attributes[layer_key]: + layer_names.append(attributes[layer_key]) + return layer_names + + +def get_renamer_config_key(attributes): + """ + Erstellt einen eindeutigen Schlüssel für die Renamer-Block-Konfiguration. + Blöcke mit demselben Schlüssel werden gruppiert und gemeinsam nummeriert. + + Args: + attributes: Dictionary mit Block-Attributen + + Returns: + Tuple mit (NAME-Patterns, LAYER_NAMEs, DIRECTION, KENNZEICHNUNG) + """ + # Sammle NAME-Patterns (normalisiert: NAME oder NAME1-3) + name_patterns = [] + if "NAME" in attributes and attributes["NAME"]: + name_patterns.append(("NAME", attributes["NAME"])) + for i in range(1, 4): + name_key = f"NAME{i}" + if name_key in attributes and attributes[name_key]: + name_patterns.append((name_key, attributes[name_key])) + + # Sammle LAYER_NAMEs (normalisiert: LAYER_NAME oder LAYER_NAME1-3) + layer_names = [] + if "LAYER_NAME" in attributes and attributes["LAYER_NAME"]: + layer_names.append(("LAYER_NAME", attributes["LAYER_NAME"])) + for i in range(1, 4): + layer_key = f"LAYER_NAME{i}" + if layer_key in attributes and attributes[layer_key]: + layer_names.append((layer_key, attributes[layer_key])) + + # DIRECTION und KENNZEICHNUNG + direction = attributes.get("DIRECTION", "") + kennzeichnung = attributes.get("KENNZEICHNUNG", "") + + # Erstelle einen eindeutigen Schlüssel + # Sortiere die Listen für konsistente Keys + name_patterns.sort() + layer_names.sort() + + return (tuple(name_patterns), tuple(layer_names), direction, kennzeichnung) + + +def validate_direction_with_geometry(direction, is_rectangle): + """ + Validiert die DIRECTION-Angabe gegen die tatsächliche Geometrie. + + Args: + direction: DIRECTION-Attribut (z.B. "LEFT_RIGHT", "POLYLINE_PATH") + is_rectangle: True wenn die Polylinie ein Rechteck ist, False sonst + + Returns: + Tupel (is_valid, error_message): + - is_valid: True wenn Kombination gültig ist + - error_message: Fehlermeldung wenn ungültig, sonst None + """ + # POLYLINE_PATH-Richtungen erwarten komplexe Polylinien (keine Rechtecke) + polyline_path_directions = ["POLYLINE_PATH"] + + # Rechteck/Standard-Richtungen erwarten einfache Rechtecke + rectangle_directions = [ + "TOP_BOTTOM", "BOTTOM_TOP", "LEFT_RIGHT", "RIGHT_LEFT", + "TOP_BOTTOM/LEFT_RIGHT", "TOP_BOTTOM/RIGHT_LEFT", + "BOTTOM_TOP/RIGHT_LEFT", "BOTTOM_TOP/LEFT_RIGHT", + "LEFT_RIGHT/TOP_BOTTOM", "RIGHT_LEFT/TOP_BOTTOM", + "LEFT_RIGHT/BOTTOM_TOP", "RIGHT_LEFT/BOTTOM_TOP" + ] + + if direction in polyline_path_directions: + if is_rectangle: + return False, f"DIRECTION '{direction}' erfordert eine komplexe Polylinie, aber ein Rechteck wurde gefunden" + return True, None + + elif direction in rectangle_directions: + if not is_rectangle: + return False, f"DIRECTION '{direction}' erfordert ein Rechteck, aber eine komplexe Polylinie wurde gefunden" + return True, None + + else: + # Unbekannte DIRECTION - gebe Warnung, aber breche nicht ab + return True, f"Unbekannte DIRECTION '{direction}' - Verarbeitung wird fortgesetzt" + + +def collect_and_group_renamer_blocks(doc, msp, renamer_layers, meldungen): + """ + Erster Pass: Sammelt und gruppiert alle Renamer-Blöcke nach Konfiguration. + + Returns: + Dictionary mit {config_key: [(insert, boundary, attributes, is_rectangle), ...]} + """ + renamer_groups = {} # {config_key: [(insert, boundary, attributes, is_rectangle), ...]} + + for layer in renamer_layers: + print(f"Durchsuche Layer: {layer}") + + # Finde alle INSERT-Blöcke auf diesem Layer + for insert in msp.query(f'INSERT[layer=="{layer}"]'): + # Extrahiere Attribute (unterstützt zweistufige Blockstruktur) + attributes = extract_block_attributes(doc, insert, meldungen) + + if not attributes: + print(f" Block ohne Attribute gefunden an Position {insert.dxf.insert}") + continue + + # Prüfe ob es ein Renamer-Block ist + has_name = "NAME" in attributes or "NAME1" in attributes + has_direction = "DIRECTION" in attributes + + if not (has_name and has_direction): + continue + + block_name = attributes.get('NAME', attributes.get('NAME1', 'UNKNOWN')) + print(f" Renamer-Block gefunden: {block_name}") + print(f" Direction: {attributes.get('DIRECTION', 'UNKNOWN')}") + print(f" Kennzeichnung: {attributes.get('KENNZEICHNUNG', 'N/A')}") + + # Finde Boundary (Rechteck oder Polylinie) + boundary, is_rectangle = get_boundary_geometry(doc, insert) + if boundary is None: + error_msg = f"Keine Polylinie/Rechteck im Renamer-Block '{block_name}' an Position {insert.dxf.insert} gefunden" + print(f" FEHLER: {error_msg}") + meldungen["errors"].append(("missing_boundary", error_msg)) + continue + + print(f" Boundary gefunden mit {len(boundary)} Punkten (Rechteck: {is_rectangle})") + print(f" Eckpunkte: {', '.join([f'({p[0]:.2f}, {p[1]:.2f})' for p in boundary])}") + + # Validiere DIRECTION gegen Geometrie + direction = attributes.get("DIRECTION", "LEFT_RIGHT") + is_valid, validation_error = validate_direction_with_geometry(direction, is_rectangle) + + if not is_valid: + error_msg = f"Renamer-Block '{block_name}' an Position {insert.dxf.insert}: {validation_error}" + print(f" FEHLER: {error_msg}") + meldungen["errors"].append(("direction_geometry_mismatch", error_msg)) + continue + elif validation_error: + # Warnung bei unbekannter DIRECTION, aber fortfahren + print(f" WARNUNG: {validation_error}") + meldungen["warnings"].append(("unknown_direction", validation_error)) + + # Erstelle Konfigurationsschlüssel und gruppiere + config_key = get_renamer_config_key(attributes) + if config_key not in renamer_groups: + renamer_groups[config_key] = [] + renamer_groups[config_key].append((insert, boundary, attributes, is_rectangle)) + + return renamer_groups + + +def find_template_symbols_in_geometry(doc, msp, group_blocks, meldungen): + """ + Findet alle Symbole mit '@' die geometrisch in den Renamer-Bereichen liegen, + unabhängig vom Layer. + + Args: + doc: DXF-Dokument + msp: Modelspace + group_blocks: Liste von (insert, boundary, attributes, is_rectangle) Tupeln + meldungen: Meldungen (neue_meldungen) fuer Fehler/Warnungen + + Returns: + Liste von Dictionaries mit 'layer', 'position', 'io' für jedes gefundene Symbol + """ + symbols_in_geometry = [] + for insert, boundary, attributes, is_rectangle in group_blocks: + for entity in msp.query('INSERT'): + symbol_attribs = extract_block_attributes(doc, entity, meldungen) + if not symbol_attribs: + continue + + # Prüfe ob irgendein Attribut "@" enthält + has_template = any('@' in str(value) for value in symbol_attribs.values()) + + if has_template: + pos = entity.dxf.insert + point = (pos[0], pos[1]) + if point_in_polygon(point, boundary): + symbols_in_geometry.append({ + 'layer': entity.dxf.layer, + 'position': point, + 'io': symbol_attribs.get('IO', 'N/A') + }) + return symbols_in_geometry + + +def create_no_symbols_found_error_message(block_name, direction, layer_names, group_blocks, + doc, msp, meldungen): + """ + Erstellt eine detaillierte Fehlermeldung wenn keine Symbole in einem Renamer-Bereich gefunden wurden. + + Args: + block_name: Name des Renamer-Blocks + direction: DIRECTION-Attribut + layer_names: Liste der erwarteten Layer-Namen + group_blocks: Liste von (insert, boundary, attributes, is_rectangle) Tupeln + doc: DXF-Dokument + msp: Modelspace + meldungen: Meldungen (neue_meldungen) fuer Fehler/Warnungen + + Returns: + Fehlermeldungs-String + """ + renamer_positions = [f"({insert.dxf.insert[0]:.1f}, {insert.dxf.insert[1]:.1f})" + for insert, _, _, _ in group_blocks] + + # Prüfe ob es Symbole mit @ gibt, die geometrisch im Bereich liegen (egal auf welchem Layer) + symbols_in_geometry = find_template_symbols_in_geometry(doc, msp, group_blocks, meldungen) + + # Erstelle Fehlermeldung basierend auf gefundenen Symbolen + if symbols_in_geometry and not layer_names: + # Es gibt Symbole im Bereich, aber keine LAYER_NAME Attribute definiert + return ( + f"Renamer-Block-Gruppe '{block_name}' (DIRECTION: {direction}) " + f"hat keine passenden Symbole gefunden. " + f"Es wurden {len(symbols_in_geometry)} Symbol(e) mit '@' geometrisch im Bereich gefunden, " + f"aber der Renamer-Block hat KEINE LAYER_NAME Attribute (LAYER_NAME1, LAYER_NAME2, LAYER_NAME3) definiert. " + f"Ohne diese Attribute können Symbole nicht zugeordnet werden. " + f"Renamer-Blöcke an Positionen: {', '.join(renamer_positions)}" + ) + elif symbols_in_geometry and layer_names: + # Es gibt Symbole im Bereich, aber sie sind auf falschen Layern + found_layers = set(s['layer'] for s in symbols_in_geometry) + return ( + f"Renamer-Block-Gruppe '{block_name}' (DIRECTION: {direction}) " + f"hat keine passenden Symbole gefunden. " + f"Es wurden {len(symbols_in_geometry)} Symbol(e) mit '@' geometrisch im Bereich gefunden, " + f"aber diese sind auf Layer: {', '.join(found_layers)}. " + f"Erwartet werden Layer: {', '.join(layer_names)}. " + f"Prüfen Sie die LAYER_NAME Attribute im Renamer-Block. " + f"Renamer-Blöcke an Positionen: {', '.join(renamer_positions)}" + ) + else: + # Keine Symbole im Bereich gefunden (weder geometrisch noch auf richtigen Layern) + return ( + f"Renamer-Block-Gruppe '{block_name}' (DIRECTION: {direction}) " + f"hat keine passenden Symbole gefunden. " + f"Gesucht auf Layer: {', '.join(layer_names) if layer_names else 'N/A'}. " + f"Renamer-Blöcke an Positionen: {', '.join(renamer_positions)}" + ) + + +def collect_symbols_from_group(doc, msp, renamer_layers, group_blocks, block_name, meldungen, polyline_max_distance=500.0): + """ + Sammelt alle Symbole aus allen Boundaries einer Renamer-Gruppe. + Prüft auf Duplikate (Symbole in mehreren Bereichen). + + Args: + doc: DXF-Dokument + msp: Modelspace + renamer_layers: Layer auf denen nach Renamer-Blöcken gesucht wird + group_blocks: Liste von (insert, boundary, attributes, is_rectangle) Tupeln + block_name: Name des Renamer-Blocks (für Fehlermeldungen) + meldungen: Meldungen (neue_meldungen) fuer Fehler/Warnungen + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + + Returns: + Liste aller eindeutigen Symbole + """ + all_symbols = [] + seen_entities = {} # {entity_id: (symbol, [list of renamer block positions])} + + for insert, boundary, attributes, is_rectangle in group_blocks: + # Finde Symbole innerhalb dieses Bereichs + symbols = find_symbols_in_boundary(doc, msp, boundary, renamer_layers, attributes, meldungen, polyline_max_distance) + + # Füge Symbole hinzu, aber nur wenn das Entity noch nicht gesehen wurde + for symbol in symbols: + entity_id = id(symbol['entity']) # Eindeutige ID des Entity-Objekts + renamer_pos = insert.dxf.insert + + if entity_id not in seen_entities: + seen_entities[entity_id] = (symbol, [renamer_pos]) + all_symbols.append(symbol) + else: + # Symbol wurde bereits in einem anderen Bereich gefunden - Fehler + existing_symbol, existing_positions = seen_entities[entity_id] + existing_positions.append(renamer_pos) + symbol_pos = symbol['position'] + io_value = symbol['attributes'].get('IO', 'N/A') + error_msg = ( + f"Symbol an Position ({symbol_pos[0]:.1f}, {symbol_pos[1]:.1f}) " + f"mit IO='{io_value}' liegt in mehreren Renamer-Bereichen der Gruppe '{block_name}'. " + f"Gefunden in Renamer-Blöcken an Positionen: " + f"{', '.join([f'({p[0]:.1f}, {p[1]:.1f})' for p in existing_positions])}" + ) + print(f" FEHLER: {error_msg}") + meldungen["errors"].append(("symbol_in_multiple_boundaries", error_msg)) + + return all_symbols + + +def process_renamer_groups(doc, msp, renamer_layers, renamer_groups, meldungen, polyline_max_distance=500.0): + """ + Zweiter Pass: Verarbeitet jede Renamer-Block-Gruppe. + Sammelt Symbole, sortiert sie und nummeriert sie. + + Args: + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + + Returns: + Liste aller umbenannten Symbole + """ + all_renamed = [] + + for config_key, group_blocks in renamer_groups.items(): + if not group_blocks: + continue + + # Verwende die Attribute des ersten Blocks als Referenz (alle in der Gruppe haben identische Attribute) + reference_attributes = group_blocks[0][2] + direction = reference_attributes.get("DIRECTION", "LEFT_RIGHT") + block_name = reference_attributes.get('NAME', reference_attributes.get('NAME1', 'UNKNOWN')) + + print(f"\n Verarbeite Gruppe: {block_name} ({len(group_blocks)} Renamer-Blöcke)") + + # Sammle Symbole aus ALLEN Boundaries dieser Gruppe + all_symbols = collect_symbols_from_group(doc, msp, renamer_layers, group_blocks, block_name, meldungen, polyline_max_distance) + + print(f" Gesamt {len(all_symbols)} eindeutige Template-Symbole aus {len(group_blocks)} Blöcken") + + if not all_symbols: + # Fehlermeldung: Keine Symbole gefunden + layer_names = get_layer_names_from_attributes(reference_attributes) + error_msg = create_no_symbols_found_error_message( + block_name, direction, layer_names, group_blocks, doc, msp, meldungen + ) + print(f" FEHLER: {error_msg}") + meldungen["errors"].append(("no_symbols_found", error_msg)) + continue + + # Für POLYLINE_PATH: Hole die Polylinie vom ersten Block + polyline_points = None + if direction == "POLYLINE_PATH": + if group_blocks: + insert, boundary, attributes, is_rectangle = group_blocks[0] + if boundary and not is_rectangle: + polyline_points = boundary + print(f" POLYLINE_PATH: Verwende Polylinie mit {len(polyline_points)} Punkten") + else: + print(f" WARNUNG: POLYLINE_PATH angegeben, aber keine Polylinie gefunden (is_rectangle={is_rectangle})") + + # Sortiere alle Symbole zusammen nach Richtung + sorted_symbols = sort_symbols_by_direction(all_symbols, direction, polyline_points) + + # Nummeriere alle Symbole als eine Sequenz + renamed = enumerate_symbols(sorted_symbols, reference_attributes) + all_renamed.extend(renamed) + + print(f" {len(renamed)} Symbole nummeriert") + + return all_renamed + + +def find_symbols_with_at_in_io(doc, msp, meldungen): + """ + Findet alle Symbole mit "@" im IO-Attribut. + + Args: + doc: DXF-Dokument + msp: Modelspace + meldungen: Meldungen (neue_meldungen) fuer Fehler/Warnungen + + Returns: + Liste von Dictionaries mit Symbol-Informationen + """ + symbols_with_at = [] + for entity in msp.query('INSERT'): + symbol_attribs = extract_block_attributes(doc, entity, meldungen) + if not symbol_attribs: + continue + + # Prüfe ob IO-Attribut "@" enthält + io_value = symbol_attribs.get('IO', '') + if '@' in str(io_value): + pos = entity.dxf.insert + point = (pos[0], pos[1]) + symbols_with_at.append({ + 'entity': entity, + 'position': point, + 'attributes': symbol_attribs, + 'layer': entity.dxf.layer, + 'io': io_value + }) + return symbols_with_at + + +def check_symbol_in_boundary(symbol, boundary, attributes, renamer_pos, polyline_max_distance=500.0): + """ + Prüft ob ein Symbol geometrisch und vom Layer her in einem Renamer-Bereich liegt. + + Args: + symbol: Dictionary mit Symbol-Informationen + boundary: Polygon-Punkte des Renamer-Bereichs + attributes: Attribute des Renamer-Blocks + renamer_pos: Position des Renamer-Blocks + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + + Returns: + Tuple (in_boundary, wrong_layer_info): + - in_boundary: True wenn Symbol geometrisch und vom Layer her im Bereich liegt + - wrong_layer_info: Dictionary mit Fehlerinformationen wenn Layer falsch ist, sonst None + """ + # Prüfe ob Symbol geometrisch innerhalb des Bereichs liegt + direction = attributes.get("DIRECTION", "") + is_polyline_path = (direction == "POLYLINE_PATH") + + if is_polyline_path: + # Für POLYLINE_PATH: Prüfe Nähe zur Polylinie + if not point_near_polyline(symbol['position'], boundary, max_distance=polyline_max_distance): + return False, None + else: + # Für Rechtecke: Prüfe ob innerhalb des Polygons + if not point_in_polygon(symbol['position'], boundary): + return False, None + + # Symbol liegt geometrisch im Bereich - prüfe Layer + search_layers = get_layer_names_from_attributes(attributes) + + if symbol['layer'] in search_layers: + # Layer passt auch - alles OK + return True, None + else: + # Symbol liegt geometrisch im Bereich, aber Layer passt nicht + block_name = attributes.get('NAME', attributes.get('NAME1', 'UNKNOWN')) + wrong_layer_info = { + 'block_name': block_name, + 'renamer_pos': renamer_pos, + 'expected_layers': search_layers, + 'attributes': attributes + } + return False, wrong_layer_info + + +def create_wrong_layer_warning(symbol, wrong_layer_info): + """ + Erstellt eine Warnung wenn ein Symbol im Bereich liegt, aber der Layer nicht passt. + + Args: + symbol: Dictionary mit Symbol-Informationen + wrong_layer_info: Dictionary mit Fehlerinformationen + + Returns: + Warnmeldungs-String + """ + if wrong_layer_info['expected_layers']: + return ( + f"Symbol an Position ({symbol['position'][0]:.1f}, {symbol['position'][1]:.1f}) " + f"mit IO='{symbol['io']}' liegt geometrisch im Renamer-Bereich '{wrong_layer_info['block_name']}' " + f"(Renamer-Position: ({wrong_layer_info['renamer_pos'][0]:.1f}, {wrong_layer_info['renamer_pos'][1]:.1f})), " + f"aber das Symbol ist auf Layer '{symbol['layer']}', erwartet wird: {', '.join(wrong_layer_info['expected_layers'])}. " + f"Prüfen Sie die LAYER_NAME Attribute im Renamer-Block." + ) + else: + return ( + f"Symbol an Position ({symbol['position'][0]:.1f}, {symbol['position'][1]:.1f}) " + f"mit IO='{symbol['io']}' liegt geometrisch im Renamer-Bereich '{wrong_layer_info['block_name']}' " + f"(Renamer-Position: ({wrong_layer_info['renamer_pos'][0]:.1f}, {wrong_layer_info['renamer_pos'][1]:.1f})), " + f"aber der Renamer-Block hat KEINE LAYER_NAME Attribute definiert. " + f"Symbole können nur gefunden werden, wenn LAYER_NAME1/LAYER_NAME2/LAYER_NAME3 Attribute gesetzt sind." + ) + + +def check_symbols_in_boundaries(doc, msp, renamer_groups, meldungen, polyline_max_distance=500.0): + """ + Dritter Pass: Prüft ob alle Symbole mit "@" im IO in einem Renamer-Bereich liegen. + + Args: + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + """ + print("\n" + "="*60) + print("Prüfe ob alle Symbole mit '@' im IO in einem Renamer-Bereich liegen") + print("="*60) + + # Sammle alle Renamer-Boundaries + all_boundaries = [] + for group_blocks in renamer_groups.values(): + for insert, boundary, attributes, is_rectangle in group_blocks: + all_boundaries.append((boundary, attributes, insert.dxf.insert)) + + # Finde alle Symbole mit "@" im IO-Attribut + symbols_with_at = find_symbols_with_at_in_io(doc, msp, meldungen) + + # Prüfe für jedes Symbol, ob es in mindestens einem Renamer-Bereich liegt + symbols_not_in_any_boundary = [] + for symbol in symbols_with_at: + in_any_boundary = False + wrong_layer_info = None + + for boundary, attributes, renamer_pos in all_boundaries: + is_in_boundary, layer_info = check_symbol_in_boundary(symbol, boundary, attributes, renamer_pos, polyline_max_distance) + + if is_in_boundary: + # Layer passt auch - alles OK + in_any_boundary = True + break + elif layer_info is not None: + # Symbol liegt geometrisch im Bereich, aber Layer passt nicht + wrong_layer_info = layer_info + + if not in_any_boundary: + if wrong_layer_info: + # Warnung: Symbol liegt geometrisch im Bereich, aber Layer stimmt nicht + warning_msg = create_wrong_layer_warning(symbol, wrong_layer_info) + print(f" WARNUNG: {warning_msg}") + meldungen["warnings"].append(("symbol_wrong_layer", warning_msg)) + # NICHT als Fehler hinzufügen - Warnung reicht aus + else: + # Fehler: Symbol liegt weder geometrisch noch vom Layer her in einem Renamer-Bereich + symbols_not_in_any_boundary.append(symbol) + + # Melde Fehler für Symbole, die in keinem Bereich liegen + if symbols_not_in_any_boundary: + for symbol in symbols_not_in_any_boundary: + error_msg = ( + f"Symbol an Position ({symbol['position'][0]:.1f}, {symbol['position'][1]:.1f}) " + f"mit IO='{symbol['io']}' auf Layer '{symbol['layer']}' " + f"enthält '@' im IO-Attribut, liegt aber in keinem Renamer-Bereich." + ) + print(f" FEHLER: {error_msg}") + meldungen["errors"].append(("symbol_with_at_not_in_boundary", error_msg)) + else: + print(f" Alle {len(symbols_with_at)} Symbole mit '@' im IO liegen in einem Renamer-Bereich.") + + +def rectangles_overlap(boundary1, boundary2): + """ + Prüft ob sich zwei Rechteck-Boundaries überlappen. + + Args: + boundary1: Liste von (x, y) Punkten (mindestens 4 Eckpunkte) + boundary2: Liste von (x, y) Punkten (mindestens 4 Eckpunkte) + + Returns: + True wenn sich die Rechtecke überlappen + """ + # Extrahiere Min/Max für beide Rechtecke + b1_xs = [p[0] for p in boundary1] + b1_ys = [p[1] for p in boundary1] + b1_left, b1_right = min(b1_xs), max(b1_xs) + b1_bottom, b1_top = min(b1_ys), max(b1_ys) + + b2_xs = [p[0] for p in boundary2] + b2_ys = [p[1] for p in boundary2] + b2_left, b2_right = min(b2_xs), max(b2_xs) + b2_bottom, b2_top = min(b2_ys), max(b2_ys) + + # Prüfe ob keine Überlappung + no_overlap = (b1_right <= b2_left or # B1 ist links von B2 + b2_right <= b1_left or # B2 ist links von B1 + b1_top <= b2_bottom or # B1 ist unter B2 + b2_top <= b1_bottom) # B2 ist unter B1 + + return not no_overlap + + +def check_overlapping_renamer_blocks(renamer_groups, meldungen): + """ + Prüft ob sich Renamer-Rechtecke überlappen. + Polyline-Pfade (DIRECTION=POLYLINE_PATH) werden nicht geprüft. + + Args: + renamer_groups: Dictionary mit gruppierten Renamer-Blöcken + meldungen: Meldungen (neue_meldungen) fuer Fehler/Warnungen + """ + print("\n" + "="*60) + print("Prüfe auf überlappende Renamer-Bereiche") + print("="*60) + + # Sammle alle Rechteck-Renamer (ohne POLYLINE_PATH) + rectangle_renamers = [] + for config_key, group_blocks in renamer_groups.items(): + for insert, boundary, attributes, is_rectangle in group_blocks: + direction = attributes.get("DIRECTION", "") + block_name = attributes.get('NAME', attributes.get('NAME1', 'UNKNOWN')) + + # Überspringe POLYLINE_PATH + if direction == 'POLYLINE_PATH': + continue + + # Nur Rechtecke prüfen + if is_rectangle: + rectangle_renamers.append({ + 'name': block_name, + 'position': insert.dxf.insert, + 'boundary': boundary, + 'direction': direction, + 'is_rectangle': is_rectangle + }) + + # Prüfe alle Paare auf Überlappung + overlaps_found = [] + for i in range(len(rectangle_renamers)): + for j in range(i + 1, len(rectangle_renamers)): + r1 = rectangle_renamers[i] + r2 = rectangle_renamers[j] + + if rectangles_overlap(r1['boundary'], r2['boundary']): + overlaps_found.append((r1, r2)) + + # Melde Fehler + if overlaps_found: + print(f"\nFEHLER: {len(overlaps_found)} Überlappung(en) zwischen Renamer-Bereichen gefunden:") + for r1, r2 in overlaps_found: + error_msg = ( + f"Renamer-Bereich '{r1['name']}' an Position " + f"({r1['position'][0]:.1f}, {r1['position'][1]:.1f}) " + f"überlappt mit Renamer-Bereich '{r2['name']}' an Position " + f"({r2['position'][0]:.1f}, {r2['position'][1]:.1f}). " + f"Symbole könnten mehrfach nummeriert werden." + ) + print(f" {error_msg}") + meldungen["errors"].append(("overlapping_renamer_blocks", error_msg)) + else: + print(f"\nOK: Keine Überlappungen zwischen {len(rectangle_renamers)} Renamer-Rechtecken gefunden") + + +def process_renamer_blocks(doc, msp, renamer_layers, meldungen, polyline_max_distance=500.0): + """ + Verarbeitet alle Renamer-Blöcke auf den angegebenen Layern. + Gruppiert Blöcke mit identischer Konfiguration und nummeriert alle Symbole + aus allen Blöcken einer Gruppe gemeinsam. + + Args: + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + + Returns: + Tuple (all_renamed, renamer_groups): + - all_renamed: Liste der umbenannten Symbole + - renamer_groups: Dictionary der gruppierten Renamer-Blöcke + """ + # Erster Pass: Sammle und gruppiere alle Renamer-Blöcke + renamer_groups = collect_and_group_renamer_blocks(doc, msp, renamer_layers, meldungen) + + # Prüfe auf überlappende Renamer-Bereiche + check_overlapping_renamer_blocks(renamer_groups, meldungen) + + # Zweiter Pass: Verarbeite jede Gruppe + all_renamed = process_renamer_groups(doc, msp, renamer_layers, renamer_groups, meldungen, polyline_max_distance) + + # Dritter Pass: Prüfe ob alle Symbole mit "@" im IO in einem Renamer-Bereich liegen + check_symbols_in_boundaries(doc, msp, renamer_groups, meldungen, polyline_max_distance) + + return all_renamed, renamer_groups + + +def collect_all_symbols_from_groups(doc, msp, renamer_groups, renamer_layers, meldungen, polyline_max_distance=500.0): + """ + Sammelt alle Symbole aus allen Renamer-Gruppen für Visualisierungszwecke. + + Args: + doc: DXF-Dokument + msp: Modelspace + renamer_groups: Dictionary mit gruppierten Renamer-Blöcken + renamer_layers: Liste der Layer mit Renamer-Blöcken + meldungen: Meldungen (neue_meldungen) fuer Fehler/Warnungen + polyline_max_distance: Maximale Distanz zur Polylinie (aus Config) + + Returns: + Liste aller gefundenen Symbole mit 'entity', 'position', 'attributes', 'layer' + """ + all_symbols = [] + seen_entities = set() # Verhindere Duplikate + + for config_key, group_blocks in renamer_groups.items(): + if not group_blocks: + continue + + reference_attributes = group_blocks[0][2] + + for insert, boundary, attributes, is_rectangle in group_blocks: + # Finde Symbole innerhalb dieses Bereichs + symbols = find_symbols_in_boundary(doc, msp, boundary, renamer_layers, attributes, meldungen, polyline_max_distance) + + # Füge Symbole hinzu, aber nur wenn das Entity noch nicht gesehen wurde + for symbol in symbols: + entity_id = id(symbol['entity']) + if entity_id not in seen_entities: + seen_entities.add(entity_id) + all_symbols.append(symbol) + + return all_symbols + + +def process_single_file(dxf_path, work_dir, renamer_layers, polyline_max_distance, args): + """ + Verarbeitet eine einzelne DXF-Datei. + + Args: + dxf_path: Pfad zur DXF-Datei + work_dir: Work-Verzeichnis + renamer_layers: Liste der konfigurierten Renamer-Layer + polyline_max_distance: Maximale Distanz zur Polylinie + args: Argparse-Argumente + + Returns: + Tuple (success, meldungen): + - success: True wenn erfolgreich verarbeitet + - meldungen: Meldungen (neue_meldungen) mit gesammelten Fehlern/Warnungen + """ + # Meldungen fuer diese Datei + meldungen = neue_meldungen() + + print(f"\n{'='*80}") + print(f"Verarbeite Datei: {dxf_path.name}") + print(f"{'='*80}\n") + + # Lese DXF-Datei + if dxf_is_binary(dxf_path): + print("DXF-Datei ist binär. Lese komplette Datei. Achten Sie auf RAM-Nutzung.") + doc = get_dxf_file(dxf_path) + msp = doc.modelspace() + else: + print("DXF-Datei ist ASCII.") + doc = get_dxf_file(dxf_path) + msp = doc.modelspace() + + # Verarbeite Renamer-Blöcke + print("\n" + "="*60) + print("Starte Verarbeitung der Renamer-Blöcke") + print("="*60 + "\n") + + renamed_symbols, renamer_groups = process_renamer_blocks(doc, msp, renamer_layers, meldungen, polyline_max_distance) + + print("\n" + "="*60) + print(f"Verarbeitung abgeschlossen: {len(renamed_symbols)} Symbole nummeriert") + print("="*60 + "\n") + + # Ausgabe der Ergebnisse + if renamed_symbols: + print("Nummerierte Symbole:") + for item in renamed_symbols: + print(f" {item['old_value']} -> {item['new_value']} (Layer: {item['layer']}, Pos: {item['position']})") + + # Zeichne Symbol-Rahmen wenn gewünscht + if args.show_symbol_frames: + all_symbols = collect_all_symbols_from_groups(doc, msp, renamer_groups, renamer_layers, meldungen, polyline_max_distance) + if all_symbols: + draw_symbol_frames(doc, msp, all_symbols) + else: + print("\nKeine Symbole für Rahmen-Zeichnung gefunden") + + # Speichere DXF-Datei wenn nicht dry-run + if not args.dryrun: + if args.overwrite: + # Überschreibe die Originaldatei + output_path = dxf_path + doc.saveas(output_path) + print(f"\nOriginaldatei überschrieben: {output_path}") + else: + # Erstelle neue Datei mit _numbered Suffix + output_path = dxf_path.parent / f"{dxf_path.stem}_numbered{dxf_path.suffix}" + doc.saveas(output_path) + print(f"\nNummerierte DXF-Datei gespeichert: {output_path}") + else: + print("\nDry-run Modus: DXF-Datei wurde nicht überschrieben") + + # Schreibe JSON-Ausgabe wenn gewünscht + if args.info: + # Formatiere die Ausgabe für bessere Lesbarkeit in JSON + formatted_symbols = [] + for symbol in renamed_symbols: + formatted_symbols.append({ + 'old_value': symbol['old_value'], + 'new_value': symbol['new_value'], + 'layer': symbol['layer'], + 'position': { + 'x': symbol['position'][0], + 'y': symbol['position'][1] + } + }) + + output_data = { + 'renamed_symbols': formatted_symbols, + 'total_count': len(formatted_symbols) + } + + info_path = Path(args.info) + + # Wenn nur ein Dateiname ohne Pfad angegeben wurde, speichere im work-Ordner + if info_path.parent == Path('.'): + output_dir = dxf_path.parent + output_filename = info_path.name + else: + # Absoluter oder relativer Pfad mit Verzeichnis angegeben + output_dir = info_path.parent + output_filename = info_path.name + + write_json_file(output_data, output_dir, output_filename) + print(f"Ergebnisse in JSON gespeichert: {output_dir / output_filename}") + + # Schreibe Fehler-Datei nur wenn Fehler oder Warnungen vorhanden sind + if (meldungen["errors"] or meldungen["warnings"]): + if args.errorfile: + error_data = meldungen_als_dict(meldungen) + error_path = Path(args.errorfile) + + # Wenn nur ein Dateiname ohne Pfad angegeben wurde, speichere im work-Ordner + if error_path.parent == Path('.'): + output_dir = dxf_path.parent + output_filename = error_path.name + else: + # Absoluter oder relativer Pfad mit Verzeichnis angegeben + output_dir = error_path.parent + output_filename = error_path.name + + write_json_file(error_data, output_dir, output_filename) + print(f"Fehler/Warnungen gespeichert: {output_dir / output_filename}") + else: + # Keine explizite Fehlerdatei angegeben, generiere eine basierend auf Input-Datei + error_filename = f"{dxf_path.stem}_errors.json" + error_data = meldungen_als_dict(meldungen) + write_json_file(error_data, dxf_path.parent, error_filename) + print(f"Fehler/Warnungen gespeichert: {dxf_path.parent / error_filename}") + + # Status-Meldung + if meldungen["errors"]: + print("\n!!! Es sind Fehler aufgetreten !!!") + return False, meldungen + elif (meldungen["errors"] or meldungen["warnings"]): + print("\n(Warnungen vorhanden, aber keine kritischen Fehler)") + return True, meldungen + else: + return True, meldungen + + +def main(argv=None): + """ + Aufruf: python create_numbers.py --filename [--config ...] + [--errorfile ...] [--info ...] [--dryrun | --overwrite] + [--show_symbol_frames] + + Konfiguration: /enumerate.cfg oder --config. + Rueckgabe: Exitcode (0 = ohne Fehler, Warnungen erlaubt). + """ + config_dir = os.environ.get("DXFM_CFG", "") + default_config = Path(config_dir) / "enumerate.cfg" if config_dir else None + + parser = argparse.ArgumentParser( + description='Nummeriert Symbole in DXF-Dateien basierend auf Renamer-Bloecken', + prog='create_numbers', + ) + parser.add_argument('-f', '--filename', action='store', required=True, help='DXF-Datei die verarbeitet werden soll', metavar='myfile.dxf') + parser.add_argument('-c', '--config', action='store', help='enumerate.cfg (Vorgabe: $DXFM_CFG/enumerate.cfg)') + parser.add_argument('-e', '--errorfile', action='store', required=False, help='JSON-Datei fuer Fehler und Warnungen', metavar='errors.json') + parser.add_argument('-i', '--info', action='store', help='Schreibe Ergebnisse der Nummerierung in eine JSON-Datei') + parser.add_argument('-d', '--dryrun', action='store_true', help='DXF-Datei nicht schreiben, nur Konsole (und --info)') + parser.add_argument('--overwrite', action='store_true', help='Ueberschreibt die Originaldatei anstatt eine neue Datei mit Suffix _numbered zu erstellen') + parser.add_argument('--show_symbol_frames', action='store_true', help='Zeichne Rahmen um jedes Symbol, um die Grenze des Symbols zu visualisieren') + + args = parser.parse_args(argv) + + if args.overwrite and args.dryrun: + parser.error("--overwrite und --dryrun koennen nicht zusammen verwendet werden") + + config_path = Path(args.config) if args.config else default_config + if config_path is None or not config_path.exists(): + print(f"Konfigurationsdatei nicht gefunden: {config_path}") + return 1 + + renamer_layers = read_config_layers(config_path) + print(f"Konfigurierte Layer: {renamer_layers}") + if not renamer_layers: + print("Keine Layer in der Konfiguration gefunden") + return 1 + + polyline_max_distance = read_config_parameters(config_path).get('polyline_max_distance', 500.0) + print(f"Parameter: polyline_max_distance = {polyline_max_distance}") + + dxf_path = Path(args.filename) + if dxf_path.suffix.lower() != ".dxf": + print("Nur fuer .dxf Dateien verfuegbar") + return 1 + if not dxf_path.exists(): + print(f"Datei nicht gefunden: {dxf_path}") + return 1 + + _success, meldungen = process_single_file(dxf_path, dxf_path.parent, renamer_layers, + polyline_max_distance, args) + return 1 if meldungen["errors"] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/lib/hw_materialfluss.py b/lib/hw_materialfluss.py index 96cb7a9..47d2867 100644 --- a/lib/hw_materialfluss.py +++ b/lib/hw_materialfluss.py @@ -9,7 +9,9 @@ Aufruf: Gestartet vom Befehl HW_MATERIALFLUSS (Lisp/HwPlanung.lsp, Menue HW Planung -> Materialfluss erzeugen) direkt nach EXPORTCSV; die Ausgabe heisst wie die Zeichnung mit Anhang "_HW.dxf". Alles wird flach in die XY-Ebene (Z = 0) -gezeichnet - es geht um den Grundriss des Materialflusses. +gezeichnet - es geht um den Grundriss des Materialflusses. Gibt es die Datei +schon (z.B. mit den Symbolen aus hw_symbole.py), wird nur der Inhalt der +HW_*-Layer ersetzt, alles andere bleibt. Was gezeichnet wird: * Kreisel/Eckrad: geschlossene Polylinie als Rechteck um die Laufbahn @@ -200,10 +202,45 @@ def _xy(punkt): return (punkt[0], punkt[1]) if punkt else None -def zeichnen(elemente, ausgabe, layer): - doc = ezdxf.new("R2018", setup=True) - doc.header["$INSUNITS"] = 4 # mm +# Markierung aller erzeugten Materialfluss-Objekte - daran erkennt LISP sie, +# wenn die Ausgabe in die AKTUELLE Zeichnung geht (HwPlanung.lsp, +# hw-alte-ausgabe), und ein Folgelauf, was er ersetzen darf. +XDATA_APP = "SSG_HW_MF" + + +def hw_dokument_oeffnen(pfad, einheitenlos=False): + """Vorhandene Ausgabe-DXF oeffnen oder neu anlegen (R2018, mm). + + Materialfluss (dieses Skript) und Symbole (hw_symbole.py) schreiben in + DIESELBE Datei und ersetzen dort nur ihren eigenen Anteil - was der + Anwender ergaenzt hat (Renamer-Rahmen, Polylinien), bleibt erhalten. + einheitenlos: $INSUNITS = 0 - fuer die Zwischendatei, die LISP als Block + in die aktuelle Zeichnung einfuegt; mit Einheit wuerde BricsCAD sie beim + Einfuegen in eine Zeichnung mit anderer Einheit skalieren. + """ + if os.path.isfile(pfad): + doc = ezdxf.readfile(pfad) + else: + doc = ezdxf.new("R2018", setup=True) + doc.header["$INSUNITS"] = 4 # mm + if einheitenlos: + doc.header["$INSUNITS"] = 0 + return doc + + +def _ist_materialfluss(e, eigene_layer): + return e.has_xdata(XDATA_APP) or e.dxf.layer in eigene_layer + + +def zeichnen(elemente, ausgabe, layer, einheitenlos=False): + doc = hw_dokument_oeffnen(ausgabe, einheitenlos) msp = doc.modelspace() + # Materialfluss des vorigen Laufs entfernen (markiert oder - aeltere + # Dateien ohne Markierung - auf den HW-Layern) + eigene = {name for name, _farbe in layer.values()} + for e in [e for e in msp if _ist_materialfluss(e, eigene)]: + msp.delete_entity(e) + vorher = {e.dxf.handle for e in msp} for name, farbe in layer.values(): if name not in doc.layers: doc.layers.add(name, color=farbe) @@ -267,6 +304,12 @@ def zeichnen(elemente, ausgabe, layer): msp.add_line(k1, k2, dxfattribs=ly("hw_entladung")) zaehler["entladung"] += 1 + if XDATA_APP not in doc.appids: + doc.appids.new(XDATA_APP) + for e in msp: + if e.dxf.handle not in vorher: + e.set_xdata(XDATA_APP, [(1000, "materialfluss")]) + doc.saveas(ausgabe) return zaehler, warnungen @@ -277,6 +320,8 @@ def main(argv=None): ap.add_argument("ausgabe") ap.add_argument("--layer-cfg", default=None, help="Pfad zu layer.cfg (Vorgabe: $DXFM_CFG/layer.cfg)") + ap.add_argument("--einheitenlos", action="store_true", + help="$INSUNITS = 0 (Zwischendatei fuer die aktuelle Zeichnung)") args = ap.parse_args(argv) cfg = args.layer_cfg @@ -284,7 +329,12 @@ def main(argv=None): cfg = os.path.join(os.environ["DXFM_CFG"], "layer.cfg") elemente = load_elements(args.csv) - zaehler, warnungen = zeichnen(elemente, args.ausgabe, layer_cfg_lesen(cfg)) + try: + zaehler, warnungen = zeichnen(elemente, args.ausgabe, layer_cfg_lesen(cfg), + args.einheitenlos) + except PermissionError: + print(f"FEHLER: {args.ausgabe} ist gesperrt (in BricsCAD geoeffnet?) - bitte schliessen.") + return 1 print(f"HW-Materialfluss geschrieben: {args.ausgabe}") print(" " + ", ".join(f"{k}: {v}" for k, v in zaehler.items())) for w in warnungen: diff --git a/lib/hw_symbole.py b/lib/hw_symbole.py new file mode 100644 index 0000000..be6b8e4 --- /dev/null +++ b/lib/hw_symbole.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +hw_symbole.py - E-Geraete-Symbole der HW-Planung in die _HW.dxf setzen. + +Aufruf: + python hw_symbole.py --symbole + +Gestartet von HW_SYMBOLE / HW_SYM_* (Lisp/HwPlanung.lsp). Den Plan schreibt +LISP aus der Ursprungszeichnung (Bauteile nach cfg/io_regeln.json): + + {"praefixe": ["MB", "BG"], + "symbole": [{"praefix": "MB", "io": "MB1@@@", "block": "ILS_Separator-Ausgang", + "x": 400.0, "y": 13920.0, "rolle": "sep_ventil", + "key": "1A2/3B4", "bauteil": "separator"}, ...]} + +x/y ist der ZIELPUNKT des Bauteils (Sensor, AS/ES-Element, Motor- bzw. +Kreiselzentrum), nicht der Einfuegepunkt. Der Plan enthaelt immer ALLE +Symbole; "praefixe" sagt, welche dieser Lauf einfuegt bzw. ersetzt - die +Lage wird trotzdem aus dem ganzen Plan berechnet, damit ein Einzelbefehl +(HW_SYM_VENTILE) dieselbe Lage liefert wie HW_SYMBOLE. + +Die Symbole selbst (Rahmen, Layer, Attribute mit Lage/Hoehe/Vorgabewerten) +stehen in cfg/hw_symbole.json - es gibt keine Symbol-Bibliothek als +Referenzdokument. Ablauf: + * die _HW.dxf oeffnen oder neu anlegen (hw_materialfluss.hw_dokument_oeffnen) + * alle frueher erzeugten Symbole der Praefixe in "praefixe" entfernen + (erkannt an der XDATA SSG_HW_SYM) - andere Symbole und alles, was der + Anwender gezeichnet hat, bleiben stehen + * die benoetigten Blockdefinitionen aus der Konfiguration (neu) aufbauen - + so wirkt jede Aenderung an hw_symbole.json beim naechsten Lauf + * Lage berechnen (platzieren): die Marke REALE_POSITION jedes Symbols + liegt mit ihrem x genau auf dem Zielpunkt, der Kasten daneben; die + Symbole eines Bauteils (z.B. Ventil + Sensor am Separator) stehen + luekenlos uebereinander, als Gruppe um den Zielpunkt zentriert. + Ueberlappt eine Gruppe (2D-Bounding-Boxen der Rahmen) mit einer schon + gesetzten, wird sie nur in y verschoben - x bleibt auf dem Ziel. + * je Plan-Eintrag den Block einfuegen; IO = Vorlage (z.B. MB1@@@), die + Platzhalter-ID des Symbols (Wert von IO, z.B. "MA0") wird in VERW/ + BEZEICHNUNG ebenfalls durch die Vorlage ersetzt - create_numbers.py + ersetzt spaeter alle '@' in einem Zug. +""" + +import argparse +import json +import os +import sys +from collections import Counter + +from ezdxf.enums import TextEntityAlignment + +from hw_materialfluss import hw_dokument_oeffnen + +XDATA_APP = "SSG_HW_SYM" +ERSETZEN_IN = ("VERW", "BEZEICHNUNG") +# Kurzzeichen in hw_symbole.json -> ezdxf (fehlt = links auf der Grundlinie) +AUSRICHTUNG = { + "C": TextEntityAlignment.CENTER, + "R": TextEntityAlignment.RIGHT, + "ML": TextEntityAlignment.MIDDLE_LEFT, + "MC": TextEntityAlignment.MIDDLE_CENTER, + "MR": TextEntityAlignment.MIDDLE_RIGHT, +} + + +# ------------------------------------------------------------------ +# Reine Kerne +# ------------------------------------------------------------------ + +def symbol_attribute(sdef, vorlage): + """Attribut-Beschreibungen eines Symbols: die der Vorlage ohne "ohne", + je Tag mit den Abweichungen aus sdef["attribute"] (Wert null = Vorlagen- + Eintrag entfernen, z.B. "stil": null -> Standard-Textstil).""" + ohne = set(sdef.get("ohne", [])) + abw = sdef.get("attribute", {}) + erg = [] + for a in vorlage["attribute"]: + if a["tag"] in ohne: + continue + a = dict(a) + for k, v in abw.get(a["tag"], {}).items(): + if v is None: + a.pop(k, None) + else: + a[k] = v + erg.append(a) + return erg + + +def symbol_vorgaben(sdef, vorlage): + """{TAG: Vorgabewert} eines Symbols: Vorlagen-"wert", vom Symbol ueberschrieben.""" + werte = {a["tag"]: a.get("wert", "") for a in symbol_attribute(sdef, vorlage)} + werte.update({k: v for k, v in sdef.get("werte", {}).items() if k in werte}) + return werte + + +def attribut_werte(vorgaben, io): + """Werte fuer add_auto_attribs: IO = Vorlage, Platzhalter-ID in VERW/ + BEZEICHNUNG ersetzt (z.B. "Motor MA0" -> "Motor MA1@@@").""" + alt = vorgaben.get("IO", "") + werte = {"IO": io} + for tag in ERSETZEN_IN: + if tag in vorgaben and alt: + werte[tag] = vorgaben[tag].replace(alt, io) + return werte + + +def symbol_geometrie(konfig, name): + """Rahmen-Box (x0, y0, x1, y1) und Marke (x, y) von REALE_POSITION eines + Symbols, beides relativ zum Einfuegepunkt. Ohne REALE_POSITION gilt die + Mitte der rechten Rahmenkante als Marke.""" + sdef = konfig["symbole"][name] + vorlage = konfig["vorlagen"][sdef["vorlage"]] + dx, dy = sdef.get("versatz", [0.0, 0.0]) + breite, hoehe = vorlage["rahmen"] + box = (dx, dy, dx + breite, dy + hoehe) + marke = (dx + breite, dy + hoehe / 2.0) + for a in symbol_attribute(sdef, vorlage): + if a["tag"] == "REALE_POSITION": + marke = (a["x"] + dx, a["y"] + dy) + return {"box": box, "marke": marke} + + +def _gruppe_auslegen(ziel, geometrien): + """Symbole einer Gruppe uebereinander, Gruppe in y um das Ziel zentriert, + Marke jedes Symbols mit x auf dem Ziel. Rueckgabe: [(einfuegepunkt, box_welt)].""" + oben = ziel[1] + sum(g["box"][3] - g["box"][1] for g in geometrien) / 2.0 + erg = [] + for g in geometrien: + x0, y0, x1, y1 = g["box"] + unten = oben - (y1 - y0) + ex, ey = ziel[0] - g["marke"][0], unten - y0 + erg.append(((ex, ey), (ex + x0, unten, ex + x1, oben))) + oben = unten + return erg + + +def _ueberlappt(a, b, abstand): + return (a[0] < b[2] + abstand and b[0] < a[2] + abstand + and a[1] < b[3] + abstand and b[1] < a[3] + abstand) + + +def _verschiebungen(schritt, maximum): + yield 0.0 + k = 1 + while k * schritt <= maximum: + yield k * schritt + yield -k * schritt + k += 1 + + +def platzieren(gruppen, abstand=20.0, schritt=50.0, max_verschiebung=3000.0): + """Einfuegepunkte fuer Symbolgruppen ohne Ueberlappung der Rahmen. + + gruppen: Liste (ziel (x, y), [geometrie, ...]) in Bearbeitungsfolge, + geometrie wie symbol_geometrie. Jede Gruppe wird ausgelegt + (_gruppe_auslegen) und, falls ihre Rahmen eine schon gesetzte Box + beruehren (Mindestabstand abstand), in y um +-schritt, +-2*schritt, ... + bis max_verschiebung verschoben; x bleibt auf dem Ziel. Findet sich keine + freie Lage, bleibt sie unverschoben und zaehlt als Ueberlappung. + Rueckgabe: (Liste der Einfuegepunkte je Gruppe, Anzahl Ueberlappungen).""" + gesetzt = [] + punkte = [] + ueberlappungen = 0 + for ziel, geometrien in gruppen: + basis = _gruppe_auslegen(ziel, geometrien) + wahl = None + for dy in _verschiebungen(schritt, max_verschiebung): + boxen = [(b[0], b[1] + dy, b[2], b[3] + dy) for _p, b in basis] + if not any(_ueberlappt(b, g, abstand) for b in boxen for g in gesetzt): + wahl = dy + break + if wahl is None: + wahl = 0.0 + ueberlappungen += 1 + gesetzt.extend((b[0], b[1] + wahl, b[2], b[3] + wahl) for _p, b in basis) + punkte.append([(p[0], p[1] + wahl) for p, _b in basis]) + return punkte, ueberlappungen + + +def plan_gruppen(symbole): + """Plan-Eintraege nach Bauteil (key) gruppieren, Reihenfolge des Plans. + Rueckgabe: Liste (ziel, [eintrag, ...]).""" + gruppen = {} + for s in symbole: + gruppen.setdefault(s.get("key", ""), ((s["x"], s["y"]), []))[1].append(s) + return list(gruppen.values()) + + +# ------------------------------------------------------------------ +# Zeichnung +# ------------------------------------------------------------------ + +def symbol_xdata(insert): + """(praefix, rolle, key) eines erzeugten Symbols oder None.""" + if not insert.has_xdata(XDATA_APP): + return None + werte = [v for code, v in insert.get_xdata(XDATA_APP) if code == 1000] + return tuple((werte + ["", "", ""])[:3]) + + +def alte_symbole_entfernen(msp, praefixe): + """Erzeugte Symbole der Praefixe loeschen. Rueckgabe: Anzahl.""" + weg = [e for e in msp.query("INSERT") + if (x := symbol_xdata(e)) is not None and x[0] in praefixe] + for e in weg: + msp.delete_entity(e) + return len(weg) + + +def _layer(doc, name): + if name not in doc.layers: + doc.layers.add(name) + + +def _textstile(doc, konfig): + """Textstile aus der Konfiguration anlegen. Rueckgabe: Name des Standardstils.""" + ts = konfig.get("textstile", {}) + for name, schrift in ts.get("schriften", {}).items(): + if name not in doc.styles: + doc.styles.add(name, font=schrift) + return ts.get("standard", "Standard") + + +def block_aufbauen(doc, name, konfig): + """Blockdefinition name aus der Symbol-Konfiguration (neu) aufbauen. + Der Einfuegepunkt liegt bei (0, 0); Rahmen und Attribute sitzen um + sdef["versatz"] verschoben (Katalog: Eingaenge/Motoren haengen nach unten).""" + sdef = konfig["symbole"][name] + vorlage = konfig["vorlagen"][sdef["vorlage"]] + standard_stil = _textstile(doc, konfig) + dx, dy = sdef.get("versatz", [0.0, 0.0]) + + layer = sdef["layer"] + _layer(doc, layer) + if name in doc.blocks: + blk = doc.blocks.get(name) + blk.delete_all_entities() + else: + blk = doc.blocks.new(name) + + breite, hoehe = vorlage["rahmen"] + ecken = [(dx, dy), (dx + breite, dy), (dx + breite, dy + hoehe), (dx, dy + hoehe)] + blk.add_lwpolyline(ecken, close=True, dxfattribs={"layer": layer, "color": 7}) + + schraffur = vorlage.get("schraffur") + if schraffur and not sdef.get("ohne_schraffur"): + h_layer = sdef.get("schraffur_layer", layer) + _layer(doc, h_layer) + farbe = sdef.get("schraffur_farbe", 256) # fehlt = VONLAYER + hatch = blk.add_hatch(color=farbe, dxfattribs={"layer": h_layer}) + hatch.set_pattern_fill(schraffur["muster"], color=farbe, + scale=schraffur.get("massstab", 1.0)) + hatch.paths.add_polyline_path(ecken, is_closed=True) + + vorgaben = symbol_vorgaben(sdef, vorlage) + for a in symbol_attribute(sdef, vorlage): + attr_layer = a.get("layer", layer) + _layer(doc, attr_layer) + dxf = {"layer": attr_layer, "height": a["hoehe"], "style": a.get("stil", standard_stil)} + if "breite" in a: + dxf["width"] = a["breite"] + farbe = a.get("farbe") + if farbe == "symbol": + farbe = sdef.get("farbe") + if isinstance(farbe, int): + dxf["color"] = farbe + punkt = (a["x"] + dx, a["y"] + dy) + attdef = blk.add_attdef(a["tag"], punkt, text=vorgaben.get(a["tag"], ""), dxfattribs=dxf) + if a.get("unsichtbar"): + attdef.is_invisible = True + if a.get("ausrichtung") in AUSRICHTUNG: + attdef.set_placement(punkt, align=AUSRICHTUNG[a["ausrichtung"]]) + return vorgaben + + +def symbole_setzen(doc, plan, konfig): + """Symbole aus dem Plan in doc setzen (nur die Praefixe aus plan + "praefixe", Lage aus dem ganzen Plan). Rueckgabe: (gesetzt je Praefix, + entfernt, Bloecke ohne Definition in der Konfiguration, Anzahl Gruppen + mit Ueberlappung).""" + msp = doc.modelspace() + praefixe = set(plan.get("praefixe") or []) + symbole = plan.get("symbole") or [] + + entfernt = alte_symbole_entfernen(msp, praefixe) + bekannt = konfig.get("symbole", {}) + namen = sorted({s["block"] for s in symbole}) + fehlend = [n for n in namen if n not in bekannt] + vorgaben_je_block = {n: block_aufbauen(doc, n, konfig) for n in namen if n in bekannt} + if XDATA_APP not in doc.appids: + doc.appids.new(XDATA_APP) + + p = konfig.get("platzierung", {}) + geometrie = {n: symbol_geometrie(konfig, n) for n in vorgaben_je_block} + gruppen = [(ziel, [s for s in eintraege if s["block"] in geometrie]) + for ziel, eintraege in plan_gruppen(symbole)] + gruppen = [g for g in gruppen if g[1]] + punkte, ueberlappungen = platzieren( + [(ziel, [geometrie[s["block"]] for s in eintraege]) for ziel, eintraege in gruppen], + abstand=p.get("abstand", 20.0), schritt=p.get("schritt", 50.0), + max_verschiebung=p.get("max_verschiebung", 3000.0)) + + gesetzt = Counter() + for (_ziel, eintraege), gruppen_punkte in zip(gruppen, punkte): + for s, (x, y) in zip(eintraege, gruppen_punkte): + if s["praefix"] not in praefixe: + continue + ref = msp.add_blockref(s["block"], (x, y, 0.0), + dxfattribs={"layer": bekannt[s["block"]]["layer"]}) + ref.add_auto_attribs(attribut_werte(vorgaben_je_block[s["block"]], s["io"])) + ref.set_xdata(XDATA_APP, [(1000, s["praefix"]), (1000, s["rolle"]), + (1000, s.get("key", ""))]) + gesetzt[s["praefix"]] += 1 + return gesetzt, entfernt, fehlend, ueberlappungen + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[1]) + ap.add_argument("plan") + ap.add_argument("ausgabe") + ap.add_argument("--symbole", required=True, help="Symboldefinitionen (cfg/hw_symbole.json)") + ap.add_argument("--einheitenlos", action="store_true", + help="$INSUNITS = 0 (Zwischendatei fuer die aktuelle Zeichnung)") + args = ap.parse_args(argv) + + if not os.path.isfile(args.symbole): + print(f"FEHLER: Symboldefinitionen nicht gefunden: {args.symbole}") + return 1 + with open(args.symbole, encoding="utf-8") as fh: + konfig = json.load(fh) + with open(args.plan, encoding="utf-8") as fh: + plan = json.load(fh) + + doc = hw_dokument_oeffnen(args.ausgabe, args.einheitenlos) + gesetzt, entfernt, fehlend, ueberlappungen = symbole_setzen(doc, plan, konfig) + try: + doc.saveas(args.ausgabe) + except PermissionError: + print(f"FEHLER: {args.ausgabe} ist gesperrt (in BricsCAD geoeffnet?) - bitte schliessen.") + return 1 + + print(f"HW-Symbole geschrieben: {args.ausgabe}") + if entfernt: + print(f" {entfernt} vorhandene Symbole ersetzt") + for p in plan.get("praefixe") or []: + print(f" {p}: {gesetzt.get(p, 0)} Symbole") + for b in fehlend: + print(f" WARNUNG: Symbol nicht in {os.path.basename(args.symbole)} definiert: {b}") + if ueberlappungen: + print(f" WARNUNG: {ueberlappungen} Bauteil(e) ohne freie Lage - Symbole ueberlappen dort") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/menu/SSG_LIB.cui b/menu/SSG_LIB.cui index 0913cbf..20cda01 100644 --- a/menu/SSG_LIB.cui +++ b/menu/SSG_LIB.cui @@ -698,295 +698,301 @@ ^C^C(ssg-ensure "HwPlanung") HW_SYMBOLE - + + + Symbole nummerieren + ^C^C(ssg-ensure "HwPlanung") HW_NUMMERIEREN + + + Marker einfuegen ^C^C(ssg-ensure "Tro_Edit") TRO_INSERT - + Marker bearbeiten ^C^C(ssg-ensure "Tro_Edit") TRO_EDIT - + Bearbeiten (Auto) ^C^C(ssg-ensure "SSG_LIB_Commands") SSG_BLOCKEDIT - + Verbindung einfuegen ^C^C(ssg-ensure "Connection_Edit") CONNECTION_INSERT - + Verbindung bearbeiten ^C^C(ssg-ensure "Connection_Edit") CONNECTION_EDIT - + Eingang ^C^C(ssg-ensure "SSG_LIB_Commands") ILS_EINGANG_INSERT - + Ausgang ^C^C(ssg-ensure "SSG_LIB_Commands") ILS_AUSGANG_INSERT - + SPS Schliessen ^C^C - + Hoehen-Check (Montagehoehe/Buehne) ^C^C(ssg-ensure "check_hoehen") HOEHEN_CHECK - + Export Sivas ^C^C(ssg-ensure "export") EXPORTSIVAS - + Export CSV ^C^C(ssg-ensure "export") EXPORTCSV - + Umschalten 2D und 3D ^C^C(ssg-ensure "SSG_LIB_Commands") SSG_DIM_SWITCH - + Alle Bloecke auf 2D ^C^C(ssg-ensure "SSG_LIB_Commands") SSG_DIM_ALL_2D - + Alle Bloecke auf 3D ^C^C(ssg-ensure "SSG_LIB_Commands") SSG_DIM_ALL_3D - + Sprache ^C^C(ssg-ensure "SSG_LIB_Commands") SSG_SPRACHE - + Planquadrate an/aus ^C^C(ssg-ensure "DrawPlanquadrate") PLANQUADRATE_TOGGLE - + CSV-Export ^C^C(ssg-ensure "export") CSV_EXPORT_KONFIG - + Bloecke zaehlen ^C^C(ssg-ensure "ssg_blocktools") SSG_BLOCK_COUNT - + Bloecke loeschen ^C^C(ssg-ensure "ssg_blocktools") SSG_BLOCK_DELETE - + Attribute ersetzen ^C^C(ssg-ensure "ssg_blocktools") SSG_ATTR_ERSETZEN - + Text ersetzen ^C^C(ssg-ensure "ssg_texttools") SSG_TEXT_ERSETZEN - + Text parallel beschriften ^C^C(ssg-ensure "ssg_texttools") SSG_TEXT_PARALLEL - + Texthoehe aendern ^C^C(ssg-ensure "ssg_texttools") SSG_TEXT_HOEHE - + Textstil aendern ^C^C(ssg-ensure "ssg_texttools") SSG_TEXT_STIL - + Faehnchen + Stueckliste aus Zeichnung ^C^C(ssg-ensure "ssg_stueckliste") SSG_FAEHNCHEN_STUECKLISTE - + Din Formate ^C^C(ssg-ensure "DinFormate") DIN_FORMATE - + alle Tests ausfuehren und exportieren ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_run_all.lsp")) (c:SSG_RUN_ALL_TESTS_EXPORT) - + Tests ausfuehren und stehen lassen ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_run_all.lsp")) (c:SSG_RUN_ALL_TESTS_OFFEN) - + Export CSV/Sivas ALL ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_export_all.lsp")) (c:TEST_EXPORT_ALL) - + Kreisel ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_kreisel.lsp")) (c:TEST_KREISEL) - + Foerderer ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_foerderer.lsp")) (c:TEST_FOERDERER) - + Foerderer-Linienzug ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_linienzug.lsp")) (c:TEST_LINIENZUG) (c:TEST_LINIENZUG_STUFE2) (c:TEST_LINIENZUG_MODUS2) - + Omniflo ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_omniflo.lsp")) (c:TEST_OMNIFLO) - + Omniflo Strecke ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_omniflo_strecke.lsp")) (c:TEST_OMNIFLO_STRECKE) - + Gefaellestrecke ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_gefaellestrecke.lsp")) (c:TEST_GEFAELLESTRECKE) - + Mubea Gesamtmodell ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_mubea.lsp")) (c:TEST_MUBEA) - + HundM05 Kreisel-Abschnitt ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_hundm05.lsp")) (c:TEST_HUNDM05) - + Dematic/Arvato Flowpuffer (Vorlage) ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_dematic_arvato.lsp")) (c:TEST_DEMATIC_ARVATO) - + ILS 3.0 Showroom (Vorlage) ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_showroom_ils.lsp")) (c:TEST_SHOWROOM_ILS) - + Testanlage test_system ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_test_system.lsp")) (c:TEST_TEST_SYSTEM) - + KS 2D/3D pruefen (CSV) ^C^C(load (strcat (getenv "DXFMAKRO") "/Lisp/ks_pruefung.lsp")) KS_PRUEFEN - + Kreisel bearbeiten (ssg-ensure "KreiselInsert") KreiselEdit - + Eckrad bearbeiten (ssg-ensure "KreiselInsert") EckradEdit - + Omniflo Element bearbeiten (ssg-ensure "OmniModulInsert") OMNI_EDIT - + Gefaellestrecke bearbeiten (ssg-ensure "Gefaellestrecke") GEFAELLESTRECKE_EDIT - + VarioFoerderer bearbeiten (ssg-ensure "VarioFoerderer") VARIOFOERDERER_EDIT - + TRO-Marker bearbeiten (ssg-ensure "Tro_Edit") TRO_EDIT - + Verbindung bearbeiten (ssg-ensure "Connection_Edit") CONNECTION_EDIT - + SSG Bearbeiten (Auto) (ssg-ensure "SSG_LIB_Commands") SSG_BLOCKEDIT @@ -1006,11 +1012,11 @@ - - - - - + + + + + ILS @@ -1787,335 +1793,342 @@ + + + Symbole nummerieren + + + + - + SPS - - + + Eingang - + Ausgang - + SPS Schliessen - + - + TRO Marker einfuegen - + Marker bearbeiten - + Bearbeiten (Auto) - + - + Connections Verbindung einfuegen - + Verbindung bearbeiten - + Bearbeiten (Auto) - + - + Export Hoehen-Check (Montagehoehe/Buehne) - + Export Sivas - + Export CSV - - - - - - - Konfiguration - - Umschalten 2D und 3D + + + Konfiguration - Alle Bloecke auf 2D - - - - - - Alle Bloecke auf 3D + Umschalten 2D und 3D - Sprache + Alle Bloecke auf 2D - Planquadrate an/aus + Alle Bloecke auf 3D - CSV-Export + Sprache + + Planquadrate an/aus + + + + + + CSV-Export + + + + - + Werkzeuge Bloecke zaehlen - + Bloecke loeschen - + Attribute ersetzen - + Text ersetzen - + Text parallel beschriften - + Texthoehe aendern - + Textstil aendern - + Faehnchen + Stueckliste aus Zeichnung - + Din Formate - + - + Tests alle Tests ausfuehren und exportieren - + Tests ausfuehren und stehen lassen - + Export CSV/Sivas ALL - + Kreisel - + Foerderer - + Foerderer-Linienzug - + Omniflo - + Omniflo Strecke - + Gefaellestrecke - + Mubea Gesamtmodell - + HundM05 Kreisel-Abschnitt - + Dematic/Arvato Flowpuffer (Vorlage) - + ILS 3.0 Showroom (Vorlage) - + Testanlage test_system - + KS 2D/3D pruefen (CSV) - + - + POP501 SSG_LIB Edit Context Kreisel bearbeiten - + Eckrad bearbeiten - + Omniflo Element bearbeiten - + Gefaellestrecke bearbeiten - + VarioFoerderer bearbeiten - + TRO-Marker bearbeiten - + Verbindung bearbeiten - + SSG Bearbeiten (Auto) - + diff --git a/menu/SSG_LIB.mnu b/menu/SSG_LIB.mnu index 78f2285..874672e 100644 --- a/menu/SSG_LIB.mnu +++ b/menu/SSG_LIB.mnu @@ -148,7 +148,9 @@ [<-Export Lager-JSON]^C^C(ssg-ensure "LagerInsert") LAGER_EXPORT [->HW Planung] [Materialfluss erzeugen]^C^C(ssg-ensure "HwPlanung") HW_MATERIALFLUSS -[<-Symbole erzeugen]^C^C(ssg-ensure "HwPlanung") HW_SYMBOLE +[Symbole erzeugen]^C^C(ssg-ensure "HwPlanung") HW_SYMBOLE +[--] +[<-Symbole nummerieren]^C^C(ssg-ensure "HwPlanung") HW_NUMMERIEREN [->SPS] [->TRO] [Marker einfuegen]^C^C(ssg-ensure "Tro_Edit") TRO_INSERT diff --git a/tests/test_create_numbers.py b/tests/test_create_numbers.py new file mode 100644 index 0000000..8cc0721 --- /dev/null +++ b/tests/test_create_numbers.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Tests fuer lib/create_numbers.py (Nummerierung der Symbole entlang Renamer-Bereichen). +Uebernommen aus kabellaengen/lib/create_numbers_test.py. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) + +from create_numbers import sort_symbols_by_direction # noqa: E402 + +def create_test_symbols(): + """ + Erstellt 16 Test-Symbole auf einem 4x4 Raster. + + Raster-Layout (Y-Achse nach oben, wie in DXF üblich): + Y=300: (0,300) (100,300) (200,300) (300,300) <- Oben + Y=200: (0,200) (100,200) (200,200) (300,200) + Y=100: (0,100) (100,100) (200,100) (300,100) + Y=0: (0,0) (100,0) (200,0) (300,0) <- Unten + X=0 X=100 X=200 X=300 + ^ ^ + Links Rechts + """ + symbols = [] + symbol_id = 1 + + # Erstelle 4x4 Raster + for y in [0, 100, 200, 300]: + for x in [0, 100, 200, 300]: + symbols.append({ + 'id': symbol_id, + 'position': (x, y), + 'entity': None, # Für den Test nicht benötigt + 'attributes': {'IO': f'SYM-{symbol_id}@@'}, + 'layer': 'TEST_LAYER' + }) + symbol_id += 1 + + return symbols + +class TestEntitySorting(unittest.TestCase): + """Tests for running all combinations of sort_symbols_by_direction.""" + + @classmethod + def setUpClass(cls): + """Wird einmal vor allen Tests ausgeführt.""" + print("="*70) + print("Test Suite: sort_symbols_by_direction") + print("="*70) + print() + sys.stdout.flush() + + def test_top_bottom_left_right(self): + """ + Test TOP_BOTTOM/LEFT_RIGHT: Von oben nach unten, in jeder Zeile von links nach rechts + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "TOP_BOTTOM/LEFT_RIGHT") + + # Erwartete Reihenfolge: Zeile Y=300, dann Y=200, dann Y=100, dann Y=0 + # In jeder Zeile: X=0, X=100, X=200, X=300 + expected_positions = [ + (0, 300), (100, 300), (200, 300), (300, 300), # Zeile Y=300 (oben) + (0, 200), (100, 200), (200, 200), (300, 200), # Zeile Y=200 + (0, 100), (100, 100), (200, 100), (300, 100), # Zeile Y=100 + (0, 0), (100, 0), (200, 0), (300, 0) # Zeile Y=0 (unten) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_top_bottom_right_left(self): + """ + Test TOP_BOTTOM/RIGHT_LEFT: Von oben nach unten, in jeder Zeile von rechts nach links + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "TOP_BOTTOM/RIGHT_LEFT") + + # Erwartete Reihenfolge: Zeile Y=300, dann Y=200, dann Y=100, dann Y=0 + # In jeder Zeile: X=300, X=200, X=100, X=0 + expected_positions = [ + (300, 300), (200, 300), (100, 300), (0, 300), # Zeile Y=300 (oben) + (300, 200), (200, 200), (100, 200), (0, 200), # Zeile Y=200 + (300, 100), (200, 100), (100, 100), (0, 100), # Zeile Y=100 + (300, 0), (200, 0), (100, 0), (0, 0) # Zeile Y=0 (unten) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_bottom_top_left_right(self): + """ + Test BOTTOM_TOP/LEFT_RIGHT: Von unten nach oben, in jeder Zeile von links nach rechts + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "BOTTOM_TOP/LEFT_RIGHT") + + # Erwartete Reihenfolge: Zeile Y=0, dann Y=100, dann Y=200, dann Y=300 + # In jeder Zeile: X=0, X=100, X=200, X=300 + expected_positions = [ + (0, 0), (100, 0), (200, 0), (300, 0), # Zeile Y=0 (unten) + (0, 100), (100, 100), (200, 100), (300, 100), # Zeile Y=100 + (0, 200), (100, 200), (200, 200), (300, 200), # Zeile Y=200 + (0, 300), (100, 300), (200, 300), (300, 300) # Zeile Y=300 (oben) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_bottom_top_right_left(self): + """ + Test BOTTOM_TOP/RIGHT_LEFT: Von unten nach oben, in jeder Zeile von rechts nach links + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "BOTTOM_TOP/RIGHT_LEFT") + + # Erwartete Reihenfolge: Zeile Y=0, dann Y=100, dann Y=200, dann Y=300 + # In jeder Zeile: X=300, X=200, X=100, X=0 + expected_positions = [ + (300, 0), (200, 0), (100, 0), (0, 0), # Zeile Y=0 (unten) + (300, 100), (200, 100), (100, 100), (0, 100), # Zeile Y=100 + (300, 200), (200, 200), (100, 200), (0, 200), # Zeile Y=200 + (300, 300), (200, 300), (100, 300), (0, 300) # Zeile Y=300 (oben) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_left_right(self): + """ + Test LEFT_RIGHT: Spaltenweise von links nach rechts + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "LEFT_RIGHT") + + # Spalte für Spalte: X=0, dann X=100, dann X=200, dann X=300 + # Innerhalb jeder Spalte: Y aufsteigend (0, 100, 200, 300) + expected_positions = [ + (0, 0), (0, 100), (0, 200), (0, 300), # Spalte X=0 + (100, 0), (100, 100), (100, 200), (100, 300), # Spalte X=100 + (200, 0), (200, 100), (200, 200), (200, 300), # Spalte X=200 + (300, 0), (300, 100), (300, 200), (300, 300) # Spalte X=300 + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_right_left(self): + """ + Test RIGHT_LEFT: Spaltenweise von rechts nach links + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "RIGHT_LEFT") + + # Spalte für Spalte: X=300, dann X=200, dann X=100, dann X=0 + # Innerhalb jeder Spalte: Y aufsteigend (0, 100, 200, 300) + expected_positions = [ + (300, 0), (300, 100), (300, 200), (300, 300), # Spalte X=300 + (200, 0), (200, 100), (200, 200), (200, 300), # Spalte X=200 + (100, 0), (100, 100), (100, 200), (100, 300), # Spalte X=100 + (0, 0), (0, 100), (0, 200), (0, 300) # Spalte X=0 + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_top_bottom(self): + """ + Test TOP_BOTTOM: Default ist TOP_BOTTOM/LEFT_RIGHT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "TOP_BOTTOM") + + # Sollte identisch mit TOP_BOTTOM/LEFT_RIGHT sein + expected_positions = [ + (0, 300), (100, 300), (200, 300), (300, 300), + (0, 200), (100, 200), (200, 200), (300, 200), + (0, 100), (100, 100), (200, 100), (300, 100), + (0, 0), (100, 0), (200, 0), (300, 0) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_bottom_top(self): + """ + Test BOTTOM_TOP: Default ist BOTTOM_TOP/LEFT_RIGHT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "BOTTOM_TOP") + + # Sollte identisch mit BOTTOM_TOP/LEFT_RIGHT sein + expected_positions = [ + (0, 0), (100, 0), (200, 0), (300, 0), + (0, 100), (100, 100), (200, 100), (300, 100), + (0, 200), (100, 200), (200, 200), (300, 200), + (0, 300), (100, 300), (200, 300), (300, 300) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_left_right_top_bottom(self): + """ + Test LEFT_RIGHT/TOP_BOTTOM: Umgekehrte Reihenfolge, identisch mit TOP_BOTTOM/LEFT_RIGHT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "LEFT_RIGHT/TOP_BOTTOM") + + expected_positions = [ + (0, 300), (0, 200), (0, 100), (0, 0), + (100, 300), (100, 200), (100, 100), (100, 0), + (200, 300), (200, 200), (200, 100), (200, 0), + (300, 300), (300, 200), (300, 100), (300, 0) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_right_left_top_bottom(self): + """ + Test RIGHT_LEFT/TOP_BOTTOM: Umgekehrte Reihenfolge, identisch mit TOP_BOTTOM/RIGHT_LEFT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "RIGHT_LEFT/TOP_BOTTOM") + + expected_positions = [ + (300, 300), (300, 200), (300, 100), (300, 0), + (200, 300), (200, 200), (200, 100), (200, 0), + (100, 300), (100, 200), (100, 100), (100, 0), + (0, 300), (0, 200), (0, 100), (0, 0) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_left_right_bottom_top(self): + """ + Test LEFT_RIGHT/BOTTOM_TOP: Umgekehrte Reihenfolge, identisch mit BOTTOM_TOP/LEFT_RIGHT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "LEFT_RIGHT/BOTTOM_TOP") + + expected_positions = [ + (0, 0), (0, 100), (0, 200), (0, 300), + (100, 0), (100, 100), (100, 200), (100, 300), + (200, 0), (200, 100), (200, 200), (200, 300), + (300, 0), (300, 100), (300, 200), (300, 300), + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_right_left_bottom_top(self): + """ + Test RIGHT_LEFT/BOTTOM_TOP: Umgekehrte Reihenfolge, identisch mit BOTTOM_TOP/RIGHT_LEFT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "RIGHT_LEFT/BOTTOM_TOP") + + # Sollte identisch mit BOTTOM_TOP/RIGHT_LEFT sein + expected_positions = [ + (300, 0), (300, 100), (300, 200), (300, 300), + (200, 0), (200, 100), (200, 200), (200, 300), + (100, 0), (100, 100), (100, 200), (100, 300), + (0, 0), (0, 100), (0, 200), (0, 300), + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + def test_fallback(self): + """ + Test Fallback (unbekannte DIRECTION): Default ist TOP_BOTTOM/LEFT_RIGHT + """ + symbols = create_test_symbols() + sorted_symbols = sort_symbols_by_direction(symbols, "UNKNOWN_DIRECTION") + + # Sollte identisch mit TOP_BOTTOM/LEFT_RIGHT sein + expected_positions = [ + (0, 300), (100, 300), (200, 300), (300, 300), + (0, 200), (100, 200), (200, 200), (300, 200), + (0, 100), (100, 100), (200, 100), (300, 100), + (0, 0), (100, 0), (200, 0), (300, 0) + ] + + actual_positions = [s['position'] for s in sorted_symbols] + + assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}" + + + + +# print("\n16 Symbole auf 4x4 Raster:") +# print(" X-Koordinaten: 0, 100, 200, 300") +# print(" Y-Koordinaten: 0, 100, 200, 300") +# print("\nUnterstützte Richtungen (beide Reihenfolgen):") +# print(" - TOP_BOTTOM/LEFT_RIGHT = LEFT_RIGHT/TOP_BOTTOM") +# print(" - TOP_BOTTOM/RIGHT_LEFT = RIGHT_LEFT/TOP_BOTTOM") +# print(" - BOTTOM_TOP/LEFT_RIGHT = LEFT_RIGHT/BOTTOM_TOP") +# print(" - BOTTOM_TOP/RIGHT_LEFT = RIGHT_LEFT/BOTTOM_TOP") +# print("\nEinfache Richtungen:") +# print(" - LEFT_RIGHT: Spaltenweise links→rechts (sortiert nach X, dann Y)") +# print(" - RIGHT_LEFT: Spaltenweise rechts→links (sortiert nach -X, dann Y)") +# print(" - TOP_BOTTOM → TOP_BOTTOM/LEFT_RIGHT (zeilenweise)") +# print(" - BOTTOM_TOP → BOTTOM_TOP/LEFT_RIGHT (zeilenweise)") + +# # Test kombinierte Richtungen (Y/X Reihenfolge) +# test_top_bottom_left_right() +# test_top_bottom_right_left() +# test_bottom_top_left_right() +# test_bottom_top_right_left() + +# # Test einfache Richtungen +# test_left_right() +# test_right_left() +# test_top_bottom() +# test_bottom_top() + +# # Test umgekehrte Reihenfolge (X/Y Reihenfolge) +# test_left_right_top_bottom() +# test_right_left_top_bottom() +# test_left_right_bottom_top() +# test_right_left_bottom_top() + +# # Test Fallback +# test_fallback() + + +# print("\n" + "="*70) +# print("✓✓✓ ALLE 13 TESTS BESTANDEN ✓✓✓") +# print("="*70) +# print("\nZusammenfassung:") +# print(" - Alle 4 kombinierten Richtungen (Y/X) funktionieren") +# print(" - Alle 4 kombinierten Richtungen (X/Y) funktionieren") +# print(" - Alle 4 einfachen Richtungen mappen korrekt") +# print(" - Fallback funktioniert") + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hw_materialfluss.py b/tests/test_hw_materialfluss.py index 9e40665..29dbf0a 100644 --- a/tests/test_hw_materialfluss.py +++ b/tests/test_hw_materialfluss.py @@ -92,3 +92,40 @@ def test_csv_nach_dxf(tmp_path): msp = ezdxf.readfile(str(dxf_pfad)).modelspace() assert len(msp.query('LWPOLYLINE[layer=="HW_Kreisel"]')) == 1 assert len(msp.query('CIRCLE[layer=="HW_Weiche"]')) == 1 + + +def _csv_mit_kreisel(tmp_path): + kopf = ("Elementnummer;TeileArt;TeileId;Bezeichnung;Planquadrat;Anzahl;Position;" + "Boundingbox;Insertpoint;K1;K2;K3;K4;Nachbarn;TrackIds;Fehler;Warnungen;Merkmale") + zeile = ('1;"ILS 2.0 Kreisel";"0001";"K1";"";1;"0.00, 0.00, 0.00";"5800.00, 800.00, 1.00";' + '"";"";"";"";"";"";"";"";"";{"Drehrichtung": "UZS", "Drehung": 0}') + csv_pfad = tmp_path / "export.csv" + csv_pfad.write_text(kopf + "\n" + zeile + "\n", encoding="utf-8") + return csv_pfad + + +def test_materialfluss_markiert_und_ersetzt_nur_eigenes(tmp_path): + """Erzeugte Objekte tragen XDATA SSG_HW_MF; ein zweiter Lauf ersetzt sie, + laesst aber fremde Geometrie (z.B. Renamer-Rahmen) stehen.""" + csv_pfad = _csv_mit_kreisel(tmp_path) + dxf_pfad = tmp_path / "Test_HW.dxf" + assert hw.main([str(csv_pfad), str(dxf_pfad), "--layer-cfg", ""]) == 0 + doc = ezdxf.readfile(str(dxf_pfad)) + msp = doc.modelspace() + assert msp and all(e.has_xdata(hw.XDATA_APP) for e in msp) + msp.add_line((0, 0), (1, 1), dxfattribs={"layer": "ILS_RENAMER"}) + doc.saveas(str(dxf_pfad)) + + assert hw.main([str(csv_pfad), str(dxf_pfad), "--layer-cfg", ""]) == 0 + msp = ezdxf.readfile(str(dxf_pfad)).modelspace() + assert len(msp.query('LWPOLYLINE[layer=="HW_Kreisel"]')) == 1 + assert len(msp.query('LINE[layer=="ILS_RENAMER"]')) == 1 + + +def test_materialfluss_einheitenlos(tmp_path): + """Zwischendatei fuer die aktuelle Zeichnung: $INSUNITS = 0, sonst + skaliert BricsCAD sie beim Einfuegen.""" + csv_pfad = _csv_mit_kreisel(tmp_path) + dxf_pfad = tmp_path / "Test_HW_tmp.dxf" + assert hw.main([str(csv_pfad), str(dxf_pfad), "--layer-cfg", "", "--einheitenlos"]) == 0 + assert ezdxf.readfile(str(dxf_pfad)).header["$INSUNITS"] == 0 diff --git a/tests/test_hw_symbole.py b/tests/test_hw_symbole.py new file mode 100644 index 0000000..bddda2f --- /dev/null +++ b/tests/test_hw_symbole.py @@ -0,0 +1,278 @@ +"""Tests fuer lib/hw_symbole.py, cfg/hw_symbole.json und die Kette +Symbole -> Renamer -> create_numbers.""" + +import json +import os +import sys + +import ezdxf +import pytest + +ROOT = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, os.path.join(ROOT, "lib")) + +import create_numbers # noqa: E402 +import hw_symbole as hs # noqa: E402 + +KONFIG = { + "textstile": {"standard": "ARIAL", "schriften": {"ARIAL": "arial.ttf"}}, + "vorlagen": {"klein": {"rahmen": [750.0, 270.0], "attribute": [ + {"tag": "IO", "x": 100.0, "y": 130.0, "hoehe": 125.0, "farbe": "symbol"}, + {"tag": "VERW", "x": 20.0, "y": 20.0, "hoehe": 70.0}, + {"tag": "BEZEICHNUNG", "x": 0.0, "y": -80.0, "hoehe": 60.0, "unsichtbar": True}, + {"tag": "REALE_POSITION", "x": 715.0, "y": 110.0, "hoehe": 50.0, + "ausrichtung": "MC", "wert": "x"}]}}, + "symbole": { + "SYM_VENTIL": {"vorlage": "klein", "layer": "ILS_Ausgang", "farbe": 3, + "werte": {"IO": "MB0", "VERW": "Sep", "BEZEICHNUNG": "Separator (MB0)"}}, + "SYM_SENSOR": {"vorlage": "klein", "layer": "ILS_Eingang", + "werte": {"IO": "BG0", "VERW": "In Sep"}}}, +} + + +def _plan(praefixe, symbole): + return {"praefixe": praefixe, "symbole": symbole} + + +def _sym(praefix, block, x, y, rolle="r"): + return {"praefix": praefix, "io": f"{praefix}1@@@", "block": block, + "x": x, "y": y, "rolle": rolle, "key": f"{x}", "bauteil": "separator"} + + +def test_attribut_werte_ersetzt_platzhalter(): + vorgaben = {"IO": "MA0", "VERW": "ILS-KR-MA0", "BEZEICHNUNG": "Motor MA0", "ARTINR": "1"} + assert hs.attribut_werte(vorgaben, "MA1@@@") == { + "IO": "MA1@@@", "VERW": "ILS-KR-MA1@@@", "BEZEICHNUNG": "Motor MA1@@@"} + assert hs.attribut_werte({"IO": ""}, "BX1@@@") == {"IO": "BX1@@@"} + + +def test_symbol_vorgaben_vorlage_und_symbol(): + werte = hs.symbol_vorgaben(KONFIG["symbole"]["SYM_SENSOR"], KONFIG["vorlagen"]["klein"]) + assert werte == {"IO": "BG0", "VERW": "In Sep", "BEZEICHNUNG": "", "REALE_POSITION": "x"} + + +def test_block_aufbauen_aus_konfiguration(): + doc = ezdxf.new("R2018") + hs.block_aufbauen(doc, "SYM_VENTIL", KONFIG) + blk = doc.blocks.get("SYM_VENTIL") + rahmen = blk.query("LWPOLYLINE")[0] + assert rahmen.dxf.layer == "ILS_Ausgang" and rahmen.closed + attdefs = {a.dxf.tag: a for a in blk.query("ATTDEF")} + assert list(attdefs) == ["IO", "VERW", "BEZEICHNUNG", "REALE_POSITION"] + assert attdefs["IO"].dxf.text == "MB0" and attdefs["IO"].dxf.color == 3 + assert attdefs["BEZEICHNUNG"].is_invisible + assert attdefs["REALE_POSITION"].dxf.text == "x" + assert "ARIAL" in doc.styles + + # Erneuter Aufbau ersetzt den Inhalt statt ihn zu verdoppeln + hs.block_aufbauen(doc, "SYM_VENTIL", KONFIG) + assert len(doc.blocks.get("SYM_VENTIL").query("ATTDEF")) == 4 + + +def test_symbole_setzen_und_nur_eigene_praefixe_ersetzen(): + doc = ezdxf.new("R2018") + doc.modelspace().add_line((0, 0), (1, 1), dxfattribs={"layer": "HW_Materialfluss"}) + + plan = _plan(["MB", "BG"], [_sym("MB", "SYM_VENTIL", 0, 0), + _sym("BG", "SYM_SENSOR", 0, 350), + _sym("MB", "FEHLT", 9, 9)]) + gesetzt, entfernt, fehlend, ueberlappt = hs.symbole_setzen(doc, plan, KONFIG) + assert gesetzt == {"MB": 1, "BG": 1} and entfernt == 0 and fehlend == ["FEHLT"] + assert ueberlappt == 0 + + ventil = doc.modelspace().query('INSERT[name=="SYM_VENTIL"]')[0] + attr = {a.dxf.tag: a.dxf.text for a in ventil.attribs} + assert ventil.dxf.layer == "ILS_Ausgang" + assert attr["IO"] == "MB1@@@" and attr["BEZEICHNUNG"] == "Separator (MB1@@@)" + assert attr["REALE_POSITION"] == "x" + assert hs.symbol_xdata(ventil)[:2] == ("MB", "r") + + # Nur Ventile neu: Sensor und fremde Geometrie bleiben stehen + gesetzt, entfernt, _, _ = hs.symbole_setzen( + doc, _plan(["MB"], [_sym("MB", "SYM_VENTIL", 100, 0), + _sym("BG", "SYM_SENSOR", 100, 0)]), KONFIG) + msp = doc.modelspace() + assert entfernt == 1 and gesetzt == {"MB": 1} + assert len(msp.query('INSERT[name=="SYM_VENTIL"]')) == 1 + assert len(msp.query('INSERT[name=="SYM_SENSOR"]')) == 1 + assert len(msp.query("LINE")) == 1 + + +def test_echte_konfiguration_deckt_regelwerk_ab(): + """Jedes Symbol aus cfg/io_regeln.json ist in cfg/hw_symbole.json definiert, + jede Vorlage existiert, und jedes Symbol baut sich fehlerfrei auf.""" + with open(os.path.join(ROOT, "cfg", "io_regeln.json"), encoding="utf-8") as fh: + regeln = json.load(fh) + with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh: + konfig = json.load(fh) + bloecke = set() + for r in regeln["rollen"]: + bloecke.add(r["block"]) + bloecke.update(v["block"] for v in r.get("block_nach_name", [])) + assert bloecke - set(konfig["symbole"]) == set() + doc = ezdxf.new("R2018") + for name, sdef in konfig["symbole"].items(): + assert sdef["vorlage"] in konfig["vorlagen"] + assert "IO" in sdef["werte"] + hs.block_aufbauen(doc, name, konfig) + + +def _renamer_pfad(doc, punkte): + """Renamer-Block mit Polylinie (POLYLINE_PATH) fuer BG und MB.""" + blk = doc.blocks.new("RENAMER_TEST") + blk.add_lwpolyline(punkte) + werte = {"NAME1": "BG-1@@@", "LAYER_NAME1": "ILS_Eingang", + "NAME2": "MB-1@@@", "LAYER_NAME2": "ILS_Ausgang", + "KENNZEICHNUNG": "=A01+UC0100", "DIRECTION": "POLYLINE_PATH"} + for i, tag in enumerate(werte): + blk.add_attdef(tag, (0, -100 * i)) + if "ILS_RENAMER" not in doc.layers: + doc.layers.add("ILS_RENAMER") + ref = doc.modelspace().add_blockref("RENAMER_TEST", (0, 0), dxfattribs={"layer": "ILS_RENAMER"}) + ref.add_auto_attribs(werte) + + +def test_kette_symbole_renamer_nummerierung(tmp_path): + symbole = tmp_path / "hw_symbole.json" + symbole.write_text(json.dumps(KONFIG), encoding="utf-8") + hw = str(tmp_path / "Anlage_HW.dxf") + plan_pfad = tmp_path / "plan.json" + # Zwei Separatoren (Ziel auf y=0), der rechte zuerst im Plan - nummeriert + # wird entlang der Polylinie + plan_pfad.write_text(json.dumps(_plan(["MB", "BG"], [ + _sym("MB", "SYM_VENTIL", 5000, 0), + _sym("BG", "SYM_SENSOR", 5000, 0), + _sym("MB", "SYM_VENTIL", 1000, 0), + _sym("BG", "SYM_SENSOR", 1000, 0)])), encoding="utf-8") + assert hs.main([str(plan_pfad), hw, "--symbole", str(symbole)]) == 0 + + doc = ezdxf.readfile(hw) + # create_numbers verlangt fuer POLYLINE_PATH mind. 4 Punkte und kein + # Rechteck (offen mit genau 4 Punkten gilt als Rechteck) - also 5 Punkte + _renamer_pfad(doc, [(-1000, 0), (1000, 0), (3000, 0), (5000, 0), (7000, 0)]) + doc.saveas(hw) + + cfg = tmp_path / "enumerate.cfg" + cfg.write_text("[Layers]\nILS_RENAMER\n[Parameters]\npolyline_max_distance = 500.0\n", + encoding="utf-8") + info = tmp_path / "info.json" + rc = create_numbers.main(["--filename", hw, "--config", str(cfg), "--dryrun", + "--info", str(info), "--errorfile", str(tmp_path / "err.json")]) + assert rc == 0 + umbenannt = json.loads(info.read_text(encoding="utf-8"))["renamed_symbols"] + # Einfuegepunkt = Ziel - Marke (715): links das Paar am Ziel x=1000 + links = {r["new_value"] for r in umbenannt if round(r["position"]["x"]) == 1000 - 715} + rechts = {r["new_value"] for r in umbenannt if round(r["position"]["x"]) == 5000 - 715} + assert {"MB1001", "BG1002"} <= links or {"BG1001", "MB1002"} <= links + assert {"MB1003", "BG1004"} <= rechts or {"BG1003", "MB1004"} <= rechts + assert any(r["new_value"] in ("Separator (MB1001)", "Separator (MB1002)") for r in umbenannt) + + +# Katalog der ILS-Symbole im Nachbar-Repo kabellaengen (Quelle von hw_symbole.json) +KATALOG = os.environ.get("ILS_SYMBOLE_KATALOG", os.path.join( + ROOT, "..", "kabellaengen", "doc", "Symbole", "ILS-Symbole.dxf")) + + +def _signatur(blk): + """Vergleichbare Beschreibung einer Blockdefinition: Rahmen, Schraffur, ATTDEFs.""" + attdefs = {} + for a in blk.query("ATTDEF"): + h, v = a.dxf.get("halign", 0), a.dxf.get("valign", 0) + p = a.dxf.align_point if (h or v) else a.dxf.insert + attdefs[a.dxf.tag] = { + "x": round(p.x, 1), "y": round(p.y, 1), "hoehe": a.dxf.height, + "unsichtbar": a.is_invisible, "layer": a.dxf.layer, "ausrichtung": (h, v), + "stil": a.dxf.get("style", "ARIAL"), "breite": round(a.dxf.get("width", 1.0), 2), + "farbe": a.dxf.get("color", 256) or 256, "text": a.dxf.text} + rahmen = sorted({(round(p[0], 1), round(p[1], 1)) + for e in blk.query("LWPOLYLINE") for p in e.get_points()}) + schraffur = [(h.dxf.pattern_name, h.dxf.pattern_scale, h.dxf.layer, h.dxf.get("color", 256)) + for h in blk.query("HATCH")] + return rahmen, schraffur, attdefs + + +@pytest.mark.skipif(not os.path.isfile(KATALOG), reason="kabellaengen-Katalog nicht gefunden") +def test_symbole_gleichen_dem_katalog(): + """Die aus cfg/hw_symbole.json gebauten Symbole tragen dieselben Blocknamen + wie die Katalogsymbole in kabellaengen - sie muessen darum auch gleich + aussehen (Einfuegepunkt, Rahmen, Lage/Hoehe/Stil aller Attribute, Werte), + sonst gilt beim Mischen beider Fassungen still die falsche.""" + with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh: + konfig = json.load(fh) + katalog = ezdxf.readfile(KATALOG) + doc = ezdxf.new("R2018") + abweichungen = [] + for name in konfig["symbole"]: + hs.block_aufbauen(doc, name, konfig) + soll_r, soll_s, soll_a = _signatur(katalog.blocks.get(name)) + ist_r, ist_s, ist_a = _signatur(doc.blocks.get(name)) + if soll_r != ist_r: + abweichungen.append(f"{name}: Rahmen {soll_r} != {ist_r}") + if soll_s != ist_s: + abweichungen.append(f"{name}: Schraffur {soll_s} != {ist_s}") + for tag in sorted(set(soll_a) | set(ist_a)): + s, i = soll_a.get(tag), ist_a.get(tag) + if s is None or i is None: + abweichungen.append(f"{name}.{tag}: nur im {'Katalog' if i is None else 'eigenen Symbol'}") + continue + for k in s: + gleich = abs(s[k] - i[k]) <= 0.11 if k in ("x", "y") else s[k] == i[k] + if not gleich: + abweichungen.append(f"{name}.{tag}.{k}: Katalog {s[k]!r}, eigen {i[k]!r}") + assert abweichungen == [] + + +# ------------------------------------------------------------------ +# Platzierung +# ------------------------------------------------------------------ + +def _geo(x0, y0, x1, y1, mx, my): + return {"box": (x0, y0, x1, y1), "marke": (mx, my)} + + +OBEN = _geo(0, 0, 750, 270, 750, 135) # wie Katalog-Ausgang +UNTEN = _geo(0, -270, 750, 0, 750, -135) # wie Katalog-Eingang (haengt nach unten) + + +def test_symbol_geometrie_aus_konfiguration(): + with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh: + konfig = json.load(fh) + assert hs.symbol_geometrie(konfig, "ILS_Separator-Ausgang") == { + "box": (0.0, 0.0, 750.0, 270.0), "marke": (750.0, 135.0)} + assert hs.symbol_geometrie(konfig, "ILS_720002003-Separator") == { + "box": (0.0, -270.0, 750.0, 0.0), "marke": (750.0, -135.0)} + + +def test_platzieren_einzeln_marke_auf_ziel(): + (punkte,), ueber = hs.platzieren([((1000.0, 500.0), [OBEN])]) + assert ueber == 0 + ex, ey = punkte[0] + assert (ex + 750, ey + 135) == (1000.0, 500.0) # Marke genau auf dem Ziel + (punkte,), _ = hs.platzieren([((1000.0, 500.0), [UNTEN])]) + assert (punkte[0][0] + 750, punkte[0][1] - 135) == (1000.0, 500.0) + + +def test_platzieren_paar_uebereinander(): + (punkte,), ueber = hs.platzieren([((1000.0, 0.0), [OBEN, UNTEN])]) + assert ueber == 0 + (x1, y1), (x2, y2) = punkte + # Marken beide mit x auf dem Ziel, Kaesten links daneben, luekenlos + # uebereinander und zusammen um das Ziel zentriert + assert x1 + 750 == 1000.0 and x2 + 750 == 1000.0 + assert (y1, y1 + 270) == (0.0, 270.0) # oberer Kasten 0..270 + assert (y2 - 270, y2) == (-270.0, 0.0) # unterer Kasten -270..0 + + +def test_platzieren_weicht_in_y_aus(): + gruppen = [((1000.0, 0.0), [OBEN, UNTEN]), ((1200.0, 100.0), [OBEN])] + (paar, (einzel,)), ueber = hs.platzieren(gruppen, abstand=20.0, schritt=50.0) + assert ueber == 0 + assert einzel[0] + 750 == 1200.0 # x bleibt auf dem Ziel + unten, oben = einzel[1], einzel[1] + 270 + assert oben <= -270 - 20 or unten >= 270 + 20 # frei von Paar-Kasten -270..270 + + +def test_platzieren_meldet_ueberlappung_ohne_platz(): + gruppen = [((0.0, 0.0), [OBEN]), ((0.0, 0.0), [OBEN])] + _punkte, ueber = hs.platzieren(gruppen, schritt=50.0, max_verschiebung=100.0) + assert ueber == 1 diff --git a/tests/test_unit.lsp b/tests/test_unit.lsp index 27a43c0..0edc9d3 100644 --- a/tests/test_unit.lsp +++ b/tests/test_unit.lsp @@ -3663,15 +3663,115 @@ ) ) -;; HW-Planung: Ausgabename der Materialfluss-Skizze -(defun tu-tests-hw () +;; HW-Planung: Ausgabename, JSON-Zahlen, Symbol-Regelwerk (Zuordnung Block -> +;; Bauteil -> Katalogsymbol, IO-Vorlage, Plan + JSON) und das Zuordnen der +;; create_numbers-Ergebnisse +(defun tu-tests-hw ( / _regeln _plan _json _echt _fehlt _eintrag) (princ "\n[HwPlanung]") (if (not (car (atoms-family 1 '("HW-DXF-NAME")))) (tu-record nil "HwPlanung-Modul geladen" "HwPlanung.lsp nicht geladen - siehe tests/test_unit.scr") (progn (tu-eq "hw-dxf-name/dwg" "Mubea_HW.dxf" (hw-dxf-name "Mubea.dwg")) - (tu-eq "hw-dxf-name/punkt" "A.B_HW.dxf" (hw-dxf-name "A.B.dwg"))) + (tu-eq "hw-dxf-name/punkt" "A.B_HW.dxf" (hw-dxf-name "A.B.dwg")) + + ;; Ausgabe-Dialog: Dateiname und Eingabepruefung + (tu-eq "hw-name-ohne-dxf" "Anlage_HW" (hw-name-ohne-dxf " Anlage_HW.DXF ")) + (tu-eq "hw-name-ohne-dxf/ohne" "Plan 2" (hw-name-ohne-dxf "Plan 2")) + (tu-true "hw-name-gueltig-p/ok" (hw-name-gueltig-p "Mubea_HW")) + (tu-nil "hw-name-gueltig-p/leer" (hw-name-gueltig-p " ")) + (tu-nil "hw-name-gueltig-p/pfad" (hw-name-gueltig-p "C:/x/Mubea")) + (tu-nil "hw-name-gueltig-p/stern" (hw-name-gueltig-p "a*b")) + (tu-eq "hw-neu-pfad" "C:/erg/Mubea_HW.dxf" (hw-neu-pfad "C:/erg" "Mubea_HW.dxf")) + (tu-nil "hw-ausgabe-pruefen/aktuell" (hw-ausgabe-pruefen "aktuell" nil "" nil)) + (tu-nil "hw-ausgabe-pruefen/ergaenzen" (hw-ausgabe-pruefen "ergaenzen" T "" nil)) + (tu-eq "hw-ausgabe-pruefen/ergaenzen-fehlt" "hw-aus-datei-fehlt" + (hw-ausgabe-pruefen "ergaenzen" nil "" nil)) + (tu-nil "hw-ausgabe-pruefen/neu" (hw-ausgabe-pruefen "neu" nil "Neu_HW" nil)) + (tu-eq "hw-ausgabe-pruefen/neu-ungueltig" "hw-aus-name-ungueltig" + (hw-ausgabe-pruefen "neu" nil "a?b" nil)) + (tu-eq "hw-ausgabe-pruefen/neu-existiert" "hw-aus-name-existiert" + (hw-ausgabe-pruefen "neu" nil "Mubea_HW" T)) + + (tu-eq "hw-json-zahl/neg" "-12.500" (hw-json-zahl -12.5)) + (tu-eq "hw-json-zahl/klein" "0.050" (hw-json-zahl 0.05)) + (tu-eq "hw-json-zahl/rund" "1001.000" (hw-json-zahl 1000.9999)) + (tu-eq "hw-json-zahl/null" "0.000" (hw-json-zahl -0.0001)) + + ;; Struktur wie ssg-json-parse sie aus cfg/io_regeln.json liefert + (setq _regeln + (list '("io_vorlage" . "1@@@") + (list "rollen" + '(("rolle" . "sep_ventil") ("praefix" . "MB") ("block" . "SYM_MB")) + '(("rolle" . "sep_sensor") ("praefix" . "BG") ("block" . "SYM_BG")) + '(("rolle" . "vario_motor") ("praefix" . "MA") ("block" . "SYM_MA_LI") + ("block_nach_name" (("muster" . "*RECHTS*") ("block" . "SYM_MA_RE"))))) + (list "bauteile" + '(("bauteil" . "separator") ("bloecke" "Separator_SP*" "S-SP") + ("benoetigt" ("sep_ventil") ("sep_sensor"))) + '(("bauteil" . "vario") ("bloecke" "Vario_Motorstation*") + ("benoetigt" ("vario_motor" "andere_alternative")))))) + (tu-eq "hw-sym-io-vorlage" "MB1@@@" (hw-sym-io-vorlage _regeln "MB")) + (tu-eq "hw-sym-io-vorlage/vorgabe" "BG1@@@" (hw-sym-io-vorlage nil "BG")) + (tu-eq "hw-sym-bauteil-fuer-block/sep" "separator" + (ssg-json-get (hw-sym-bauteil-fuer-block _regeln "separator_sp_3D") "bauteil")) + (tu-nil "hw-sym-bauteil-fuer-block/fremd" (hw-sym-bauteil-fuer-block _regeln "AN8_3D")) + (tu-eq "hw-sym-muster" "SEPARATOR_SP*,S-SP,VARIO_MOTORSTATION*" (hw-sym-muster _regeln)) + (tu-eq "hw-sym-block-fuer/rechts" "SYM_MA_RE" + (hw-sym-block-fuer (hw-sym-rolle _regeln "vario_motor") "Vario_Motorstation_500mm_rechts_3D")) + (tu-eq "hw-sym-block-fuer/links" "SYM_MA_LI" + (hw-sym-block-fuer (hw-sym-rolle _regeln "vario_motor") "Vario_Motorstation_500mm_links_3D")) + (tu-eq "hw-sym-ziel/flach" '(1000.0 2000.0 0.0) (hw-sym-ziel '(1000.0 2000.0 5.0))) + + ;; Plan: zwei Separatoren (Sortierung nach x) + eine Motorstation + (setq _plan + (hw-sym-plan _regeln + (hw-sym-instanzen-sortieren _regeln + (list (list (hw-sym-bauteil-fuer-block _regeln "Vario_Motorstation_rechts") + "Vario_Motorstation_rechts" '(0.0 0.0 0.0) "V1") + (list (hw-sym-bauteil-fuer-block _regeln "Separator_SP_3D") + "Separator_SP_3D" '(500.0 0.0 0.0) "S2") + (list (hw-sym-bauteil-fuer-block _regeln "Separator_SP_3D") + "Separator_SP_3D" '(100.0 0.0 2000.0) "S1"))))) + (tu-eq "hw-sym-plan/keys" '("S1" "S1" "S2" "S2" "V1") (mapcar '(lambda (r) (nth 5 r)) _plan)) + ;; Zielpunkt = Bauteilpunkt (flach), beide Symbole eines Bauteils gleich - + ;; die Lage daneben/uebereinander rechnet hw_symbole.py + (tu-eq "hw-sym-plan/erster" '("MB" "MB1@@@" "SYM_MB" (100.0 0.0 0.0) "sep_ventil" "S1" "separator") + (car _plan)) + (tu-eq "hw-sym-plan/sensor-gleiches-ziel" '(100.0 0.0 0.0) (nth 3 (cadr _plan))) + (tu-eq "hw-sym-plan/stapelfolge" '("sep_ventil" "sep_sensor") + (list (nth 4 (car _plan)) (nth 4 (cadr _plan)))) + (tu-eq "hw-sym-plan/motor-rechts" "SYM_MA_RE" (nth 2 (last _plan))) + + ;; JSON fuer hw_symbole.py: wieder einlesbar, Komma-Setzung korrekt + (setq _json (ssg-json-parse (ssg-join (hw-sym-plan->json '("MB" "MA") _plan) "\n"))) + (tu-eq "hw-sym-plan->json/praefixe" '("MB" "MA") (ssg-json-get _json "praefixe")) + (tu-eq "hw-sym-plan->json/anzahl" 5 (length (ssg-json-get _json "symbole"))) + (tu-eq "hw-sym-plan->json/x" 100.0 (ssg-json-get (car (ssg-json-get _json "symbole")) "x")) + (tu-eq "hw-sym-plan->json/leer" nil + (ssg-json-get (ssg-json-parse (ssg-join (hw-sym-plan->json '("BX") nil) "\n")) "symbole")) + + ;; Ergebnis von create_numbers zuordnen (Wert + Einfuegepunkt) + (setq _eintrag '(("old_value" . "MB1@@@") ("new_value" . "MB1001") + ("position" ("x" . 400.0) ("y" . 0)))) + (tu-true "hw-num-eintrag-p/passt" (hw-num-eintrag-p _eintrag "MB1@@@" '(400.004 0.0 0.0))) + (tu-nil "hw-num-eintrag-p/wert" (hw-num-eintrag-p _eintrag "BG1@@@" '(400.0 0.0 0.0))) + (tu-nil "hw-num-eintrag-p/lage" (hw-num-eintrag-p _eintrag "MB1@@@" '(400.0 350.0 0.0))) + + ;; Echtes Regelwerk: jede benoetigte Rolle hat eine Rollen-Definition + (setq _echt (if (getenv "DXFM_CFG") + (ssg-json-laden (strcat (getenv "DXFM_CFG") "/io_regeln.json")))) + (if _echt + (progn + (foreach b (ssg-json-get _echt "bauteile") + (foreach alt (ssg-json-get b "benoetigt") + (foreach r alt (if (null (hw-sym-rolle _echt r)) (setq _fehlt (cons r _fehlt)))))) + (tu-nil "hw-sym-regelwerk/rollen-vollstaendig" _fehlt) + (tu-eq "hw-sym-regelwerk/io-vorlage" "MB1@@@" (hw-sym-io-vorlage _echt "MB")) + (tu-eq "hw-sym-regelwerk/as-element" "ausschleuser" + (ssg-json-get (hw-sym-bauteil-fuer-block _echt "AS_Element_90_links_3D") "bauteil"))) + (tu-record nil "hw-sym-regelwerk" "cfg/io_regeln.json nicht gefunden (DXFM_CFG?)")) + ) ) )