diff --git a/CLAUDE.md b/CLAUDE.md index 2725233..fc945ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Menue einmalig in BricsCAD geladen wurde (siehe unten: "Menue einrichten"). Alternativ per LISP-Kommandozeile in BricsCAD: ```lisp -(command "_.MENULOAD" "C:/10-Develop/gitrepos/dxfmakros/menu/SSG_LIB.mnu") +(command "_.MENULOAD" (strcat (getenv "DXFMAKRO") "/menu/SSG_LIB.mnu")) ``` Das Menue bleibt dauerhaft registriert. Nach einem Update der `.mnu`-Datei @@ -157,6 +157,7 @@ Abhaengigkeiten: `ezdxf`, Standard-Library. Benoetigen Umgebungsvariablen (`DXFM | `export_sivas.py` | Erzeugt Sivas-Export-CSV mit Summierungszeilen (ILS Automation, Omniflo Sum). Aufruf: `python export_sivas.py ` | | `export_csv.py` | Erzeugt einfache Item-Liste als CSV (ohne Summierung). Aufruf: `python export_csv.py ` | | `split_block_library.py` | Teilt `ils_library.dxf` in einzelne DXF-Dateien pro Block auf (mit Abhaengigkeiten). Export-Prefixe: Vario_, Staustrecke_, _3D_AS_, _3D_ES_ | +| `dxf_scan_components.py` | Erkennt ILS-/Omniflo-Komponenten in einer **fremden** Projektzeichnung (ohne unsere Wrapper-Bloecke) und schreibt sie im JSON-Schema aus `tests/testdata/object_data.md` — Grundlage fuer Nachbau-Testfaelle wie `tests/testdata/hundm05.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. 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/README.md b/Lisp/README.md index c7eed6a..49ab91b 100644 --- a/Lisp/README.md +++ b/Lisp/README.md @@ -20,7 +20,7 @@ alle Feature-Module). Details: siehe `../CLAUDE.md` Abschnitt "Lademechanismus". **Manuell laden (Notfall / Entwicklung):** ```lisp -(load "C:/10-Develop/gitrepos/dxfmakros/Lisp/ssg_load.lsp") +(load (strcat (getenv "DXFMAKRO") "/Lisp/ssg_load.lsp")) ``` `ssg_load.lsp` laedt alle Module in der richtigen Reihenfolge ohne Menueabhaengigkeit. diff --git a/doc/Heights-HundM05-02.png b/doc/Heights-HundM05-02.png new file mode 100644 index 0000000..640e1c4 Binary files /dev/null and b/doc/Heights-HundM05-02.png differ diff --git a/doc/Heights-HundM05.png b/doc/Heights-HundM05.png new file mode 100644 index 0000000..8c79cb8 Binary files /dev/null and b/doc/Heights-HundM05.png differ diff --git a/lib/dxf_abbild.py b/lib/dxf_abbild.py new file mode 100644 index 0000000..20cfb83 --- /dev/null +++ b/lib/dxf_abbild.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +dxf_abbild.py - Getreue Abbildung der Bauteile einer Fremdzeichnung als JSON. + +Abgrenzung zu lib/dxf_scan_components.py: dieses Skript beschreibt, WAS in der +Zeichnung steht - nicht, wie unsere Makros es nachbauen sollen. Es interpretiert +so wenig wie moeglich und schreibt alles mit, was im DXF wirklich vorhanden ist: +Blockname, Layer, Weltposition, Rotation, Skalierung, Extrusionsrichtung, +saemtliche Blockattribute und die verschachtelten Unterkomponenten. Wo eine +Angabe nicht im DXF steht, steht sie auch nicht in der Ausgabe - es wird nichts +geraten und nichts mit Defaults aufgefuellt. + +Der Typ eines Bauteils kommt bevorzugt aus den Attributen (BESCHR, BEZEICHNUNG, +VERW), nicht aus dem Blocknamen: in ST500592_05.dxf tragen 15730 INSERTs +Attribute mit 80 verschiedenen Tags, darunter Klartext wie +BESCHR="TEF-Profil-Bogen innen 67,5 Grad R400". Das ist belastbarer als jede +Blocknamen-Heuristik. + +Was das DXF NICHT enthaelt und was hier darum fehlt: + - Flussrichtung/Pfeile: kein Blockname enthaelt Pfeil/arrow/flow/Richtung, + die Pfeile sind gezeichnete Geometrie (SOLID/Polylinie). Nicht ableitbar. + - Explizite Verbindungsgraphen: die Zeichnung hat 0 GROUP-Objekte. Die + vorhandenen XDATA sind Fremd-Metadaten (TeklaStructures, CSTABVIEWER). + Was an Beziehung drinsteckt, steckt in den Kennzeichnungs-Strings und wird + unter "beziehungen" als GELESENER Text abgebildet, nicht als Topologie. + - Zeichnungsgeometrie (LINE/ARC/LWPOLYLINE): bewusst nicht erfasst. + +Aufruf: + python dxf_abbild.py [--fenster X0 Y0 X1 Y1] + [--max-tiefe N] [--ohne-geometrie-container] + +Beispiel: + python lib/dxf_abbild.py results/ST500592_05.dxf results/hundm05_abbild.json +""" + +import argparse +import collections +import json +import math +import os +import re +import sys + +from ezdxf.math import Matrix44 + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from dxf_scan_components import ( # noqa: E402 + RUND, familie_von, insert_matrix, lese_inserts, im_fenster) + +PREFIX = "[dxf_abbild]" + +# Attribut-Tags, die ein Bauteil identifizieren. Reihenfolge = Aussagekraft. +ID_TAGS = ["ID", "ARTINR", "OriginalName", "KENNZEICHNUNG", "POSITION", + "DETAILS", "IO"] + +# Attribut-Tags, aus denen sich der Bauteiltyp im Klartext ergibt. +TYP_TAGS = ["BESCHR", "BEZEICHNUNG", "VERW"] + +# Attribut-Tags, die eine Zugehoerigkeit ausdruecken (Gruppe, Steuerung, +# Anlagenkennzeichen). Werden als gelesener Text uebernommen. +BEZUG_TAGS = ["KENNZEICHNUNG", "GRUPPE", "SPS", "AUFLOESE", "ETIKETTE"] + +# Blocknamen, die reine Geometrie-Container sind (Volumenkoerper-Huellen aus +# dem CAD-Import). Sie tragen keine Bauteilinformation, blaehen die Ausgabe aber +# stark auf - mit --ohne-geometrie-container werden sie nur gezaehlt. +GEOMETRIE_CONTAINER = re.compile(r"^(sgBrepBlock__|\*[A-Z])") + +# EPLAN-Kennzeichnung: =Anlage +Ort -Betriebsmittel +KENNZ_RE = re.compile(r"(?:=([^+\-=]+))?(?:\+([^+\-=]+))?(?:-(.+))?$") + +# Verweis in Klartext-Bezeichnungen, z.B. "in Separator (ILS-CV M2111)" +VERWEIS_RE = re.compile(r"\(([^)]+)\)") + + +def identifikatoren(attribs): + """Alle vorhandenen Identifikatoren eines Bauteils - nur echte Werte.""" + ids = {} + for tag in ID_TAGS: + wert = attribs.get(tag) + if wert not in (None, ""): + ids[tag] = wert + return ids + + +def typ_von(attribs, blockname): + """Bauteiltyp ermitteln. + + Rueckgabe: (typ, quelle). "quelle" nennt das Attribut, aus dem der Typ + stammt, bzw. "blockname" als Rueckfallebene - damit im JSON sichtbar + bleibt, wie belastbar die Angabe ist. + """ + for tag in TYP_TAGS: + wert = attribs.get(tag) + if wert not in (None, ""): + return wert, tag + return blockname, "blockname" + + +def kennzeichnung_teile(wert): + """=Anlage +Ort -Betriebsmittel aufschluesseln, soweit vorhanden.""" + m = KENNZ_RE.match(wert.strip()) + if not m: + return {} + teile = {} + for name, gruppe in (("anlage", 1), ("ort", 2), ("betriebsmittel", 3)): + if m.group(gruppe): + teile[name] = m.group(gruppe).strip() + return teile + + +def beziehungen(attribs): + """Zugehoerigkeiten, die als Text in den Attributen stehen. + + Bewusst KEINE Topologie: die Zeichnung enthaelt keinen Verbindungsgraphen + (0 GROUP-Objekte, XDATA nur von Fremd-Anwendungen). Hier steht nur, was + wirklich geschrieben ist - die Auswertung bleibt dem Leser. + """ + bez = {} + for tag in BEZUG_TAGS: + wert = attribs.get(tag) + if wert not in (None, ""): + bez[tag] = wert + kennz = attribs.get("KENNZEICHNUNG") + if kennz: + teile = kennzeichnung_teile(kennz) + if teile: + bez["kennzeichnung_teile"] = teile + # Verweis auf ein benanntes Foerdermittel, z.B. "(ILS-CV M2111)" + for tag in ("BEZEICHNUNG", "BESCHR"): + wert = attribs.get(tag) + if wert: + m = VERWEIS_RE.search(wert) + if m: + bez["verweis"] = m.group(1).strip() + break + return bez + + +def welt_daten(rec, mutter, block_base): + """Weltposition, Rotation und Gesamtmatrix eines INSERT.""" + mat = insert_matrix(rec, block_base) @ mutter + ursprung = mat.transform((0.0, 0.0, 0.0)) + xachse = mat.transform((1.0, 0.0, 0.0)) - ursprung + rot = math.degrees(math.atan2(xachse.y, xachse.x)) % 360.0 + return mat, ursprung, rot + + +def element_dict(rec, ursprung, rot, tiefe): + """Einen INSERT als Abbild-Eintrag beschreiben.""" + attribs = dict(rec["attribs"]) + typ, typ_quelle = typ_von(attribs, rec["name"]) + eintrag = collections.OrderedDict() + eintrag["block"] = rec["name"] + eintrag["layer"] = rec["layer"] or "" + eintrag["typ"] = typ + eintrag["typ_quelle"] = typ_quelle + eintrag["position"] = [round(ursprung.x, RUND) + 0.0, + round(ursprung.y, RUND) + 0.0, + round(ursprung.z, RUND) + 0.0] + eintrag["rotation"] = round(rot, RUND) + 0.0 + if [round(s, 6) for s in rec["s"]] != [1.0, 1.0, 1.0]: + eintrag["skalierung"] = [round(s, 6) for s in rec["s"]] + if [round(v, 6) for v in rec["ext"]] != [0.0, 0.0, 1.0]: + # Nur wenn abweichend: die Extrusionsrichtung erklaert, warum ein + # Bauteil gekippt/gespiegelt im Raum liegt (13,7 % der INSERTs). + eintrag["extrusion"] = [round(v, 6) for v in rec["ext"]] + ids = identifikatoren(attribs) + if ids: + eintrag["ids"] = ids + bez = beziehungen(attribs) + if bez: + eintrag["beziehungen"] = bez + if attribs: + eintrag["attribute"] = attribs + fam = familie_von(rec["name"]) + if fam: + # Unsere eigene Klassifikation - klar als solche gekennzeichnet, damit + # sie nicht mit den Angaben aus der Zeichnung verwechselt wird. + eintrag["ssg_familie"] = fam + eintrag["tiefe"] = tiefe + return eintrag + + +def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None, + max_tiefe=12, ohne_container=False): + """Alle Modelspace-INSERTs als Abbild-Baum beschreiben. + + Rueckgabe: (elemente, statistik). Jedes Element der obersten Ebene enthaelt + seine aufgeloesten Unterkomponenten unter "unterkomponenten". + """ + stat = collections.Counter() + + def kinder_von(name, mat, tiefe, pfad): + """Verschachtelte INSERTs eines Blocks rekursiv beschreiben.""" + if tiefe > max_tiefe: + return [] + ergebnis = [] + for rec in block_inserts.get(name, []): + if not rec["name"]: + continue + if ohne_container and GEOMETRIE_CONTAINER.match(rec["name"]): + stat["container_uebersprungen"] += 1 + continue + kind_mat, ursprung, rot = welt_daten(rec, mat, block_base) + eintrag = element_dict(rec, ursprung, rot, tiefe) + stat["unterkomponenten"] += 1 + if rec["name"] not in pfad: + enkel = kinder_von(rec["name"], kind_mat, tiefe + 1, + pfad | {rec["name"]}) + if enkel: + eintrag["unterkomponenten"] = enkel + ergebnis.append(eintrag) + return ergebnis + + elemente = [] + for nr, rec in enumerate(ms_inserts, start=1): + if not rec["name"]: + continue + mat, ursprung, rot = welt_daten(rec, Matrix44(), block_base) + if not im_fenster(ursprung.x, ursprung.y, fenster): + stat["ausserhalb"] += 1 + continue + if ohne_container and GEOMETRIE_CONTAINER.match(rec["name"]): + stat["container_uebersprungen"] += 1 + continue + eintrag = element_dict(rec, ursprung, rot, 0) + eintrag["nr"] = nr + eintrag.move_to_end("nr", last=False) + kinder = kinder_von(rec["name"], mat, 1, frozenset({rec["name"]})) + if kinder: + eintrag["unterkomponenten"] = kinder + elemente.append(eintrag) + stat["elemente"] += 1 + if rec["attribs"]: + stat["mit_attributen"] += 1 + return elemente, stat + + +def main(): + parser = argparse.ArgumentParser( + description="Schreibt die Bauteile einer Fremdzeichnung als getreue " + "JSON-Abbildung (keine Bau-Anweisung).") + parser.add_argument("dxf", help="Quellzeichnung (binaeres DXF)") + parser.add_argument("output", help="Ziel-JSON") + parser.add_argument("--fenster", nargs=4, type=float, + metavar=("X0", "Y0", "X1", "Y1"), + help="Nur Elemente in diesem XY-Fenster (mm)") + parser.add_argument("--max-tiefe", type=int, default=12, + help="Maximale Verschachtelungstiefe (Default 12)") + parser.add_argument("--ohne-geometrie-container", action="store_true", + help="Volumenkoerper-Huellen (sgBrepBlock_*, *A/*B/...) " + "nur zaehlen, nicht ausgeben") + args = parser.parse_args() + + if not os.path.isfile(args.dxf): + print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, args.dxf)) + sys.exit(1) + + fenster = None + if args.fenster: + x0, y0, x1, y1 = args.fenster + fenster = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + + groesse = os.path.getsize(args.dxf) / (1024.0 * 1024.0) + print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, groesse)) + ms_inserts, block_inserts, block_base, statistik = lese_inserts(args.dxf) + print("%s %d INSERTs im Modelspace, %d INSERTs gesamt, %d mit gekippter OCS" + % (PREFIX, len(ms_inserts), statistik["inserts"], + statistik["ocs_gekippt"])) + + elemente, stat = baue_abbild(ms_inserts, block_inserts, block_base, + fenster=fenster, max_tiefe=args.max_tiefe, + ohne_container=args.ohne_geometrie_container) + + # Kennzahlen, die belegen, wie belastbar die Typangaben sind + quellen = collections.Counter() + layer = collections.Counter() + + def zaehle(liste): + for e in liste: + quellen[e["typ_quelle"]] += 1 + layer[e["layer"]] += 1 + zaehle(e.get("unterkomponenten", [])) + + zaehle(elemente) + + abbild = collections.OrderedDict() + abbild["quelle"] = collections.OrderedDict([ + ("datei", os.path.basename(args.dxf)), + ("groesse_mb", round(groesse, 1)), + ("inserts_gesamt", statistik["inserts"]), + ("inserts_modelspace", len(ms_inserts)), + ("inserts_gekippte_ocs", statistik["ocs_gekippt"]), + ("fenster", list(fenster) if fenster else None), + ("hinweis", "Getreue Abbildung der Bauteile. Enthaelt KEINE " + "Flussrichtung (im DXF nicht vorhanden), keinen " + "Verbindungsgraphen (0 GROUP-Objekte) und keine " + "Zeichnungsgeometrie (bewusst ausgelassen). Felder mit " + "typ_quelle=blockname sind NICHT durch ein Attribut " + "belegt."), + ]) + abbild["kennzahlen"] = collections.OrderedDict([ + ("elemente_oberste_ebene", stat["elemente"]), + ("unterkomponenten", stat["unterkomponenten"]), + ("mit_attributen_oberste_ebene", stat["mit_attributen"]), + ("typ_quellen", dict(quellen.most_common())), + ("layer_top15", dict(layer.most_common(15))), + ]) + abbild["elemente"] = elemente + + ziel_dir = os.path.dirname(os.path.abspath(args.output)) + if ziel_dir and not os.path.isdir(ziel_dir): + os.makedirs(ziel_dir) + with open(args.output, "w", encoding="utf-8") as fh: + json.dump(abbild, fh, ensure_ascii=False, indent=2) + fh.write("\n") + + mb = os.path.getsize(args.output) / (1024.0 * 1024.0) + print("%s %d Elemente (oberste Ebene) + %d Unterkomponenten -> %s (%.1f MB)" + % (PREFIX, stat["elemente"], stat["unterkomponenten"], args.output, + mb)) + if stat["ausserhalb"]: + print("%s %d Elemente ausserhalb des Fensters ausgelassen" + % (PREFIX, stat["ausserhalb"])) + if stat["container_uebersprungen"]: + print("%s %d Geometrie-Container uebersprungen" + % (PREFIX, stat["container_uebersprungen"])) + print("%s Typ-Quellen: %s" % (PREFIX, dict(quellen.most_common()))) + + +if __name__ == "__main__": + main() diff --git a/lib/dxf_scan_components.py b/lib/dxf_scan_components.py new file mode 100644 index 0000000..85f3db6 --- /dev/null +++ b/lib/dxf_scan_components.py @@ -0,0 +1,1110 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +dxf_scan_components.py - Erkennt ILS-/Omniflo-Komponenten in einem FREMDEN DXF. + +Gedacht fuer Projekt-Zeichnungen, die NICHT von unseren Makros erzeugt wurden +(also ohne die Wrapper-Bloecke KREISEL_*, VF_*, GF_*). Das Skript sucht die +Bauteile ueber Blocknamen-Muster, rechnet ihre Position in Weltkoordinaten um +und schreibt das Ergebnis im JSON-Schema aus tests/testdata/object_data.md - +also so, dass die Anlage per JSON-Treiber mit den Produktionsmakros +nachgebaut werden kann (Vorbild: tests/testdata/mubea.json, das bisher von +Hand aus einem DXF abgeleitet wurde). + +Aufruf: + python dxf_scan_components.py + [--fenster X0 Y0 X1 Y1] + +Beispiel (ganze Zeichnung): + python lib/dxf_scan_components.py results/ST500592_05.dxf data \ + tests/testdata/hundm05.json + +Beispiel (nur ein Anlagenabschnitt - hier der Kreisel-Bereich): + python lib/dxf_scan_components.py results/ST500592_05.dxf data \ + tests/testdata/hundm05.json --fenster 216000 144000 228000 156000 + +Ohne --fenster liefert eine Zeichnung dieser Groesse ueber 1300 Eintraege - +zuviel fuer einen Testfall, der in BricsCAD noch diagnostizierbar bleibt. + +Besonderheit: die Zieldateien sind binaere DXF im GB-Bereich. ezdxf.readfile() +wuerde die komplette Zeichnung als DOM in den Speicher legen (bei 1 GB Datei +weit mehr RAM als verfuegbar), darum liest dieses Skript die Tags per mmap +streamend selbst - konstanter Speicherbedarf, ein Durchlauf ca. 1 Minute. +Eine geoeffnete Zeichnung (BricsCAD-Dateisperre) stoert den Lesezugriff nicht. +""" + +import argparse +import io +import json +import math +import mmap +import os +import re +import struct +import sys + +from ezdxf.lldxf.types import BINARY_DATA, INT16, INT32, INT64, DOUBLE, BYTES +from ezdxf.math import Matrix44, OCS + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from export_blockpatterns import matches_any # noqa: E402 + +PREFIX = "[dxf_scan]" + +# Maximaler Abstand, bis zu dem Antriebs- und Umlenkstation noch als ein und +# derselbe Foerderer gewertet werden (mm). +MAX_PAAR_ABSTAND = 60000.0 + +# Bis zu dieser Hoehendifferenz (mm) gilt ein Antrieb/Umlenk-Paar als +# waagrechte Foerderstrecke ("gerade") und NICHT als VarioFoerderer. Der +# Vario-Solver braucht eine Hoehendifferenz, um einen Bogenwinkel aus der +# Kandidatenliste (3..51 Grad) zu waehlen; bei ~0 mm ist der Eintrag unbaubar. +# 10 mm deckt Rundungs- und Zeichenungenauigkeiten der Fremdzeichnung ab. +GERADE_MAX_DELTA_H = 10.0 + +# Toleranz fuer die Dedup-Rundung (Nachkommastellen). +RUND = 2 + +# Fenster, in dem die Einfuegehoehe eines Anlagenbauteils liegen muss (mm). +# Z-Werte ausserhalb sind praktisch immer ein Zeichen fuer eine falsch +# aufgeloeste Transformation (z.B. eine nicht beruecksichtigte OCS) und nicht +# fuer echte Geometrie - eine Foerderanlage hat keine Bauteile 15 m unter dem +# Hallenboden. Wird nur fuer die Plausibilitaetsmeldung genutzt, nicht zum +# Filtern: aussortiert wird nichts, aber es wird auch nichts still geschrieben. +Z_PLAUSIBEL = (-1000.0, 20000.0) + +# Groesste plausible XY-Ausdehnung einer Anlage (mm). +XY_MAX_AUSDEHNUNG = 500000.0 + + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + +def load_json(path): + """JSON laden; UTF-8 mit cp1252-Fallback (wie lib/export_csv.py).""" + try: + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + except UnicodeDecodeError: + with open(path, "r", encoding="cp1252") as fh: + return json.load(fh) + + +def decode_str(raw): + """DXF-String dekodieren - die Zeichnungen mischen UTF-8 und cp1252.""" + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("cp1252", errors="replace") + + +def iter_binary_tags(mm): + """Streamt (code, value)-Tags aus einem binaeren DXF (AC1021+). + + Nachbau von ezdxf.lldxf.tagger.binary_tags_loader, aber ueber ein + mmap-Objekt statt eines bytes-Objekts: der Original-Loader braucht + data.index(), das mmap nicht kennt, waehrend ein Proxy-Objekt wiederum am + Buffer-Protokoll von struct.unpack_from scheitert. Die Typtabellen + (BINARY_DATA/INT16/...) kommen weiterhin aus ezdxf. + """ + if mm[:22] != b"AutoCAD Binary DXF\r\n\x1a\x00": + raise ValueError("keine binaere DXF-Datei") + i = 22 + n = len(mm) + unpack = struct.unpack_from + while i < n: + code = mm[i] | (mm[i + 1] << 8) # AC1021+: immer 2-Byte-Gruppencode + i += 2 + if code in BINARY_DATA: + length = mm[i] + i += 1 + value = mm[i:i + length] + i += length + elif code in INT16: + value = unpack(" Bauteilfamilie) +# --------------------------------------------------------------------------- +# Reihenfolge zaehlt: der erste Treffer gewinnt. Muster ohne Umlaute, dafuer +# mit Wildcards - die Blocknamen der Fremdzeichnungen enthalten Umlaute und +# Sonderzeichen (z.B. "3D_Schraegfoerderer_Antrieb", "TEF_Bogen_90Grad_..."). +FAMILIEN = [ + ("kreisel_an", ["AN8", "AN8_*"]), + ("kreisel_sp", ["SP8", "SP8_*"]), + ("foerderer_antrieb", ["3D_TEF_Antrieb*", "3D_FF_Antrieb*", + "3D_Schr*rderer_Antrieb*", "Antrieb FF"]), + ("foerderer_umlenk", ["3D_TEF_Umlenk*", "3D_FF_Umlenk*", + "3D_Schr*rderer_Umlenk*", "Umlenkung FF"]), + ("omniflo_bogen", ["APB *", "APB_*", "APBW *", "APBW_*"]), + # ILS-Modulweiche VOR omniflo_weiche pruefen: "Weiche 2 Wege" ist eine + # ILS-Weiche (unsere Bloecke Modulweiche_RG_*/Modulweiche_RR_*) und wuerde + # sonst vom allgemeinen Muster "Weiche *" als Omniflo-Weiche eingesammelt. + # Als Omniflo-Eintrag ist sie zwangsweise ohne Sivasnr, weil der Blockname + # keine Radius-/Winkel-/Masskennzahlen enthaelt, aus denen sich ein + # Katalogtreffer ableiten liesse. + ("ils_weiche", ["Weiche 2 Wege", "Weiche 2 Wege*"]), + ("omniflo_weiche", ["Weiche *", "Weichen-TEF*"]), + ("bogen", ["3D_ILS_Bogen_*", "3D_FF_Bogen_*", "TEF_Bogen_*", + "tef30*", "tef90*"]), + ("schleuselement", ["Ausschleuselement*", "Einschleuselement*", + "EE_Neu*", "AE_Neu*"]), + ("sensor", ["S-LP", "S-SP", "SCAN", "SCAN_*", "Scanner*", + "Separator*", "Stopper*", "Stopperschleuse*"]), +] + +# Foerderer-System je Blockname - es wird nur innerhalb desselben Systems +# gepaart (ein TEF-Antrieb gehoert nie zu einer Fingerfoerderer-Umlenkung). +SYSTEM_MUSTER = [ + ("TEF", ["3D_TEF_*"]), + ("FF", ["3D_FF_*", "Antrieb FF", "Umlenkung FF"]), + ("SCHRAEG", ["3D_Schr*"]), +] + +# Blocknamen der Fremdzeichnung -> Blocknamen, die ils-insert-sensor kennt +# (ssg-ils-block-datei loest diese mit 2D/3D-Suffix auf). Ohne Eintrag bleibt +# der Originalname stehen - der Nachbau muss dafuer dann einen Block liefern. +SENSOR_BLOCK_MAP = { + "SCAN": "Scanner", + "Separator": "Separator_SP", + "Separator 200000070": "Separator_SP", + "Separator LP 3D": "S-LP", +} + +# Varianten-Defaults fuer Angaben, die im Fremd-Blocknamen fehlen. Unsere +# Bibliothek unterscheidet Seite (links/rechts), Winkel und bei Kurven die +# Lage (aussen/innen); die Fremdzeichnung benennt das nur teilweise +# ("3D_FF_Antrieb_rechts" ja, "Antrieb FF" nein). Ohne eine Festlegung liesse +# sich kein Block aufloesen, darum diese Defaults - jede so gesetzte Angabe +# wird im comment-Feld des Eintrags als geraten ausgewiesen, damit sie im +# JSON gezielt nachgezogen werden kann. +DEFAULT_SEITE = "rechts" +DEFAULT_BOGEN_LAGE = "aussen" +DEFAULT_KURVEN_WINKEL = 30 +DEFAULT_SCHLEUS_WINKEL = 90 + + +def winkel_aus_name(bname, default): + """Ersten Winkel (Grad) aus einem Blocknamen lesen. + + Rueckgabe: (winkel, geraten) - geraten=True, wenn der Name keinen Winkel + enthaelt und der Default eingesetzt wurde. + """ + m = re.search(r"(\d{1,3})\s*(?:Grad|°)", bname) + if not m: + m = re.search(r"_(\d{1,3})(?:_|$)", bname) + if m: + return int(m.group(1)), False + return default, True + + +def mappe_block(bname): + """Fremd-Blockname auf einen Block aus data/ils/ abbilden. + + Rueckgabe: (zielblock, geraten). "geraten" listet die Angaben, die im + Fremdnamen NICHT stehen und darum aus den DEFAULT_*-Konstanten kommen; + eine leere Liste heisst: vollstaendig aus dem Namen abgeleitet. Ist der + Name gar nicht zuzuordnen, kommt er unveraendert zurueck (geraten=None) - + dann entscheidet der LISP-Treiber (Fallback auf Separator_SP bzw. + Platzhalter). + """ + if bname in SENSOR_BLOCK_MAP: + return SENSOR_BLOCK_MAP[bname], [] + low = bname.lower() + geraten = [] + + def seite_aus(): + if "rechts" in low: + return "rechts" + if "links" in low: + return "links" + geraten.append("Seite=%s" % DEFAULT_SEITE) + return DEFAULT_SEITE + + # Antriebs-/Umlenkstation -> Vario_Motorstation / Vario_Umlenkstation + if "antrieb" in low: + return "Vario_Motorstation_500mm_%s" % seite_aus(), geraten + if "umlenk" in low: + return "Vario_Umlenkstation_500mm_%s" % seite_aus(), geraten + + # Kurven/Boegen -> Vario_Kurve___TEF_ + if "bogen" in low or low.startswith("tef"): + winkel, geschaetzt = winkel_aus_name(bname, DEFAULT_KURVEN_WINKEL) + if geschaetzt: + geraten.append("Winkel=%d" % winkel) + if "außen" in low or "aussen" in low: + lage = "aussen" + elif "innen" in low: + lage = "innen" + else: + lage = DEFAULT_BOGEN_LAGE + geraten.append("Lage=%s" % lage) + return ("Vario_Kurve_%s_%d_TEF_%s" % (seite_aus(), winkel, lage), + geraten) + + # Aus-/Einschleuselement -> AS_Element / ES_Element + if "ausschleus" in low or low.startswith("ae_"): + winkel, geschaetzt = winkel_aus_name(bname, DEFAULT_SCHLEUS_WINKEL) + if geschaetzt: + geraten.append("Winkel=%d" % winkel) + return "AS_Element_%d_%s" % (winkel, seite_aus()), geraten + if "einschleus" in low or low.startswith("ee_"): + winkel, geschaetzt = winkel_aus_name(bname, DEFAULT_SCHLEUS_WINKEL) + if geschaetzt: + geraten.append("Winkel=%d" % winkel) + return "ES_Element_%d_%s" % (winkel, seite_aus()), geraten + + return bname, None + + +def lade_bekannte_bloecke(data_dir): + """Blocknamen aus data/ils/ (ohne _2D/_3D-Suffix) als Menge. + + Dient der Kontrolle, ob ein von mappe_block gebildeter Name wirklich + existiert - eine erfundene Variante wuerde im Testlauf sonst still zum + Platzhalter. + """ + ils_dir = os.path.join(data_dir, "ils") + namen = set() + if not os.path.isdir(ils_dir): + return namen + for datei in os.listdir(ils_dir): + stamm, endung = os.path.splitext(datei) + if endung.lower() != ".dwg": + continue + for suffix in ("_2D", "_3D"): + if stamm.endswith(suffix): + stamm = stamm[:-len(suffix)] + break + namen.add(stamm) + return namen + +# Blocknamen, die zwar technisch aussehen, aber bewusst ignoriert werden +# (Ausstattung/Visualisierung, kein Anlagenbauteil). +IGNORIEREN = ["Trolley*", "MT Trolley*", "Minitrolley*", "*Trolley*"] + + +def familie_von(bname): + """Bauteilfamilie zu einem Blocknamen, oder None.""" + if matches_any(bname, IGNORIEREN): + return None + for name, muster in FAMILIEN: + if matches_any(bname, muster): + return name + return None + + +def system_von(bname): + """Foerderer-System (TEF/FF/SCHRAEG) zu einem Blocknamen.""" + for name, muster in SYSTEM_MUSTER: + if matches_any(bname, muster): + return name + return "?" + + +# --------------------------------------------------------------------------- +# DXF einlesen und Bloecke in Weltkoordinaten aufloesen +# --------------------------------------------------------------------------- + +def lese_inserts(dxf_path): + """Liest alle INSERTs (Modelspace und Blockdefinitionen) plus Basispunkte. + + Rueckgabe: (ms_inserts, block_inserts, block_base, statistik) + ms_inserts - Liste der INSERTs der ENTITIES-Sektion + block_inserts- dict blockname -> Liste der darin verschachtelten INSERTs + block_base - dict blockname -> Basispunkt [x, y, z] + statistik - dict mit Zaehlern fuer die Diagnoseausgabe + """ + ms_inserts = [] + block_inserts = {} + block_base = {} + statistik = {"inserts": 0, "ocs_gekippt": 0, "papierbereich": 0} + + section = None + warte_auf_sectionname = False + aktuell = None # aktueller Entity-Typ + aktueller_block = None # Name der gerade gelesenen Blockdefinition + rec = None # INSERT-Datensatz im Aufbau + attrib_tag = None # Code 2 des laufenden ATTRIB + attrib_wert = None # Code 1 des laufenden ATTRIB (kommt VOR dem Tag) + + def neu_insert(im_modelspace, parent): + return {"name": None, "layer": None, "p": [0.0, 0.0, 0.0], + "rot": 0.0, "s": [1.0, 1.0, 1.0], "ps": 0, "attribs": {}, + "ext": [0.0, 0.0, 1.0], "_ms": im_modelspace, + "_parent": parent} + + def ablegen(datensatz): + if datensatz["name"] is None: + return + # Erst hier ist die Extrusionsrichtung komplett gelesen (210/220/230 + # kommen als drei einzelne Tags), darum wird die OCS-Statistik beim + # Ablegen gefuehrt und nicht beim Lesen der einzelnen Codes. + if datensatz["ext"] != [0.0, 0.0, 1.0]: + statistik["ocs_gekippt"] += 1 + if datensatz["_ms"]: + if datensatz["ps"] != 1: # Papierbereich ueberspringen + ms_inserts.append(datensatz) + elif datensatz["_parent"]: + block_inserts.setdefault(datensatz["_parent"], []).append(datensatz) + + fh = io.open(dxf_path, "rb") + try: + mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ) + except ValueError: + fh.close() + raise + try: + for code, value in iter_binary_tags(mm): + if code == 0: + # Ein ATTRIB endet immer am naechsten 0-Tag. Erst hier steht + # sicher fest, dass Tag UND Wert gelesen sind - in einem ATTRIB + # kommt laut DXF-Spezifikation Code 1 (Wert) VOR Code 2 (Tag), + # eine Zuordnung schon beim Lesen von Code 1 waere also immer + # leer (in dieser Zeichnung: 400 von 400 Stichproben in der + # Reihenfolge 1,2 - saemtliche Attribute gingen verloren). + if rec is not None and attrib_tag and attrib_wert: + rec["attribs"][attrib_tag] = attrib_wert + attrib_tag = None + attrib_wert = None + if value in ("ATTRIB", "SEQEND"): + aktuell = value + continue + if rec is not None: + ablegen(rec) + rec = None + if value == "SECTION": + warte_auf_sectionname = True + aktuell = None + elif value == "ENDSEC": + section = None + aktuell = None + aktueller_block = None + elif value == "ENDBLK": + aktueller_block = None + aktuell = value + else: + aktuell = value + if value == "BLOCK": + aktueller_block = None + elif value == "INSERT": + rec = neu_insert(section == "ENTITIES", aktueller_block) + statistik["inserts"] += 1 + elif code == 2: + if warte_auf_sectionname: + section = value + warte_auf_sectionname = False + elif aktuell == "BLOCK": + aktueller_block = value + block_base.setdefault(value, [0.0, 0.0, 0.0]) + elif aktuell == "INSERT" and rec is not None: + rec["name"] = value + elif aktuell == "ATTRIB": + attrib_tag = value + elif aktuell == "BLOCK" and aktueller_block and code in (10, 20, 30): + block_base[aktueller_block][{10: 0, 20: 1, 30: 2}[code]] = value + elif aktuell == "INSERT" and rec is not None: + if code == 8: + rec["layer"] = value + elif code in (10, 20, 30): + rec["p"][{10: 0, 20: 1, 30: 2}[code]] = value + elif code in (41, 42, 43): + rec["s"][{41: 0, 42: 1, 43: 2}[code]] = value + elif code == 50: + rec["rot"] = value + elif code == 67: + rec["ps"] = value + if value == 1: + statistik["papierbereich"] += 1 + elif code in (210, 220, 230): + # Extrusionsrichtung = Normale des OCS, in dem p/rot/s + # angegeben sind (siehe insert_matrix). + rec["ext"][{210: 0, 220: 1, 230: 2}[code]] = value + elif aktuell == "ATTRIB" and code == 1 and attrib_wert is None: + # Nur den ersten Code 1 nehmen; die Zuordnung zum Tag erfolgt + # am Ende des ATTRIB (siehe oben beim 0-Tag). + attrib_wert = value + if rec is not None: + ablegen(rec) + finally: + mm.close() + fh.close() + return ms_inserts, block_inserts, block_base, statistik + + +def insert_matrix(rec, block_base): + """Transformationsmatrix eines INSERT (inkl. Basispunkt des Zielblocks). + + Einfuegepunkt, Rotation und Skalierung eines INSERT sind NICHT in + Weltkoordinaten angegeben, sondern im Objektkoordinatensystem (OCS), das + von der Extrusionsrichtung (Gruppencode 210/220/230) aufgespannt wird. + Bei der Normalen (0,0,1) ist OCS == WCS, und der Unterschied fiele nicht + auf; in dieser Zeichnung liegen aber rund 14 Prozent der INSERTs auf + gekippten Ebenen - haeufig (0,0,-1) sowie (+-1,0,0), also senkrecht + stehende Einbauten. Ohne die abschliessende OCS->WCS-Abbildung wird deren + Y als Z gedeutet: Positionen und Rotation sind dann falsch, ohne dass man + es dem Ergebnis ansieht. Die OCS-Achsen liefert ezdxf ueber den + Arbitrary-Axis-Algorithmus. + """ + base = block_base.get(rec["name"], (0.0, 0.0, 0.0)) + lokal = Matrix44.scale(*rec["s"]) + lokal @= Matrix44.z_rotate(math.radians(rec["rot"])) + lokal @= Matrix44.translate(*rec["p"]) + ext = rec.get("ext", [0.0, 0.0, 1.0]) + # Nullvektor waere eine defekte Extrusionsrichtung - OCS() wuerde daran + # scheitern; in dem Fall bleibt es bei OCS == WCS. + if list(ext) != [0.0, 0.0, 1.0] and any(ext): + ocs = OCS(ext) + lokal @= Matrix44.ucs(ocs.ux, ocs.uy, ocs.uz) + return Matrix44.translate(-base[0], -base[1], -base[2]) @ lokal + + +def im_fenster(x, y, fenster): + """Liegt (x, y) im XY-Fenster (x0, y0, x1, y1)? Ohne Fenster: immer ja.""" + if not fenster: + return True + x0, y0, x1, y1 = fenster + return x0 <= x <= x1 and y0 <= y <= y1 + + +def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12): + """Loest alle INSERTs rekursiv in Weltkoordinaten auf. + + Aequivalent zu ezdxf virtual_entities(), aber ohne DOM: die Matrizen + werden selbst komponiert. Gespiegelte Inserts (negative Skalierung) + liefern dadurch ebenfalls die korrekte Weltrotation. Zurueckgegeben + werden nur Vorkommen, deren Blockname einer Familie zugeordnet ist. + + Hier wird NICHT auf einen Abschnitt gefiltert - das passiert erst nach + der Paarbildung (siehe filtere_fenster). Ein Filter an dieser Stelle + wuerde die Paare zerreissen: der Kreisel dieser Zeichnung ist 23,6 m + lang, seine Spannstation liegt ausserhalb eines 12-m-Fensters, und ein + AN8 ohne SP8-Partner ergibt keinen Kreisel mehr, sondern einen + bedeutungslosen Einzel-Insert. Dasselbe gilt fuer Antrieb/Umlenkung. + """ + treffer = [] + unbekannt = {} + + def lauf(recs, mutter, tiefe, pfad): + if tiefe > max_tiefe: + return + for rec in recs: + mat = insert_matrix(rec, block_base) @ mutter + ursprung = mat.transform((0.0, 0.0, 0.0)) + xachse = mat.transform((1.0, 0.0, 0.0)) - ursprung + rot = math.degrees(math.atan2(xachse.y, xachse.x)) % 360.0 + fam = familie_von(rec["name"]) + if fam: + treffer.append({ + "familie": fam, + "block": rec["name"], + "layer": rec["layer"] or "", + # "+ 0.0" normalisiert negative Null: round(-0.0, 2) + # bleibt -0.0 und landete als "-0.0" im JSON, was beim + # Lesen und Vergleichen von Hand nur verwirrt. + "x": round(ursprung.x, RUND) + 0.0, + "y": round(ursprung.y, RUND) + 0.0, + "z": round(ursprung.z, RUND) + 0.0, + "rot": round(rot, RUND) + 0.0, + "tiefe": tiefe, + "attribs": rec["attribs"], + }) + elif rec["name"] and not rec["name"].startswith("*"): + unbekannt[rec["name"]] = unbekannt.get(rec["name"], 0) + 1 + kinder = block_inserts.get(rec["name"]) + if kinder and rec["name"] not in pfad: + lauf(kinder, mat, tiefe + 1, pfad | {rec["name"]}) + + lauf(ms_inserts, Matrix44(), 0, frozenset()) + + # Echte Duplikate entfernen - die Zeichnung enthaelt deckungsgleiche + # Mehrfach-Inserts (z.B. SP8 zweimal auf derselben Position). + gesehen = set() + eindeutig = [] + verworfen = 0 + for t in treffer: + key = (t["block"], t["x"], t["y"], t["z"], t["rot"]) + if key in gesehen: + verworfen += 1 + continue + gesehen.add(key) + eindeutig.append(t) + return eindeutig, unbekannt, verworfen + + +# --------------------------------------------------------------------------- +# Omniflo: Sivasnr aus dem Blocknamen ableiten +# --------------------------------------------------------------------------- + +def zahlen_aus(text): + """Alle Zahlen eines Textes als float-Liste (Komma = Dezimaltrenner).""" + return [float(t.replace(",", ".")) for t in re.findall(r"\d+(?:[.,]\d+)?", text)] + + +def basis_sivasnr(eintraege): + """Waehlt aus Katalogtreffern den Grundeintrag (ohne TEF-Kombination).""" + for e in eintraege: + if isinstance(e["Sivasnr"], int): + return str(e["Sivasnr"]) + return str(eintraege[0]["Sivasnr"]) if eintraege else None + + +def sivasnr_bogen(bname, katalog): + """Sivasnr eines Omniflo-Bogens aus dem Blocknamen ermitteln.""" + direkt = re.search(r"\b(\d{9})\b", bname) + if direkt: + return direkt.group(1), "Sivasnr direkt aus Blockname" + nums = zahlen_aus(bname) + if len(nums) < 3: + return None, "Blockname enthaelt zu wenige Kennzahlen" + radius, winkel = nums[1], nums[2] + kandidaten = [e for e in katalog + if e["Radius"] == radius and e["KurvenWinkel"] == winkel] + if not kandidaten and len(nums) > 3 and float(winkel).is_integer(): + # "67 5" im Blocknamen meint 67.5 - der Punkt geht beim Blocknamen verloren + winkel2 = winkel + nums[3] / 10.0 + kandidaten = [e for e in katalog + if e["Radius"] == radius and e["KurvenWinkel"] == winkel2] + if kandidaten: + winkel = winkel2 + nums = nums[:3] + nums[4:] + if len(kandidaten) > 1 and len(nums) >= 5: + breite, laenge = nums[3], nums[4] + enger = [e for e in kandidaten + if e["Breite"] == breite and e["Länge"] == laenge] + if enger: + kandidaten = enger + if not kandidaten: + return None, "kein Katalogtreffer fuer R=%g/Winkel=%g" % (radius, winkel) + return (basis_sivasnr(kandidaten), + "Katalogtreffer R=%g/Winkel=%g" % (radius, winkel)) + + +def sivasnr_weiche(bname, katalog): + """Sivasnr einer Omniflo-Weiche aus dem Blocknamen ermitteln.""" + direkt = re.search(r"\b(\d{9})\b", bname) + if direkt: + return direkt.group(1), "Sivasnr direkt aus Blockname" + nums = zahlen_aus(bname) + if len(nums) < 3: + return None, "Blockname enthaelt zu wenige Kennzahlen" + winkel, breite, laenge = nums[0], nums[1], nums[2] + richtung = None + if re.search(r"\bR\b", bname): + richtung = 2 + elif re.search(r"\bL\b", bname): + richtung = 1 + schaltung = None + m = re.search(r"mit\s+([MP])\b", bname, re.I) + if m: + schaltung = m.group(1).upper() + kandidaten = [e for e in katalog if e["KurvenWinkel"] == winkel + and e["Breite"] == breite and e["Länge"] == laenge] + if richtung is not None: + kandidaten = [e for e in kandidaten if e["KurvenRichtung"] == richtung] + if schaltung: + kandidaten = [e for e in kandidaten if e["Schaltungstyp"] == schaltung] + if not kandidaten: + return None, "kein Katalogtreffer fuer Winkel=%g/%gx%g" % (winkel, breite, laenge) + return (basis_sivasnr(kandidaten), + "Katalogtreffer Winkel=%g/%gx%g" % (winkel, breite, laenge)) + + +# --------------------------------------------------------------------------- +# Paarbildung und Schema-Abbildung +# --------------------------------------------------------------------------- + +def paare_bilden(antriebe, umlenkungen): + """Gegenseitig naechste Nachbarn paaren (nur innerhalb desselben Systems). + + Rueckgabe: (paare, rest_antriebe, rest_umlenkungen) + """ + def dist(a, b): + return math.hypot(b["x"] - a["x"], b["y"] - a["y"]) + + paare = [] + offen_a = list(antriebe) + offen_u = list(umlenkungen) + while True: + bester = None + for a in offen_a: + kand = [u for u in offen_u if system_von(u["block"]) == system_von(a["block"])] + if not kand: + continue + u = min(kand, key=lambda k: dist(a, k)) + d = dist(a, u) + if d > MAX_PAAR_ABSTAND: + continue + # Gegenprobe: ist a auch fuer u der naechste Antrieb? + kand_a = [x for x in offen_a if system_von(x["block"]) == system_von(u["block"])] + if min(kand_a, key=lambda k: dist(k, u)) is not a: + continue + if bester is None or d < bester[2]: + bester = (a, u, d) + if bester is None: + break + a, u, d = bester + offen_a.remove(a) + offen_u.remove(u) + paare.append((a, u, d)) + return paare, offen_a, offen_u + + +def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min): + """Ein Antrieb/Umlenk-Paar auf Gerade, VarioFoerderer (3.1) oder + Gefaellestrecke (2.1) abbilden. + + Die Aufteilung folgt der Produktionsregel aus gf-analysiere-kette + (Lisp/Gefaellestrecke.lsp): Endpunkt hoeher -> VarioFoerderer; tiefer mit + mindestens dem Gefaellewinkel -> Gefaellestrecke; tiefer mit weniger + Neigung -> VarioFoerderer "Ab". + + Vorgeschaltet der Sonderfall ohne Hoehendifferenz: ein VarioFoerderer ist + per Definition ein Steig- oder Fallfoerderer, sein Solver + (berechne-standard -> berechne-alle-winkel) muss einen Bogenwinkel aus der + Kandidatenliste 3..51 Grad finden. Bei deltaH ~ 0 gibt es keinen, der + Eintrag waere unbaubar. Solche Paare sind waagrechte Foerderstrecken und + werden als eigener Typ "gerade" ausgewiesen. + """ + dz = u["z"] - a["z"] + hz = round(math.degrees(math.atan2(u["y"] - a["y"], u["x"] - a["x"])) % 360.0, RUND) + neigung = round(math.degrees(math.atan2(abs(dz), dist_xy)), RUND) if dist_xy else 0.0 + herkunft = ("aus DXF: %s -> %s (Layer %s / %s), XY-Abstand %.2f, " + "Neigung %.2f Grad" % (a["block"], u["block"], a["layer"], + u["layer"], dist_xy, neigung)) + + if abs(dz) < GERADE_MAX_DELTA_H: + return { + "test_id": "GERADE_HundM05_%02d" % nummer, + "typ": "gerade", + "laenge": round(dist_xy, RUND), + "hz": hz, + "startpunkt": [a["x"], a["y"], a["z"]], + "endpunkt": [u["x"], u["y"], u["z"]], + "comment": herkunft + "; waagrechte Foerderstrecke (Hoehen" + "differenz %.2f mm < %g mm) - KEIN VarioFoerderer, da " + "dessen Winkel-Solver eine Hoehendifferenz braucht." + % (abs(dz), GERADE_MAX_DELTA_H), + } + + if dz < 0 and neigung >= gf_winkel_min: + return { + "test_id": "GF_HundM05_%02d" % nummer, + "modus": 1, + "deltaL_mm": round(dist_xy, RUND), + "winkel_grad": neigung, + "hz_grad": hz, + "start_mm": [a["x"], a["y"], a["z"]], + "as_seite": "links", + "es_seite": "links", + "as_winkel": "90", + "es_winkel": "90", + "comment": herkunft + "; Gefaellestrecke, da fallend und Neigung " + ">= %g Grad. as_/es_seite und -winkel sind Defaults " + "(im DXF nicht ableitbar)." % gf_winkel_min, + } + + return { + "test_id": "VF_HundM05_%02d" % nummer, + "typ": "standard", + "richtung": "Auf" if dz > 0 else "Ab", + "deltaL": round(dist_xy, RUND), + "deltaH": round(abs(dz), RUND), + "startpunkt": [a["x"], a["y"], a["z"]], + "seite": "rechts", + "hz": hz, + "comment": herkunft + "; VarioFoerderer. 'winkel' bewusst nicht " + "gesetzt - der Solver waehlt den kleinsten gueltigen " + "Kandidaten. 'seite' ist Default (im DXF nicht ableitbar).", + } + + +def sensor_eintrag(t, bekannte_bloecke=None, hinweise=None): + """Beliebiges Bauteil auf das generische Insert-Schema (5.) abbilden. + + "bekannte_bloecke" (siehe lade_bekannte_bloecke) dient der Kontrolle, ob + der abgebildete Blockname in data/ils/ wirklich existiert; fehlende + Varianten werden in "hinweise" gemeldet statt im Testlauf still zu einem + Platzhalter zu werden. + """ + block, geraten = mappe_block(t["block"]) + kommentar = "aus DXF: Block '%s', Layer '%s'" % (t["block"], t["layer"]) + if block != t["block"]: + kommentar += ", auf Makro-Block '%s' abgebildet" % block + if geraten: + kommentar += ("; ACHTUNG geraten (im DXF nicht ableitbar): %s - bei " + "Bedarf hier im JSON korrigieren" % ", ".join(geraten)) + if bekannte_bloecke and block not in bekannte_bloecke: + kommentar += ("; Block '%s' ist in data/ils/ NICHT vorhanden - " + "erzeugt im Testlauf einen Platzhalter" % block) + if hinweise is not None: + hinweise.append("Block '%s' (aus '%s') fehlt in data/ils/" + % (block, t["block"])) + if t["attribs"]: + kommentar += ", Attribute %s" % json.dumps(t["attribs"], ensure_ascii=False) + return { + "function": "insert", + "block": block, + "x": t["x"], + "y": t["y"], + "z": t["z"], + "rotation": t["rot"], + "comment": kommentar, + } + + +def baue_eintraege(treffer, defaults, boegen, weichen, + bekannte_bloecke=None): + """Erkannte Vorkommen auf die Schemata aus object_data.md abbilden.""" + nach_familie = {} + for t in treffer: + nach_familie.setdefault(t["familie"], []).append(t) + + eintraege = [] + hinweise = [] + + # --- Kreisel: AN8 (Antriebsstation) + SP8 (Spannstation) -------------- + durchmesser = defaults.get("kreisel", {}).get("durchmesser", 800.0) + an_liste = sorted(nach_familie.get("kreisel_an", []), key=lambda t: (t["x"], t["y"])) + sp_liste = list(nach_familie.get("kreisel_sp", [])) + for i, an in enumerate(an_liste, start=1): + if not sp_liste: + hinweise.append("AN8 bei (%.2f, %.2f) ohne SP8-Partner" % (an["x"], an["y"])) + eintraege.append(sensor_eintrag(an, bekannte_bloecke, hinweise)) + continue + sp = min(sp_liste, key=lambda s: math.hypot(s["x"] - an["x"], s["y"] - an["y"])) + sp_liste.remove(sp) + dist = math.hypot(sp["x"] - an["x"], sp["y"] - an["y"]) + rotation = math.degrees(math.atan2(sp["y"] - an["y"], sp["x"] - an["x"])) % 360.0 + eintraege.append({ + "id": "Kreisel_HundM05_%02d" % i, + "function": "connect", + "start_x": an["x"], "start_y": an["y"], "start_z": an["z"], + "end_x": sp["x"], "end_y": sp["y"], "end_z": sp["z"], + "typ": "STANDARD", + "hoehe": an["z"], + "comment": "aus DXF: AN8 (Layer %s) -> SP8 (Layer %s), Abstand " + "%.2f; daraus folgt produktionsseitig abstand = %.2f - " + "%.1f = %.2f und rotation = %.2f Grad. ACHTUNG hoehe: " + "AN8 und SP8 sind in dieser Zeichnung flache 2D-Symbole " + "(nur Linien/Kreis/Text auf Z=0) und auf Z=0 eingefuegt " + "- die Einfuegehoehe des Kreisels steht NICHT im DXF " + "und muss hier von Hand gesetzt werden (vgl. " + "mubea.json, wo z ebenfalls Handarbeit ist)." + % (an["layer"], sp["layer"], dist, dist, durchmesser, + dist - durchmesser, rotation), + }) + for sp in sp_liste: + hinweise.append("SP8 bei (%.2f, %.2f) ohne AN8-Partner" % (sp["x"], sp["y"])) + eintraege.append(sensor_eintrag(sp, bekannte_bloecke, hinweise)) + + # --- Foerderer: Antriebs- und Umlenkstation paaren --------------------- + gf_winkel_min = defaults.get("gefaelle", {}).get("default_winkel", 3.0) + paare, rest_a, rest_u = paare_bilden(nach_familie.get("foerderer_antrieb", []), + nach_familie.get("foerderer_umlenk", [])) + paare.sort(key=lambda p: (p[0]["x"], p[0]["y"])) + for i, (a, u, d) in enumerate(paare, start=1): + eintraege.append(foerderer_eintrag(a, u, d, i, gf_winkel_min)) + for t in rest_a + rest_u: + hinweise.append("Foerderer-Station ohne Partner: %s bei (%.2f, %.2f)" + % (t["block"], t["x"], t["y"])) + eintraege.append(sensor_eintrag(t, bekannte_bloecke, hinweise)) + + # --- Omniflo Boegen und Weichen --------------------------------------- + for familie, katalog, typ in (("omniflo_bogen", boegen, "bogen"), + ("omniflo_weiche", weichen, "weiche")): + posten = sorted(nach_familie.get(familie, []), key=lambda t: (t["x"], t["y"])) + for i, t in enumerate(posten, start=1): + if typ == "bogen": + sivasnr, grund = sivasnr_bogen(t["block"], katalog) + else: + sivasnr, grund = sivasnr_weiche(t["block"], katalog) + if sivasnr is None: + hinweise.append("Omniflo %s ohne Sivasnr: '%s' (%s)" + % (typ, t["block"], grund)) + eintraege.append({ + "id": sivasnr or ("%s_HundM05_%02d" % (typ.upper(), i)), + "type": typ, + "sivasnr": sivasnr, + "x": t["x"], "y": t["y"], "z": t["z"], + "hoehe": t["z"], + "drehung": t["rot"], + "comment": "aus DXF: Block '%s', Layer '%s'; %s" + % (t["block"], t["layer"], grund), + }) + + # --- ILS-Modulweichen ------------------------------------------------- + # Welche der zwoelf Modulweiche-Varianten (RG links/rechts bzw. RR, je + # Passiv/Pneumatik/Zylinder) gemeint ist, steht im Blocknamen "Weiche 2 + # Wege" nicht drin und ist aus der Zeichnung auch sonst nicht ableitbar. + # Darum bleibt der Originalname stehen und die Variante wird als Hinweis + # gemeldet, statt hier eine zu erfinden. + ils_weichen = sorted(nach_familie.get("ils_weiche", []), + key=lambda t: (t["x"], t["y"])) + if ils_weichen: + hinweise.append( + "%d ILS-Modulweiche(n): Variante nicht aus der Zeichnung " + "ableitbar - Blockname bleibt stehen, gewuenschte Variante " + "(Modulweiche_RG_links/rechts bzw. Modulweiche_RR, je Passiv/" + "Pneumatik/Zylinder) muss im JSON von Hand gesetzt werden" + % len(ils_weichen)) + eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise) + for t in ils_weichen) + + # --- Rest: Sensoren, Boegen, Schleuselemente -------------------------- + for familie in ("bogen", "schleuselement", "sensor"): + posten = sorted(nach_familie.get(familie, []), + key=lambda t: (t["block"], t["x"], t["y"])) + eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise) + for t in posten) + + return eintraege, hinweise + + +# --------------------------------------------------------------------------- +# Plausibilitaetspruefung der erzeugten Eintraege +# --------------------------------------------------------------------------- + +def punkte_von(eintrag): + """Alle Koordinatentripel eines Eintrags - je Schema anders benannt.""" + punkte = [] + if "x" in eintrag: + punkte.append((eintrag["x"], eintrag["y"], eintrag.get("z", 0.0))) + if "start_x" in eintrag: + punkte.append((eintrag["start_x"], eintrag["start_y"], + eintrag.get("start_z", 0.0))) + if "end_x" in eintrag: + punkte.append((eintrag["end_x"], eintrag["end_y"], + eintrag.get("end_z", 0.0))) + # startpunkt: VarioFoerderer (3.1/3.2) und Gerade; start_mm: + # Gefaellestrecke (2.1); endpunkt: nur Gerade. Wird hier ein Feld eines + # neuen Eintragsschemas vergessen, liefert die Funktion eine leere Liste - + # filtere_fenster wuerde den Eintrag dann ungeprueft behalten, darum muss + # diese Liste vollstaendig bleiben. + for feld in ("startpunkt", "start_mm", "endpunkt"): + p = eintrag.get(feld) + if isinstance(p, (list, tuple)) and len(p) >= 3: + punkte.append((p[0], p[1], p[2])) + return punkte + + +def filtere_fenster(eintraege, fenster): + """Behaelt die Eintraege, die den Abschnitt (x0, y0, x1, y1) beruehren. + + Gefiltert wird bewusst erst hier - nach der Paarbildung in + baue_eintraege - und nicht schon auf Ebene der einzelnen Vorkommen: ein + Kreisel oder Foerderer besteht aus zwei Stationen, die weiter + auseinanderliegen koennen als das Fenster breit ist. Ein Eintrag gilt als + im Abschnitt, wenn IRGENDEINER seiner Punkte darin liegt; er darf also + ueber den Fensterrand hinausragen (der Kreisel dieser Zeichnung tut das: + Antriebsstation innen, Spannstation 23,6 m weiter aussen). + + Rueckgabe: (behaltene Eintraege, Anzahl verworfener, Anzahl ohne Punkte) + Eintraege, aus denen punkte_von keine Koordinate lesen kann, werden + behalten - aber gezaehlt und gemeldet: das ist immer eine Luecke in + punkte_von und keine Eigenschaft der Zeichnung. + """ + if not fenster: + return eintraege, 0, 0 + behalten = [] + ohne_punkte = 0 + for e in eintraege: + punkte = punkte_von(e) + if not punkte: + ohne_punkte += 1 + behalten.append(e) + elif any(im_fenster(p[0], p[1], fenster) for p in punkte): + behalten.append(e) + return behalten, len(eintraege) - len(behalten), ohne_punkte + + +def arrays_einzeilig(text): + """Zahlen-Arrays im JSON-Text auf eine Zeile ziehen. + + ZWINGEND fuer den LISP-Treiber: ssg-parse-json-array (ssg_core.lsp) liest + die Datei ZEILENWEISE und ssg-cfg-parse-array erwartet das komplette Array + in einem String. json.dump(indent=2) bricht Arrays aber um: + + "start_mm": [ + 219540.85, + ... + ], + + Davon sieht der Parser nur "start_mm": [ - das ergibt nil, und der Treiber + baut das Element am Ursprung statt an seiner Position. mubea.json war von + Hand geschrieben und hatte die Arrays deshalb immer einzeilig + ("start_mm": [9510, 12300, 1941]). Greift nur bei rein numerischen + Arrays - andere kommen in dieser Ausgabe nicht vor - und ist idempotent. + """ + def zusammen(m): + return "[" + " ".join(m.group(1).split()) + "]" + return re.sub(r"\[([\s\d.,+\-eE]*?)\]", zusammen, text) + + +def pruefe_plausibilitaet(eintraege): + """Prueft Hoehen und Ausdehnung der Eintraege auf Unmoeglichkeiten. + + Rueckgabe: Liste von Warntexten (leer = alles im Rahmen). Es wird nichts + aussortiert - die Pruefung soll nur verhindern, dass eine JSON mit + offensichtlich falsch transformierten Koordinaten still geschrieben und + danach fuer einen Testfall verwendet wird. + """ + warnungen = [] + punkte = [p for e in eintraege for p in punkte_von(e)] + if not punkte: + return ["keine Koordinaten in den Eintraegen gefunden"] + + zu_tief = [p for p in punkte if p[2] < Z_PLAUSIBEL[0]] + zu_hoch = [p for p in punkte if p[2] > Z_PLAUSIBEL[1]] + if zu_tief or zu_hoch: + warnungen.append( + "%d von %d Koordinaten liegen ausserhalb des plausiblen " + "Z-Fensters %.0f..%.0f mm (%d zu tief, min %.1f; %d zu hoch, " + "max %.1f) - Hinweis auf eine falsch aufgeloeste Transformation" + % (len(zu_tief) + len(zu_hoch), len(punkte), Z_PLAUSIBEL[0], + Z_PLAUSIBEL[1], len(zu_tief), min(p[2] for p in punkte), + len(zu_hoch), max(p[2] for p in punkte))) + + breite = max(p[0] for p in punkte) - min(p[0] for p in punkte) + hoehe = max(p[1] for p in punkte) - min(p[1] for p in punkte) + if breite > XY_MAX_AUSDEHNUNG or hoehe > XY_MAX_AUSDEHNUNG: + warnungen.append( + "XY-Ausdehnung %.0f x %.0f mm ueberschreitet die plausible " + "Anlagengroesse von %.0f mm" % (breite, hoehe, XY_MAX_AUSDEHNUNG)) + return warnungen + + +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Erkennt ILS-/Omniflo-Komponenten in einem fremden DXF " + "und schreibt sie im JSON-Schema aus " + "tests/testdata/object_data.md.") + parser.add_argument("dxf", help="Quellzeichnung (binaeres DXF)") + parser.add_argument("data_dir", help="Datenverzeichnis mit json/") + parser.add_argument("output", help="Ziel-JSON") + parser.add_argument("--fenster", nargs=4, type=float, + metavar=("X0", "Y0", "X1", "Y1"), + help="Nur Bauteile in diesem XY-Fenster (mm) " + "ausgeben - fuer den Nachbau eines einzelnen " + "Anlagenabschnitts statt der ganzen Zeichnung") + args = parser.parse_args() + dxf_path, data_dir, output_path = args.dxf, args.data_dir, args.output + fenster = None + if args.fenster: + x0, y0, x1, y1 = args.fenster + fenster = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + + if not os.path.isfile(dxf_path): + print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, dxf_path)) + sys.exit(1) + + json_dir = os.path.join(data_dir, "json") + defaults = load_json(os.path.join(json_dir, "component_defaults.json")) + boegen = load_json(os.path.join(json_dir, "omniflo_boegen.json")) + weichen = load_json(os.path.join(json_dir, "omniflo_weichen.json")) + bekannte_bloecke = lade_bekannte_bloecke(data_dir) + print("%s %d Blocknamen in %s/ils gefunden" % (PREFIX, + len(bekannte_bloecke), + data_dir)) + + groesse = os.path.getsize(dxf_path) / (1024.0 * 1024.0) + print("%s lese %s (%.1f MB) ..." % (PREFIX, dxf_path, groesse)) + ms_inserts, block_inserts, block_base, statistik = lese_inserts(dxf_path) + print("%s %d INSERTs im Modelspace, %d Blockdefinitionen mit Inhalt" + % (PREFIX, len(ms_inserts), len(block_inserts))) + print("%s %d INSERTs gesamt, davon %d mit gekippter OCS (%.1f%%), " + "%d im Papierbereich (ignoriert)" + % (PREFIX, statistik["inserts"], statistik["ocs_gekippt"], + 100.0 * statistik["ocs_gekippt"] / max(statistik["inserts"], 1), + statistik["papierbereich"])) + + treffer, unbekannt, verworfen = sammle_vorkommen(ms_inserts, block_inserts, + block_base) + print("%s %d erkannte Bauteil-Vorkommen (%d deckungsgleiche Duplikate " + "verworfen)" % (PREFIX, len(treffer), verworfen)) + mit_attrib = sum(1 for t in treffer if t["attribs"]) + print("%s %d davon mit Blockattributen" % (PREFIX, mit_attrib)) + + zaehler = {} + for t in treffer: + zaehler[t["familie"]] = zaehler.get(t["familie"], 0) + 1 + for familie in sorted(zaehler): + print("%s %-20s %d" % (PREFIX, familie, zaehler[familie])) + + eintraege, hinweise = baue_eintraege(treffer, defaults, boegen, weichen, + bekannte_bloecke) + + if fenster: + vorher = len(eintraege) + eintraege, weggefallen, ohne_punkte = filtere_fenster(eintraege, + fenster) + print("%s Abschnitt X %.0f..%.0f / Y %.0f..%.0f mm: %d von %d " + "Eintraegen behalten (%d ausserhalb)" + % (PREFIX, fenster[0], fenster[2], fenster[1], fenster[3], + len(eintraege), vorher, weggefallen)) + if ohne_punkte: + print("%s ! %d Eintraege ohne lesbare Koordinaten wurden " + "ungeprueft behalten - punkte_von ergaenzen" + % (PREFIX, ohne_punkte)) + + warnungen = pruefe_plausibilitaet(eintraege) + if warnungen: + print("%s PLAUSIBILITAET - die Ausgabe ist mit Vorsicht zu " + "verwenden:" % PREFIX) + for w in warnungen: + print("%s ! %s" % (PREFIX, w)) + else: + print("%s Plausibilitaetspruefung (Z-Fenster, XY-Ausdehnung): ok" + % PREFIX) + + ziel_dir = os.path.dirname(os.path.abspath(output_path)) + if ziel_dir and not os.path.isdir(ziel_dir): + os.makedirs(ziel_dir) + with open(output_path, "w", encoding="utf-8") as fh: + fh.write(arrays_einzeilig(json.dumps(eintraege, ensure_ascii=False, + indent=2))) + fh.write("\n") + print("%s %d Eintraege geschrieben -> %s" % (PREFIX, len(eintraege), output_path)) + + if hinweise: + # Die Hinweise entstehen bei der Paarbildung, also VOR dem + # Abschnittsfilter - sie beziehen sich damit auf die ganze Zeichnung + # und nicht nur auf den ausgegebenen Abschnitt. + print("%s Hinweise (%d, ganze Zeichnung%s):" + % (PREFIX, len(hinweise), + " - nicht nur der Abschnitt" if fenster else "")) + for h in hinweise: + print("%s - %s" % (PREFIX, h)) + + if unbekannt: + top = sorted(unbekannt.items(), key=lambda kv: -kv[1])[:25] + print("%s nicht zugeordnete Blocknamen (Top %d von %d) - ggf. " + "FAMILIEN ergaenzen:" % (PREFIX, len(top), len(unbekannt))) + for name, anzahl in top: + print("%s %-50s %d" % (PREFIX, name, anzahl)) + + +if __name__ == "__main__": + main() diff --git a/menu/SSG_LIB.cui b/menu/SSG_LIB.cui index 2216e34..66e4675 100644 --- a/menu/SSG_LIB.cui +++ b/menu/SSG_LIB.cui @@ -639,132 +639,156 @@ + + Verbindung einfuegen + ^C^C(ssg-ensure "Connection_Edit") CONNECTION_INSERT + + + + + Verbindung bearbeiten + ^C^C(ssg-ensure "Connection_Edit") CONNECTION_EDIT + + + Export Sivas ^C^C(ssg-ensure "count_sep_scan") ZAEHLE_SEP_SCAN (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 - + Alle Tests ausfuehren ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_run_all.lsp")) SSG_RUN_ALL_TESTS - + Kreisel ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_kreisel.lsp")) TEST_KREISEL - + Foerderer ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_foerderer.lsp")) TEST_FOERDERER - + Foerderer-Linienzug ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_linienzug.lsp")) TEST_LINIENZUG - + Omniflo ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_omniflo.lsp")) TEST_OMNIFLO - + Omniflo Strecke ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_omniflo_strecke.lsp")) TEST_OMNIFLO_STRECKE - + Gefaellestrecke ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_gefaellestrecke.lsp")) TEST_GEFAELLESTRECKE - + Mubea Gesamtmodell ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_mubea.lsp")) TEST_MUBEA - + + + HundM05 Kreisel-Abschnitt + ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_hundm05.lsp")) TEST_HUNDM05 + + + KS 2D/3D pruefen (CSV) ^C^C(load (strcat (getenv "DXFMAKRO") "/Lisp/ks_pruefung.lsp")) KS_PRUEFEN - + Export CSV/Sivas ALL ^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_export_all.lsp")) TEST_EXPORT_ALL - + Kreisel bearbeiten (ssg-ensure "KreiselInsert") KreiselEdit - + 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 @@ -783,8 +807,9 @@ - - + + + ILS @@ -1484,44 +1509,65 @@ - Export + Connections - Export Sivas + Verbindung einfuegen - Export CSV + Verbindung bearbeiten - - - Konfiguration - Umschalten 2D und 3D + Bearbeiten (Auto) - + + + + Export - Alle Bloecke auf 2D + Export Sivas - Alle Bloecke auf 3D + Export CSV + + + Konfiguration + + Umschalten 2D und 3D + + + + + + Alle Bloecke auf 2D + + + + + + Alle Bloecke auf 3D + + + + Sprache - + @@ -1531,108 +1577,120 @@ - + Tests Alle Tests ausfuehren - + Kreisel - + Foerderer - + Foerderer-Linienzug - + Omniflo - + Omniflo Strecke - + Gefaellestrecke - + Mubea Gesamtmodell - + + + + + HundM05 Kreisel-Abschnitt + + KS 2D/3D pruefen (CSV) - + Export CSV/Sivas ALL - + - + POP501 SSG_LIB Edit Context Kreisel 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 d1ef45b..c43d379 100644 --- a/menu/SSG_LIB.mnu +++ b/menu/SSG_LIB.mnu @@ -153,6 +153,7 @@ [Omniflo Strecke]^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_omniflo_strecke.lsp")) TEST_OMNIFLO_STRECKE [Gefaellestrecke]^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_gefaellestrecke.lsp")) TEST_GEFAELLESTRECKE [Mubea Gesamtmodell]^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_mubea.lsp")) TEST_MUBEA +[HundM05 Kreisel-Abschnitt]^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_hundm05.lsp")) TEST_HUNDM05 [KS 2D/3D pruefen (CSV)]^C^C(load (strcat (getenv "DXFMAKRO") "/Lisp/ks_pruefung.lsp")) KS_PRUEFEN [<-Export CSV/Sivas ALL]^C^C(load (strcat (getenv "DXFMAKRO") "/tests/test_export_all.lsp")) TEST_EXPORT_ALL diff --git a/tests/alltests.json b/tests/alltests.json index 422d492..10b83ef 100644 --- a/tests/alltests.json +++ b/tests/alltests.json @@ -5,5 +5,6 @@ { "name": "omniflo", "save": "dxf", "module": "OmniModulInsert" }, { "name": "omniflo_strecke", "save": "dxf", "module": "OmniModulInsert" }, { "name": "gefaellestrecke", "save": "dwg", "module": "Gefaellestrecke" }, - { "name": "mubea", "save": "dxf", "module": "KreiselInsert" } + { "name": "mubea", "save": "dxf", "module": "KreiselInsert" }, + { "name": "hundm05", "save": "dxf", "module": "KreiselInsert" } ] diff --git a/tests/conftest.py b/tests/conftest.py index 6eea4aa..1d8e8c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,3 +187,37 @@ def mubea_dxf(): if not os.path.exists(path): pytest.skip("mubea_tests.dxf nicht vorhanden - TESTRUN (save:dxf) in BricsCAD ausfuehren") return ezdxf.readfile(path) + + +# --- HundM05 Fixtures --- + +@pytest.fixture +def hundm05_testdata(): + """Laedt die HundM05-Testdefinitionen aus testdata/hundm05.json. + + Erzeugt von lib/dxf_scan_components.py aus results/ST500592_05.dxf + (Fenster 216000 144000 228000 156000, Kreisel-Abschnitt). + """ + path = os.path.join(_testdata_dir(), "hundm05.json") + return _load_json(path) + + +@pytest.fixture +def hundm05_results(): + """Laedt die HundM05-Ergebnisse aus output/hundm05_results.json.""" + path = os.path.join(_output_dir(), "hundm05_results.json") + if not os.path.exists(path): + pytest.skip("hundm05_results.json nicht vorhanden - TEST_HUNDM05 in BricsCAD ausfuehren") + data = _load_json(path) + if not data: + pytest.skip("hundm05_results.json ist leer") + return data + + +@pytest.fixture +def hundm05_dxf(): + """Laedt die erzeugte HundM05-DXF aus output/hundm05_tests.dxf.""" + path = os.path.join(_output_dir(), "hundm05_tests.dxf") + if not os.path.exists(path): + pytest.skip("hundm05_tests.dxf nicht vorhanden - TESTRUN (save:dxf) in BricsCAD ausfuehren") + return ezdxf.readfile(path) diff --git a/tests/test_hundm05.lsp b/tests/test_hundm05.lsp new file mode 100644 index 0000000..a8b197a --- /dev/null +++ b/tests/test_hundm05.lsp @@ -0,0 +1,601 @@ +;; ============================================================ +;; test_hundm05.lsp - Integrationstest: baut den Kreisel-Abschnitt der +;; Anlage HundM aus der Fremdzeichnung ST500592_05.dxf nach. +;; +;; Liest tests/testdata/hundm05.json (erzeugt von lib/dxf_scan_components.py +;; mit --fenster 216000 144000 228000 156000) und baut in EINER Zeichnung: +;; - Kreisel (Eintraege mit "id", OHNE "type") Block KREISEL_* +;; "function": "connect" -> kreisel-connect-script (Start-/ +;; Endpunkt, abstand+rotation werden intern berechnet) +;; "function": "insert" -> kreisel-insert-script +;; - Vario (test_id "VF_*") Block VF_* via variofoerderer-einfuegen +;; - Gefaelle (test_id "GF_*") Block GF_* via gefaellestrecke-einfuegen +;; - Gerade (test_id "GERADE_*") waagrechte Foerderstrecke via +;; insert-hz-scaled-block-by-ks (Staustrecke_SP_1000_mm) +;; - Omniflo (Eintraege mit "type") - NICHT gebaut, siehe hundm05:build-omniflo +;; - Sensoren (Eintraege mit "block") via _.INSERT +;; +;; Speichert (via SSG_RUN_ALL_TESTS bei "save":"dxf"): +;; tests/output/hundm05_tests.dxf +;; tests/output/hundm05_results.json (via hundm05:export-results) +;; +;; Unterschiede zu tests/test_mubea.lsp - warum eine eigene Datei und kein +;; gemeinsamer Treiber: +;; 1. Der Kreisel kommt hier als "connect" (zwei Punkte aus dem DXF), bei +;; Mubea als "insert" (Punkt + abstand + rotation von Hand). +;; 2. Die VarioFoerderer nennen ihren Startpunkt "startpunkt" (Liste, wie +;; object_data.md 3.1) statt x/y/z, und sie haben kein "L_GF1" - der +;; L_GF-Split ist hier "gleichmaessig" (Default nach object_data.md). +;; 3. Es gibt den Eintragstyp "gerade", den mubea.json nicht kennt. +;; 4. Eintraege mit "type" (Omniflo) muessen VOR "id" abgefragt werden, weil +;; sie beides tragen - bei Mubea gab es diesen Fall nicht. +;; test_mubea.lsp bleibt dadurch unangetastet und die bestehende Suite +;; unveraendert lauffaehig. +;; +;; Voraussetzungen: +;; - SSG_LIB geladen (KreiselInsert, VarioFoerderer, Gefaellestrecke, +;; SSG_LIB_Commands, ssg_core, vf_core) +;; - Umgebungsvariable DXFMAKRO gesetzt +;; +;; Aufruf in BricsCAD: +;; (load (strcat (getenv "DXFMAKRO") "/tests/test_hundm05.lsp")) +;; TEST_HUNDM05 +;; ============================================================ + + +;; --- Ergebnis eines Bau-Schritts als JSON-String erzeugen --- +(defun hundm05:result-json (test-id kind status block-name block-handle insert-point attribs / + json tag val first) + (setq json (strcat " {\n" + " \"test_id\": \"" test-id "\",\n" + " \"kind\": \"" kind "\",\n" + " \"status\": \"" status "\",\n" + " \"block_name\": \"" (if block-name block-name "") "\",\n" + " \"block_handle\": \"" (if block-handle block-handle "") "\",\n" + " \"insert_point\": [" + (if insert-point + (strcat (rtos (car insert-point) 2 1) ", " + (rtos (cadr insert-point) 2 1) ", " + (rtos (if (caddr insert-point) (caddr insert-point) 0.0) 2 1)) + "0.0, 0.0, 0.0") + "],\n" + " \"actual_attributes\": {")) + (setq first T) + (foreach att attribs + (setq tag (car att) val (cdr att)) + (if (not first) (setq json (strcat json ","))) + (setq json (strcat json "\n \"" tag "\": \"" (if val val "") "\"")) + (setq first nil)) + (setq json (strcat json "\n }\n }")) + json +) + + +;; --- Aus einer Block-ENAME ein Ergebnis-JSON erzeugen --- +(defun hundm05:ent-json (test-id kind ent / ed) + (if ent + (progn + (setq ed (entget ent)) + (hundm05:result-json test-id kind "executed" + (cdr (assoc 2 ed)) (cdr (assoc 5 ed)) (cdr (assoc 10 ed)) + (ssg-attrib-read ent))) + (hundm05:result-json test-id kind "failed" nil nil nil nil)) +) + + +;; --- Letzten INSERT mit Blocknamen-Praefix (rueckwaerts) suchen --- +(defun hundm05:last-insert-prefix (pref / ent found ed bn) + (setq ent (entlast)) + (while (and ent (null found)) + (setq ed (entget ent) bn (cdr (assoc 2 ed))) + (if (and (= (cdr (assoc 0 ed)) "INSERT") bn + (>= (strlen bn) (strlen pref)) + (= (substr bn 1 (strlen pref)) pref)) + (setq found ent)) + (setq ent (entprev ent))) + found +) + + +;; --- Zahl aus JSON lesen, mit Default statt Fehler bei fehlendem Feld --- +;; (float nil) bricht in AutoLISP ab. hundm05.json laesst - anders als +;; mubea.json - bewusst Felder weg (z.B. "winkel" und "L_GF1" beim Vario, damit +;; der Solver selbst waehlt), darum hier grundsaetzlich abgesichert lesen. +(defun hundm05:num (e key default / v) + (setq v (ssg-val e key)) + (if (numberp v) (float v) default) +) + + +;; --- Punkt-Liste aus JSON lesen ("startpunkt"/"start_mm": [x, y, z]) --- +;; Liefert NIEMALS einen Ersatzpunkt. Ein Ursprungs-Fallback (0 0 0) waere hier +;; fatal: er baut die Anlage sichtbar, aber komplett an der falschen Stelle - +;; genau das passiert, wenn das Array in der JSON ueber mehrere Zeilen +;; umgebrochen ist (siehe hundm05:startpunkt). +(defun hundm05:pt (e key / v) + (setq v (ssg-val e key)) + (if (and v (listp v) (>= (length v) 3)) (mapcar 'float v) nil) +) + + +;; --- Startpunkt eines Eintrags lesen: Array-Feld, sonst flache x/y/z --- +;; Rueckgabe: Punkt oder nil. Bei nil MUSS der Aufrufer den Eintrag als +;; "failed" melden und darf nichts bauen. +;; +;; Haeufigste Ursache fuer nil: ssg-parse-json-array (ssg_core.lsp) liest die +;; JSON ZEILENWEISE, und ssg-cfg-parse-array braucht das ganze Array in einer +;; Zeile. Ein von json.dump(indent=2) umgebrochenes Array +;; "start_mm": [ +;; 219540.85, +;; ... +;; ] +;; liefert nil. Die Ausgabe von lib/dxf_scan_components.py zieht Zahlen-Arrays +;; darum wieder auf eine Zeile (arrays_einzeilig). +(defun hundm05:startpunkt (e feld / p) + (setq p (hundm05:pt e feld)) + ;; Ersatzweise flache x/y/z-Felder zulassen (Schema wie mubea.json) + (if (and (null p) (numberp (ssg-val e "x"))) + (setq p (list (hundm05:num e "x" 0.0) (hundm05:num e "y" 0.0) + (hundm05:num e "z" 0.0)))) + p +) + + +;; --- Meldung fuer einen nicht lesbaren Startpunkt --- +(defun hundm05:kein-startpunkt (tid feld) + (princ (strcat "\n FEHLER: " tid " - Feld \"" feld "\" nicht lesbar." + " Steht das Array in EINER Zeile? ssg-cfg-parse-array kann" + " keine umgebrochenen Arrays lesen. Nichts gebaut.")) +) + + +;; ============================================================ +;; Bau-Funktionen je Elementtyp +;; ============================================================ + +;; --- Kreisel: "connect" (Start-/Endpunkt) oder "insert" (Punkt+abstand) --- +;; Der Scanner liefert fuer AN8/SP8-Paare "connect": beide Stationen stehen im +;; DXF, abstand und rotation berechnet kreisel-connect-script daraus selbst +;; (abstand = Distanz - *kreisel-durchmesser*). "insert" bleibt fuer von Hand +;; gepflegte Eintraege moeglich (Schema wie mubea.json). +(defun hundm05:build-kreisel (e / id fn typ hoehe ptStart ptEnd x y z abstand rotation ent) + (setq id (ssg-val e "id") + fn (ssg-val e "function") + typ (ssg-val e "typ") + hoehe (ssg-val e "hoehe")) + (if (null typ) (setq typ "STANDARD")) + (if (numberp hoehe) (setq hoehe (float hoehe)) (setq hoehe nil)) + + (if (= fn "connect") + (progn + (setq ptStart (list (hundm05:num e "start_x" 0.0) + (hundm05:num e "start_y" 0.0) + (hundm05:num e "start_z" 0.0))) + (setq ptEnd (list (hundm05:num e "end_x" 0.0) + (hundm05:num e "end_y" 0.0) + (hundm05:num e "end_z" 0.0))) + (princ (strcat "\n [KREISEL] " id " connect (" + (rtos (car ptStart) 2 0) "," (rtos (cadr ptStart) 2 0) ") -> (" + (rtos (car ptEnd) 2 0) "," (rtos (cadr ptEnd) 2 0) ")" + " H=" (if hoehe (rtos hoehe 2 0) "-"))) + (setq ent (kreisel-connect-script ptStart ptEnd typ hoehe))) + (progn + (setq x (hundm05:num e "x" 0.0) + y (hundm05:num e "y" 0.0) + z (hundm05:num e "z" 0.0) + abstand (hundm05:num e "abstand" *kreisel-default-laenge*) + rotation (hundm05:num e "rotation" 0.0)) + (princ (strcat "\n [KREISEL] " id " insert (" (rtos x 2 0) "," (rtos y 2 0) + "," (rtos z 2 0) ") A=" (rtos abstand 2 0) + " R=" (rtos rotation 2 1))) + (setq ent (kreisel-insert-script (list x y z) abstand rotation typ + (if hoehe hoehe z))))) + (hundm05:ent-json id "kreisel" ent) +) + + +;; --- Vario (VF_*) --- +;; Wie mubea:build-vario-one, aber ohne "winkel"-Zwang und ohne L_GF1-Ziel: +;; hundm05.json gibt nur deltaL/deltaH/richtung/seite/hz vor, den Bogenwinkel +;; waehlt berechne-alle-winkel selbst (kleinster gueltiger Kandidat) und der +;; L_GF-Split ist "gleichmaessig" (beide Haelften L_GF/2, Default nach +;; object_data.md 3.1). Ist "L_GF1" doch gesetzt, wird es als Vorlauf-Ziel +;; verwendet - dann verhaelt sich der Treiber wie der Mubea-Treiber. +(defun hundm05:build-vario (e / tid typ richtung deltaL deltaH seite hz startpunkt + gf1-ziel ergebnis best-winkel L_GF L_VF gf1 gf2 + hoehe-von hoehe-bis lastEnt ent vf-nummer) + (setq tid (ssg-val e "test_id") + typ (ssg-val e "typ") + richtung (ssg-val e "richtung") + deltaL (hundm05:num e "deltaL" 0.0) + deltaH (hundm05:num e "deltaH" 0.0) + seite (ssg-val e "seite") + hz (hundm05:num e "hz" 0.0) + gf1-ziel (ssg-val e "L_GF1")) + (if (or (null typ) (= typ "")) (setq typ "standard")) + (if (or (null seite) (= seite "")) (setq seite "rechts")) + (if (or (null richtung) (= richtung "")) (setq richtung "Auf")) + (setq startpunkt (hundm05:startpunkt e "startpunkt")) + (if (null startpunkt) (hundm05:kein-startpunkt tid "startpunkt")) + + (princ (strcat "\n [VARIO] " tid " (" typ ") -> deltaL=" (rtos deltaL 2 0) + " deltaH=" (rtos deltaH 2 0) " " richtung + " hz=" (rtos hz 2 0))) + + (setq ergebnis + (if (= typ "etage") + (berechne-etage deltaL deltaH richtung seite) + (berechne-alle-winkel deltaL deltaH richtung nil))) + (setq best-winkel (car ergebnis) L_GF (cadr ergebnis) L_VF (caddr ergebnis)) + + ;; startpunkt MUSS mit in die Bedingung: ohne ihn wuerde am Ursprung gebaut. + (if (and startpunkt best-winkel L_GF L_VF (> L_GF 0) (> L_VF 0)) + (progn + ;; L_GF-Split: mit L_GF1-Vorgabe wie bei Mubea (Vorlauf-Ziel), sonst + ;; gleichmaessig auf Vor- und Nachlauf verteilt. + (if (numberp gf1-ziel) + (setq gf1 (min (float gf1-ziel) L_GF)) + (setq gf1 (/ L_GF 2.0))) + (setq gf2 (max 0.0 (- L_GF gf1))) + (princ (strcat " -> Winkel=" (itoa best-winkel) " L_GF=" (rtos L_GF 2 1) + " L_VF=" (rtos L_VF 2 1) " L_GF1=" (rtos gf1 2 1) + " L_GF2=" (rtos gf2 2 1))) + + ;; Absolute Hoehen eintragen. hoehe-von/hoehe-bis gehen NUR in Label und + ;; Attribute (MONTAGEHOEHE_m, HOEHE_VON_mm - vf_core.lsp), die Geometrie + ;; kommt aus startpunkt. test_mubea.lsp uebergibt hier 0 und traegt damit + ;; RELATIVE Hoehen ein; fuer diese Zeichnung stehen die echten Z-Werte im + ;; DXF, also werden sie auch verwendet - sonst exportiert EXPORTCSV eine + ;; falsche Montagehoehe (bei VF_HundM05_11 waeren es 2.914 m statt + ;; -1.028 m). + (setq hoehe-von (caddr startpunkt)) + (setq hoehe-bis (+ hoehe-von (if (= richtung "Auf") deltaH (- deltaH)))) + (setq lastEnt (vf-lastent-ohne-attribute)) + (if (= typ "etage") + (etage-foerderanlage-einfuegen deltaL deltaH richtung best-winkel gf1 gf2 L_VF startpunkt seite hz) + (variofoerderer-einfuegen deltaL deltaH richtung best-winkel gf1 gf2 L_VF startpunkt seite hz)) + ;; Blocknummer wie in der Produktion ueber vf-next-number holen (NICHT + ;; fest auf 1) - sonst trifft das _.BLOCK des zweiten Foerderers auf einen + ;; schon vorhandenen Namen, BricsCAD fragt "Neu definieren?" und + ;; verschluckt die feste Antwortfolge (siehe test_mubea.lsp). + (setq vf-nummer (vf-next-number)) + (setq ent (vf-block-erstellen typ seite vf-nummer 0 hoehe-von hoehe-bis + deltaH deltaL L_VF gf1 gf2 richtung best-winkel + startpunkt lastEnt hz "0" "Schoenenberger Geruest")) + (if (or (null ent) (/= (type ent) 'ENAME)) + (setq ent (hundm05:last-insert-prefix "VF_"))) + ;; SSG_VF_EDIT-XDATA schreiben, sonst verweigert VARIOFOERDERER_EDIT beim + ;; Doppelklick die Bearbeitung (vfs-edit-keine-xdata). + (if (and ent (= (type ent) 'ENAME) (member typ '("standard" "etage"))) + (vf-edit-xdata-schreiben ent typ hz)) + (hundm05:ent-json tid "vario" ent)) + (progn + ;; Ursache benennen, nicht pauschal "nicht loesbar" melden: ein fehlender + ;; Startpunkt hat nichts mit dem Winkel-Solver zu tun, und eine falsche + ;; Diagnose schickt die Fehlersuche in die falsche Richtung. + (if (null startpunkt) + (princ (strcat "\n FEHLER: " tid " nicht gebaut - Startpunkt fehlt" + " (siehe Meldung oben).")) + (princ (strcat "\n FEHLER: " tid " deltaL=" (rtos deltaL 2 0) + " deltaH=" (rtos deltaH 2 0) " nicht loesbar" + " (kein gueltiges L_GF/L_VF)."))) + (hundm05:result-json tid "vario" "failed" nil nil startpunkt nil))) +) + + +;; --- Gefaellestrecke: Init-Konflikt umgehen ----------------------------- +;; gefaellestrecke-einfuegen prueft "(if (not *lib-initialized*) +;; (gf-init-bibliothek))" - dasselbe Flag, das init-bibliothek (Vario) fuer +;; SEINE eigenen aus-dx/ein-dx setzt. Wird zuerst ein Vario gebaut, kaeme +;; gf-init-bibliothek nie mehr zum Zug und aus-dx/ein-dx blieben die +;; Vario-Werte. Darum einmal je Lauf erzwingen (wie in test_mubea.lsp). +(defun hundm05:ensure-gf-init ( / ) + (if (not *hundm05-gf-init-done*) + (progn + (setq *lib-initialized* nil) + (gf-init-bibliothek) + (setq *lib-initialized* t) + (setq *hundm05-gf-init-done* T))) +) + + +;; --- Eine Gefaellestrecke bauen (Modus 1) --- +;; Aufbau ausschliesslich aus Anfangspunkt und horizontaler Gesamtlaenge +;; deltaL - KEIN Endpunkt (wie Produktion/gefaellestrecke.dcl Modus 1, siehe +;; object_data.md 2.1). L_stau aus deltaL abgeleitet, Formel identisch zu +;; test_gefaellestrecke.lsp/test_mubea.lsp. +(defun hundm05:build-gf (e / tid start-mm deltaL hz as-seite es-seite as-winkel + es-winkel winkel) + (setq tid (ssg-val e "test_id") + start-mm (hundm05:startpunkt e "start_mm") + deltaL (hundm05:num e "deltaL_mm" 0.0) + hz (hundm05:num e "hz_grad" 0.0) + as-seite (ssg-val e "as_seite") + es-seite (ssg-val e "es_seite") + as-winkel(ssg-val e "as_winkel") + es-winkel(ssg-val e "es_winkel") + winkel (hundm05:num e "winkel_grad" 3.0)) + (if (or (null as-seite) (= as-seite "")) (setq as-seite "links")) + (if (or (null es-seite) (= es-seite "")) (setq es-seite "links")) + (if (or (null as-winkel) (= as-winkel "")) (setq as-winkel "90")) + (if (or (null es-winkel) (= es-winkel "")) (setq es-winkel "90")) + + (if (null start-mm) + (progn + (hundm05:kein-startpunkt tid "start_mm") + (hundm05:result-json tid "gefaellestrecke" "failed" nil nil nil nil)) + (hundm05:build-gf-punkt tid start-mm deltaL hz as-seite es-seite + as-winkel es-winkel winkel)) +) + + +;; --- Gefaellestrecke mit gepruefter Startkoordinate bauen --- +(defun hundm05:build-gf-punkt (tid start-mm deltaL hz as-seite es-seite + as-winkel es-winkel winkel / rad sep-x L_stau ent) + (hundm05:ensure-gf-init) + ;; Element-Masse zur gewaehlten AS/ES-Variante setzen, damit aus-dx/ein-dx + ;; zur Variante (90/30 Grad) passen; danach auf 90 zuruecksetzen. + (vf-set-as-masse as-winkel as-seite) + (vf-set-es-masse es-winkel es-seite) + (setq rad (* winkel (/ pi 180.0))) + (setq sep-x (* 300.0 (cos rad))) + (setq L_stau (/ (- deltaL (float aus-dx) (float ein-dx) sep-x) (cos rad))) + + (princ (strcat "\n [GF] " tid " -> Start=(" (rtos (car start-mm) 2 0) + "," (rtos (cadr start-mm) 2 0) "," (rtos (caddr start-mm) 2 0) + ") hz=" (rtos hz 2 0) " deltaL=" (rtos deltaL 2 0) + " winkel=" (rtos winkel 2 2) " L_stau=" (rtos L_stau 2 1))) + + (if (<= L_stau 0.0) + (progn + (princ (strcat " -> uebersprungen: L_stau <= 0 (deltaL " (rtos deltaL 2 0) + " zu kurz fuer AS/ES-Elemente + Separator)")) + (vf-set-as-masse "90" as-seite) + (vf-set-es-masse "90" es-seite) + (hundm05:result-json tid "gefaellestrecke" "failed" nil nil start-mm nil)) + (progn + (setq ent (car (gefaellestrecke-einfuegen + L_stau winkel start-mm as-seite es-seite hz + deltaL as-winkel es-winkel "0" "Schoenenberger Geruest"))) + (vf-set-as-masse "90" as-seite) + (vf-set-es-masse "90" es-seite) + (hundm05:ent-json tid "gefaellestrecke" ent))) +) + + +;; --- Waagrechte Foerderstrecke (GERADE_*) --- +;; Antrieb/Umlenk-Paare ohne Hoehendifferenz sind keine VarioFoerderer (deren +;; Winkel-Solver braucht eine Hoehendifferenz, siehe GERADE_MAX_DELTA_H in +;; lib/dxf_scan_components.py). Gebaut wird mit derselben Produktions-Primitive, +;; die der Etage-Foerderer fuer seine waagrechten Abschnitte nutzt: +;; insert-hz-scaled-block-by-ks skaliert Staustrecke_SP_1000_mm auf die Laenge +;; und richtet sie an hz aus (vert-winkel 0 = waagrecht). +;; Rueckgabe der Primitive ist der Ausgangspunkt, nicht die ENAME - der Block +;; wird darum ueber (entlast) eingesammelt. +(defun hundm05:build-gerade (e / tid laenge hz startpunkt blk ent) + (setq tid (ssg-val e "test_id") + laenge (hundm05:num e "laenge" 0.0) + hz (hundm05:num e "hz" 0.0) + startpunkt (hundm05:startpunkt e "startpunkt")) + (if (null startpunkt) + (hundm05:kein-startpunkt tid "startpunkt") + (princ (strcat "\n [GERADE] " tid " -> Start=(" (rtos (car startpunkt) 2 0) + "," (rtos (cadr startpunkt) 2 0) "," (rtos (caddr startpunkt) 2 0) + ") laenge=" (rtos laenge 2 0) " hz=" (rtos hz 2 0)))) + (if (or (null startpunkt) (<= laenge 0.0)) + (progn + (if startpunkt (princ " -> uebersprungen: laenge <= 0")) + (hundm05:result-json tid "gerade" "failed" nil nil startpunkt nil)) + (progn + ;; Block VORHER laden und pruefen: insert-hz-scaled-block-by-ks ruft bei + ;; fehlendem Block (exit) auf und wuerde den gesamten Testlauf abbrechen. + (setq blk (ssg-ils-block-laden "Staustrecke_SP_1000_mm")) + (if (null blk) + (progn + (princ " -> uebersprungen: Block Staustrecke_SP_1000_mm nicht ladbar") + (hundm05:result-json tid "gerade" "failed" "Staustrecke_SP_1000_mm" + nil startpunkt nil)) + (progn + (insert-hz-scaled-block-by-ks "Staustrecke_SP_1000_mm" + startpunkt laenge hz 0) + (setq ent (entlast)) + (if (and ent (atoms-family 1 '("ssg-id-generate"))) + (ssg-id-generate ent)) + (hundm05:ent-json tid "gerade" ent))))) +) + + +;; --- Omniflo (Eintraege mit "type": "bogen"/"weiche") --- +;; BEWUSST nicht gebaut. omni:insert-dxf (Lisp/OmniModulInsert.lsp) holt den +;; Einfuegepunkt zwingend per (command "_.INSERT" dxf-pfad pause ...) - es gibt +;; in Produktion keinen Aufrufpfad, der X/Y programmgesteuert uebergibt (siehe +;; object_data.md 4.1). Ein JSON-Treiber muesste den _.INSERT selbst nachbauen. +;; Im gewaehlten Abschnitt (Kreisel-Bereich) kommt kein einziger Omniflo-Eintrag +;; vor, darum wird hier nichts auf Verdacht implementiert, was der Testlauf nie +;; ausfuehren koennte. Der eigene Zweig ist trotzdem noetig: Omniflo-Eintraege +;; tragen ein "id"-Feld und wuerden sonst als Kreisel gebaut werden. +(defun hundm05:build-omniflo (e / tid) + (setq tid (cond ((ssg-val e "id")) ((ssg-val e "sivasnr")) ("OMNIFLO"))) + (princ (strcat "\n [OMNIFLO] " tid " -> uebersprungen (nicht scriptbar, " + "siehe object_data.md 4.1)")) + (hundm05:result-json tid "omniflo" "unsupported" + (ssg-val e "sivasnr") nil + (list (hundm05:num e "x" 0.0) (hundm05:num e "y" 0.0) + (hundm05:num e "z" 0.0)) + nil) +) + + +;; --- Ein einzelner Sensor/Block: Blockdefinition laden, dann per Name einfuegen --- +;; Wie mubea:build-separator-one. Der in JSON genannte Block wird ueber +;; ssg-ils-block-laden vorab definiert (loest 2D/3D-Suffix auf); schlaegt das +;; fehl, wird auf Separator_SP zurueckgefallen - genau das greift bei den +;; Blocknamen "S-LP"/"S-SP", die es in data/ils/ nicht gibt. +(defun hundm05:build-sensor (e idx / blockname x y z rot tid eff n pt) + (setq blockname (ssg-val e "block") + x (hundm05:num e "x" 0.0) + y (hundm05:num e "y" 0.0) + z (hundm05:num e "z" 0.0) + rot (hundm05:num e "rotation" 0.0) + tid (cond ((ssg-val e "test_id")) + ((strcat "SENSOR_" (itoa idx))))) + (setq pt (list x y z)) + (princ (strcat "\n [SENSOR] " tid " '" (if blockname blockname "?") + "' -> (" (rtos x 2 0) "," (rtos y 2 0) "," (rtos z 2 0) + ") R=" (rtos rot 2 0))) + ;; NICHT (and blockname (ssg-ils-block-laden ...)) verwenden: AutoLISPs AND + ;; liefert T statt des letzten Wertes, das _.INSERT wuerde dann einen Block + ;; namens T einfuegen (siehe Kommentar in test_mubea.lsp). + (setq eff (if blockname (ssg-ils-block-laden blockname))) + (if (null eff) (setq eff (ssg-ils-block-laden "Separator_SP"))) + (if eff + (progn + (command "_.INSERT" eff pt "" "" rot) + ;; Verbleibende Prompts GEZAEHLT mit Enter abfangen - ein Prompt, der die + ;; Leer-Antwort ablehnt, haelt CMDACTIVE dauerhaft > 0 und eine ungebremste + ;; Schleife wuerde BricsCAD haengen lassen. + (setq n 0) + (while (and (> (getvar "CMDACTIVE") 0) (< n 5)) + (command "") + (setq n (1+ n))) + (if (> (getvar "CMDACTIVE") 0) + (progn + (command) + (princ (strcat "\n WARNUNG: INSERT von '" eff + "' nicht sauber beendet - abgebrochen.")))) + (if (atoms-family 1 '("ssg-id-generate")) + (ssg-id-generate (entlast))) + (hundm05:ent-json tid "sensor" (entlast))) + (progn + ;; Kein Block gefunden -> sichtbaren Platzhalter zeichnen + (if (not (tblsearch "LAYER" "HUNDM05_SENSOR")) + (entmake (list '(0 . "LAYER") '(2 . "HUNDM05_SENSOR") '(70 . 0) '(62 . 1)))) + (entmake (list '(0 . "CIRCLE") (cons 10 pt) (cons 40 150.0) + '(8 . "HUNDM05_SENSOR"))) + (princ " (Block fehlt - Platzhalter)") + (hundm05:result-json tid "sensor" "placeholder" + (if blockname blockname "Separator_SP") nil pt nil))) +) + + +;; --- JSON-Export der Ergebnisse --- +(defun hundm05:export-results (tests-out-dir / out-json f first) + (if (null *hundm05-test-results*) + (princ "\n Keine HundM05-Ergebnisse vorhanden.") + (progn + (vl-mkdir tests-out-dir) + (setq out-json (strcat tests-out-dir "/hundm05_results.json")) + (setq f (open out-json "w")) + (if f + (progn + (write-line "[" f) + (setq first T) + (foreach r *hundm05-test-results* + (if (not first) (write-line "," f)) + (write-line r f) + (setq first nil)) + (write-line "]" f) + (close f) + (princ (strcat "\n Ergebnisse: " out-json))) + (princ (strcat "\n FEHLER: Kann " out-json " nicht schreiben!"))))) +) + + +;; ============================================================ +;; C:TEST_HUNDM05 - Hauptbefehl +;; ============================================================ +(defun c:TEST_HUNDM05 ( / json-datei daten eintrag tid res r + results-list sensor-idx anz-ok anz-fehler) + (dbgopen "hundm05_id.dbg" "DXFM_LOG") + + ;; Benoetigte Feature-Module laden + (ssg-ensure "KreiselInsert") + (ssg-ensure "VarioFoerderer") + (ssg-ensure "Gefaellestrecke") + (ssg-ensure "SSG_LIB_Commands") + + (ssg-start "TEST_HUNDM05" '(("OSMODE") ("CECOLOR") ("ATTREQ") ("ATTDIA"))) + (setvar "OSMODE" 0) + (setvar "ATTREQ" 0) + (setvar "ATTDIA" 0) + + ;; Block-Bibliothek (Bogen-/AS-/ES-Masse) initialisieren - fuer Vario+GF noetig + (if (or (not *lib-initialized*) (null bogen-auf)) + (init-bibliothek)) + + ;; Cache fuer den GF-Init-Workaround pro Lauf zuruecksetzen + (setq *hundm05-gf-init-done* nil) + + ;; Testdaten laden + (setq json-datei (strcat (getenv "DXFMAKRO") "/tests/testdata/hundm05.json")) + (if (not (findfile json-datei)) + (progn + (princ (strcat "\n[TEST_HUNDM05] FEHLER: " json-datei " nicht gefunden!")) + (ssg-end) + (dbgclose) + (exit))) + (setq daten (ssg-load-json json-datei)) + (if (null daten) + (progn + (princ "\n[TEST_HUNDM05] FEHLER: Testdaten konnten nicht geladen werden.") + (ssg-end) + (dbgclose) + (exit))) + + (princ "\n\n================================================================") + (princ "\n TEST_HUNDM05 - HundM Kreisel-Abschnitt (ST500592_05.dxf)") + (princ (strcat "\n " (itoa (length daten)) " Eintraege geladen")) + (princ "\n================================================================") + + (setq results-list '() sensor-idx 0 anz-ok 0 anz-fehler 0) + + (foreach eintrag daten + (setq tid (ssg-val eintrag "test_id")) + (setq res + (cond + ;; Sensor/Insert: hat "block" + ((ssg-val eintrag "block") + (setq sensor-idx (1+ sensor-idx)) + (hundm05:build-sensor eintrag sensor-idx)) + ;; Omniflo VOR Kreisel pruefen: diese Eintraege tragen "id" UND "type" + ((ssg-val eintrag "type") + (hundm05:build-omniflo eintrag)) + ;; Kreisel: hat "id" (ohne "type") + ((ssg-val eintrag "id") + (hundm05:build-kreisel eintrag)) + ;; Vario: test_id "VF_*" + ((and tid (>= (strlen tid) 3) (= (substr tid 1 3) "VF_")) + (hundm05:build-vario eintrag)) + ;; Gefaellestrecke: test_id "GF_*" + ((and tid (>= (strlen tid) 3) (= (substr tid 1 3) "GF_")) + (hundm05:build-gf eintrag)) + ;; Waagrechte Strecke: test_id "GERADE_*" + ((and tid (>= (strlen tid) 7) (= (substr tid 1 7) "GERADE_")) + (hundm05:build-gerade eintrag)) + (T + (princ "\n WARNUNG: Unbekannter Eintrag uebersprungen") + nil))) + ;; Alle Builder liefern hier je einen String (keine Templates mit "anzahl" + ;; wie bei Mubea) - der Vollstaendigkeit halber trotzdem Listen zulassen. + (if res + (progn + (if (= (type res) 'STR) (setq res (list res))) + (foreach r res + (setq results-list (cons r results-list)) + (if (or (vl-string-search "\"executed\"" r) + (vl-string-search "\"placeholder\"" r) + (vl-string-search "\"unsupported\"" r)) + (setq anz-ok (1+ anz-ok)) + (setq anz-fehler (1+ anz-fehler))))))) + + (princ "\n================================================================") + (princ (strcat "\n Ergebnis: " (itoa anz-ok) " OK, " (itoa anz-fehler) " Fehler")) + (princ "\n================================================================") + + (setq *hundm05-test-results* (reverse results-list)) + + (ssg-end) + (princ "\n TEST_HUNDM05 abgeschlossen.") + (princ) + (dbgclose) +) diff --git a/tests/test_hundm05.py b/tests/test_hundm05.py new file mode 100644 index 0000000..edacfcb --- /dev/null +++ b/tests/test_hundm05.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_hundm05.py - Validiert den HundM-Kreisel-Abschnitt. + +Prueft die von TEST_HUNDM05 (test_hundm05.lsp) erzeugten Ergebnisse: + 1. Alle Eintraege aus hundm05.json wurden verarbeitet (results.json) + 2. Statuswerte (executed / placeholder fuer Sensoren / unsupported Omniflo) + 3. Kreisel-Attribute (HOEHE, KREISELART, Block-Praefix) + 4. Vario: VF_-Block, L_GF gleichmaessig verteilt (kein L_GF1 in den Daten) + 5. DXF-Geometrie: Block-Anzahlen je Typ (ezdxf) + +Die Testdaten stammen aus lib/dxf_scan_components.py (Fenster +216000 144000 228000 156000 der Zeichnung results/ST500592_05.dxf) und nicht +aus Handarbeit - anders als bei mubea.json. +""" + +import pytest + + +# ============================================================ +# Klassifikation der hundm05.json-Eintraege +# ============================================================ +# Reihenfolge wie im Dispatcher von test_hundm05.lsp: Omniflo VOR Kreisel, +# weil Omniflo-Eintraege sowohl "id" als auch "type" tragen. + +def _is_sensor(item): + return "block" in item + + +def _is_omniflo(item): + return "type" in item and "block" not in item + + +def _is_kreisel(item): + return "id" in item and "type" not in item and "block" not in item + + +def _hat_prefix(item, prefix): + return str(item.get("test_id", "")).startswith(prefix) + + +def _is_vario(item): + return _hat_prefix(item, "VF_") + + +def _is_gefaelle(item): + return _hat_prefix(item, "GF_") + + +def _is_gerade(item): + return _hat_prefix(item, "GERADE_") + + +def _expected_counts(testdata): + return { + "kreisel": sum(1 for t in testdata if _is_kreisel(t)), + "vario": sum(1 for t in testdata if _is_vario(t)), + "gefaellestrecke": sum(1 for t in testdata if _is_gefaelle(t)), + "gerade": sum(1 for t in testdata if _is_gerade(t)), + "sensor": sum(1 for t in testdata if _is_sensor(t)), + "omniflo": sum(1 for t in testdata if _is_omniflo(t)), + } + + +# ============================================================ +# Testdaten-Pruefung (hundm05.json selbst) +# ============================================================ + +class TestHundM05Testdaten: + + def test_jeder_eintrag_klassifizierbar(self, hundm05_testdata): + """Kein Eintrag darf durch alle Zweige des Dispatchers fallen.""" + for i, t in enumerate(hundm05_testdata): + erkannt = (_is_sensor(t) or _is_omniflo(t) or _is_kreisel(t) + or _is_vario(t) or _is_gefaelle(t) or _is_gerade(t)) + assert erkannt, \ + f"Eintrag {i} ist keinem Typ zuzuordnen: {sorted(t)}" + + def test_kreisel_vorhanden(self, hundm05_testdata): + """Der Abschnitt wurde wegen des Kreisels gewaehlt - er muss drin sein.""" + kreisel = [t for t in hundm05_testdata if _is_kreisel(t)] + assert kreisel, "kein Kreisel in hundm05.json" + + def test_vario_hat_hoehendifferenz(self, hundm05_testdata): + """VarioFoerderer brauchen deltaH > 0 - der Winkel-Solver findet sonst + keinen Bogenwinkel. Waagrechte Paare gehoeren als 'gerade' ins JSON + (siehe GERADE_MAX_DELTA_H in lib/dxf_scan_components.py).""" + for t in hundm05_testdata: + if _is_vario(t): + assert float(t.get("deltaH", 0)) > 0, \ + f'{t["test_id"]}: deltaH={t.get("deltaH")} - gehoert als ' \ + f'"gerade" klassifiziert' + + def test_gerade_hat_laenge(self, hundm05_testdata): + for t in hundm05_testdata: + if _is_gerade(t): + assert float(t.get("laenge", 0)) > 0, \ + f'{t["test_id"]}: laenge={t.get("laenge")}' + + def test_arrays_einzeilig(self): + """Jedes Array muss komplett in EINER Zeile stehen. + + ssg-parse-json-array (Lisp/ssg_core.lsp) liest die Datei zeilenweise, + und ssg-cfg-parse-array braucht das ganze Array in einem String. Ein + von json.dump(indent=2) umgebrochenes Array wird zu nil - der Treiber + baut das Element dann nicht an seiner Position. Das ist genau der + Fehler, der die Anlage einmal komplett am Ursprung aufgebaut hat, und + er ist in der Zeichnung nicht als Datenfehler zu erkennen. + """ + import os + pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "testdata", "hundm05.json") + with open(pfad, encoding="utf-8") as fh: + for nr, zeile in enumerate(fh, start=1): + offen = zeile.count("[") - zeile.count("]") + # Die aeusserste Klammer des Dokuments darf allein stehen. + if zeile.strip() in ("[", "]"): + continue + assert offen == 0, \ + f"Zeile {nr}: Array ueber mehrere Zeilen umgebrochen - " \ + f"ssg-cfg-parse-array kann das nicht lesen: {zeile.strip()!r}" + + +# ============================================================ +# Ergebnis-Pruefung (results.json vs. testdata) +# ============================================================ + +class TestHundM05Results: + + def test_alle_verarbeitet(self, hundm05_testdata, hundm05_results): + """Je Kind muss die erwartete Anzahl an Ergebnissen vorliegen.""" + expected = _expected_counts(hundm05_testdata) + got = {} + for r in hundm05_results: + got[r["kind"]] = got.get(r["kind"], 0) + 1 + for kind, n in expected.items(): + assert got.get(kind, 0) == n, \ + f'{kind}: erwartet {n}, verarbeitet {got.get(kind, 0)}' + + def test_status_ok(self, hundm05_results): + """Sensoren duerfen 'placeholder', Omniflo ist 'unsupported' + (omni:insert-dxf ist nicht scriptbar, siehe object_data.md 4.1), + alles andere muss 'executed' sein.""" + for r in hundm05_results: + if r["kind"] == "sensor": + erlaubt = ("executed", "placeholder") + elif r["kind"] == "omniflo": + erlaubt = ("unsupported",) + else: + erlaubt = ("executed",) + assert r["status"] in erlaubt, \ + f'{r["test_id"]} ({r["kind"]}): Status={r["status"]}, ' \ + f'erlaubt {erlaubt}' + + def test_kreisel_attribute(self, hundm05_testdata, hundm05_results): + """Kreisel: KREISELART und HOEHE stimmen mit der Vorgabe, Blockname + traegt das Praefix KREISEL_. HOEHE wird von kreisel-connect-script aus + 'hoehe' bzw. dem Z des Startpunkts gebildet (rtos ... 2 0, also + ganzzahlig).""" + by_id = {r["test_id"]: r for r in hundm05_results} + for t in hundm05_testdata: + if not _is_kreisel(t): + continue + r = by_id.get(t["id"]) + assert r, f'Kreisel {t["id"]} fehlt im Ergebnis' + assert r["block_name"].startswith("KREISEL_"), \ + f'{t["id"]}: Block "{r["block_name"]}" ohne Praefix KREISEL_' + assert r["actual_attributes"].get("KREISELART") == \ + t.get("typ", "STANDARD"), \ + f'{t["id"]}: KREISELART ist=' \ + f'{r["actual_attributes"].get("KREISELART")}' + soll = t.get("hoehe", t.get("start_z", 0.0)) + assert r["actual_attributes"].get("HOEHE") == str(int(round(soll))), \ + f'{t["id"]}: HOEHE ist={r["actual_attributes"].get("HOEHE")}, ' \ + f'erwartet={int(round(soll))}' + + def test_kreisel_abstand(self, hundm05_testdata, hundm05_results): + """Bei 'connect' berechnet kreisel-connect-script den Abstand als + Distanz(Start,Ende) - Kreiseldurchmesser (800mm). Der Block muss also + auf dem Startpunkt sitzen.""" + by_id = {r["test_id"]: r for r in hundm05_results} + for t in hundm05_testdata: + if not _is_kreisel(t) or t.get("function") != "connect": + continue + r = by_id.get(t["id"]) + assert r, f'Kreisel {t["id"]} fehlt im Ergebnis' + ip = r["insert_point"] + assert abs(ip[0] - t["start_x"]) < 1.0, \ + f'{t["id"]}: Einfuegepunkt X {ip[0]} != start_x {t["start_x"]}' + assert abs(ip[1] - t["start_y"]) < 1.0, \ + f'{t["id"]}: Einfuegepunkt Y {ip[1]} != start_y {t["start_y"]}' + + def test_vario_block_und_split(self, hundm05_testdata, hundm05_results): + """Vario: VF_-Block, und ohne L_GF1-Vorgabe wird L_GF gleichmaessig auf + Vor- und Nachlauf verteilt (Default nach object_data.md 3.1).""" + by_id = {r["test_id"]: r for r in hundm05_results} + for t in hundm05_testdata: + if not _is_vario(t): + continue + r = by_id.get(t["test_id"]) + assert r, f'Vario {t["test_id"]} fehlt im Ergebnis' + assert r["block_name"].startswith("VF_"), \ + f'{t["test_id"]}: Block "{r["block_name"]}" ist kein VF_-Block' + attrs = r["actual_attributes"] + if "L_GF1" in t or "L_GF_m" not in attrs: + continue + teile = attrs["L_GF_m"].split(",") + assert len(teile) == 2, \ + f'{t["test_id"]}: L_GF_m Format "{attrs["L_GF_m"]}"' + vorne, hinten = float(teile[0]), float(teile[1]) + assert abs(vorne - hinten) < 0.002, \ + f'{t["test_id"]}: L_GF nicht gleichmaessig verteilt ' \ + f'({vorne} / {hinten})' + + def test_gefaelle_block(self, hundm05_testdata, hundm05_results): + by_id = {r["test_id"]: r for r in hundm05_results} + for t in hundm05_testdata: + if not _is_gefaelle(t): + continue + r = by_id.get(t["test_id"]) + assert r, f'Gefaellestrecke {t["test_id"]} fehlt im Ergebnis' + assert r["block_name"].startswith("GF_"), \ + f'{t["test_id"]}: Block "{r["block_name"]}" ist kein GF_-Block' + + +# ============================================================ +# DXF Geometrie-Pruefung (ezdxf) +# ============================================================ + +class TestHundM05Geometry: + + def _counts(self, dxf): + c = {} + for e in dxf.modelspace(): + if e.dxftype() == "INSERT": + c[e.dxf.name] = c.get(e.dxf.name, 0) + 1 + return c + + def test_block_anzahlen(self, hundm05_dxf, hundm05_testdata): + """Anzahl der KREISEL_/VF_/GF_-Bloecke >= Vorgabe aus hundm05.json.""" + c = self._counts(hundm05_dxf) + exp = _expected_counts(hundm05_testdata) + n_kr = sum(v for n, v in c.items() if n.startswith("KREISEL_")) + n_vf = sum(v for n, v in c.items() if n.startswith("VF_")) + n_gf = sum(v for n, v in c.items() if n.startswith("GF_")) + assert n_kr >= exp["kreisel"], f"KREISEL_: {n_kr} < {exp['kreisel']}" + assert n_vf >= exp["vario"], f"VF_: {n_vf} < {exp['vario']}" + assert n_gf >= exp["gefaellestrecke"], \ + f"GF_: {n_gf} < {exp['gefaellestrecke']}" + + def test_handles_im_modelspace(self, hundm05_dxf, hundm05_results): + """Jedes gebaute Block-Handle (executed) liegt im Modelspace.""" + handles = {e.dxf.handle for e in hundm05_dxf.modelspace() + if e.dxftype() == "INSERT"} + for r in hundm05_results: + if r["status"] == "executed" and r["block_handle"]: + assert r["block_handle"] in handles, \ + f'{r["test_id"]}: Handle {r["block_handle"]} nicht im ' \ + f'Modelspace' diff --git a/tests/testdata/hundm05.json b/tests/testdata/hundm05.json new file mode 100644 index 0000000..b8edcb0 --- /dev/null +++ b/tests/testdata/hundm05.json @@ -0,0 +1,573 @@ +[ + { + "id": "Kreisel_HundM05_01", + "function": "connect", + "start_x": 216120.19, + "start_y": 151070.0, + "start_z": 0.0, + "end_x": 239701.9, + "end_y": 151070.0, + "end_z": 0.0, + "typ": "STANDARD", + "hoehe": 0.0, + "comment": "aus DXF: AN8 (Layer 1ANTR-800) -> SP8 (Layer 1SP-800), Abstand 23581.71; daraus folgt produktionsseitig abstand = 23581.71 - 800.0 = 22781.71 und rotation = 0.00 Grad. ACHTUNG hoehe: AN8 und SP8 sind in dieser Zeichnung flache 2D-Symbole (nur Linien/Kreis/Text auf Z=0) und auf Z=0 eingefuegt - die Einfuegehoehe des Kreisels steht NICHT im DXF und muss hier von Hand gesetzt werden (vgl. mubea.json, wo z ebenfalls Handarbeit ist)." + }, + { + "test_id": "GF_HundM05_04", + "modus": 1, + "deltaL_mm": 2094.45, + "winkel_grad": 48.21, + "hz_grad": 125.48, + "start_mm": [219540.85, 152832.91, 2342.96], + "as_seite": "links", + "es_seite": "links", + "as_winkel": "90", + "es_winkel": "90", + "comment": "aus DXF: 3D_FF_Antrieb_rechts -> Umlenkung FF (Layer _3D_Fingerförderer / z-3), XY-Abstand 2094.45, Neigung 48.21 Grad; Gefaellestrecke, da fallend und Neigung >= 3 Grad. as_/es_seite und -winkel sind Defaults (im DXF nicht ableitbar)." + }, + { + "test_id": "VF_HundM05_05", + "typ": "standard", + "richtung": "Auf", + "deltaL": 1694.19, + "deltaH": 2057.58, + "startpunkt": [219542.27, 153175.18, 0.0], + "seite": "rechts", + "hz": 135.86, + "comment": "aus DXF: Antrieb FF -> 3D_FF_Umlenk_rechts (Layer z-3 / _3D_Fingerförderer), XY-Abstand 1694.19, Neigung 50.53 Grad; VarioFoerderer. 'winkel' bewusst nicht gesetzt - der Solver waehlt den kleinsten gueltigen Kandidaten. 'seite' ist Default (im DXF nicht ableitbar)." + }, + { + "test_id": "GERADE_HundM05_08", + "typ": "gerade", + "laenge": 3305.26, + "hz": 143.35, + "startpunkt": [223150.89, 152953.65, 0.0], + "endpunkt": [220499.01, 154926.53, 0.0], + "comment": "aus DXF: Antrieb FF -> Umlenkung FF (Layer z-3 / z-3), XY-Abstand 3305.26, Neigung 0.00 Grad; waagrechte Foerderstrecke (Hoehendifferenz 0.00 mm < 10 mm) - KEIN VarioFoerderer, da dessen Winkel-Solver eine Hoehendifferenz braucht." + }, + { + "test_id": "GF_HundM05_09", + "modus": 1, + "deltaL_mm": 3817.56, + "winkel_grad": 6.75, + "hz_grad": 107.51, + "start_mm": [223151.48, 152603.13, 2337.66], + "as_seite": "links", + "es_seite": "links", + "as_winkel": "90", + "es_winkel": "90", + "comment": "aus DXF: 3D_FF_Antrieb_rechts -> 3D_FF_Umlenk_rechts (Layer _3D_Fingerförderer / _3D_Fingerförderer), XY-Abstand 3817.56, Neigung 6.75 Grad; Gefaellestrecke, da fallend und Neigung >= 3 Grad. as_/es_seite und -winkel sind Defaults (im DXF nicht ableitbar)." + }, + { + "test_id": "GERADE_HundM05_10", + "typ": "gerade", + "laenge": 11940.15, + "hz": 33.61, + "startpunkt": [225207.36, 149949.84, 0.0], + "endpunkt": [235151.11, 156559.61, -3.45], + "comment": "aus DXF: Antrieb FF -> Umlenkung FF (Layer z-3 / z-3), XY-Abstand 11940.15, Neigung 0.02 Grad; waagrechte Foerderstrecke (Hoehendifferenz 3.45 mm < 10 mm) - KEIN VarioFoerderer, da dessen Winkel-Solver eine Hoehendifferenz braucht." + }, + { + "test_id": "VF_HundM05_11", + "typ": "standard", + "richtung": "Auf", + "deltaL": 11769.21, + "deltaH": 5827.59, + "startpunkt": [225207.48, 149949.9, -3941.61], + "seite": "rechts", + "hz": 32.34, + "comment": "aus DXF: Antrieb FF -> 3D_FF_Umlenk_rechts (Layer z-3 / _3D_Fingerförderer), XY-Abstand 11769.21, Neigung 26.34 Grad; VarioFoerderer. 'winkel' bewusst nicht gesetzt - der Solver waehlt den kleinsten gueltigen Kandidaten. 'seite' ist Default (im DXF nicht ableitbar)." + }, + { + "test_id": "VF_HundM05_12", + "typ": "standard", + "richtung": "Auf", + "deltaL": 3450.94, + "deltaH": 1885.98, + "startpunkt": [227298.92, 152942.18, 0.0], + "seite": "rechts", + "hz": 106.79, + "comment": "aus DXF: Antrieb FF -> 3D_FF_Umlenk_rechts (Layer z-3 / _3D_Fingerförderer), XY-Abstand 3450.94, Neigung 28.66 Grad; VarioFoerderer. 'winkel' bewusst nicht gesetzt - der Solver waehlt den kleinsten gueltigen Kandidaten. 'seite' ist Default (im DXF nicht ableitbar)." + }, + { + "test_id": "GF_HundM05_13", + "modus": 1, + "deltaL_mm": 4078.06, + "winkel_grad": 29.82, + "hz_grad": 104.2, + "start_mm": [227300.62, 152605.32, 2337.74], + "as_seite": "links", + "es_seite": "links", + "as_winkel": "90", + "es_winkel": "90", + "comment": "aus DXF: 3D_FF_Antrieb_rechts -> Umlenkung FF (Layer _3D_Fingerförderer / z-3), XY-Abstand 4078.06, Neigung 29.82 Grad; Gefaellestrecke, da fallend und Neigung >= 3 Grad. as_/es_seite und -winkel sind Defaults (im DXF nicht ableitbar)." + }, + { + "function": "insert", + "block": "Vario_Motorstation_500mm_rechts", + "x": 224346.52, + "y": 149549.9, + "z": 21677.04, + "rotation": 90.0, + "comment": "aus DXF: Block 'Antrieb FF', Layer 'z-3', auf Makro-Block 'Vario_Motorstation_500mm_rechts' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Motorstation_500mm_rechts", + "x": 223998.19, + "y": 149549.84, + "z": 2384.8, + "rotation": 180.0, + "comment": "aus DXF: Block '3D_FF_Antrieb_rechts', Layer '_3D_Fingerförderer', auf Makro-Block 'Vario_Motorstation_500mm_rechts' abgebildet" + }, + { + "function": "insert", + "block": "Vario_Motorstation_500mm_rechts", + "x": 224346.4, + "y": 149549.84, + "z": 0.0, + "rotation": 90.0, + "comment": "aus DXF: Block 'Antrieb FF', Layer 'z-3', auf Makro-Block 'Vario_Motorstation_500mm_rechts' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Kurve_rechts_30_TEF_aussen", + "x": 219480.78, + "y": 153174.98, + "z": 2230.86, + "rotation": 270.0, + "comment": "aus DXF: Block '3D_FF_Bogen_außen_30°', Layer '_3D_Fingerförderer', auf Makro-Block 'Vario_Kurve_rechts_30_TEF_aussen' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Kurve_rechts_30_TEF_innen", + "x": 220438.78, + "y": 154981.88, + "z": 1726.02, + "rotation": 90.0, + "comment": "aus DXF: Block '3D_FF_Bogen_innen_30°', Layer '_3D_Fingerförderer', auf Makro-Block 'Vario_Kurve_rechts_30_TEF_innen' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Kurve_rechts_30_TEF_aussen", + "x": 219535.71, + "y": 153172.45, + "z": 2349.22, + "rotation": 270.0, + "comment": "aus DXF: Block '3D_ILS_Bogen_30°', Layer '_3D_ILS_Bogen_30°', auf Makro-Block 'Vario_Kurve_rechts_30_TEF_aussen' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Lage=aussen, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Kurve_rechts_30_TEF_aussen", + "x": 220500.01, + "y": 154973.55, + "z": 1850.0, + "rotation": 90.0, + "comment": "aus DXF: Block '3D_ILS_Bogen_30°', Layer '_3D_ILS_Bogen_30°', auf Makro-Block 'Vario_Kurve_rechts_30_TEF_aussen' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Lage=aussen, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Kurve_rechts_30_TEF_aussen", + "x": 222776.13, + "y": 149549.84, + "z": 2335.65, + "rotation": 0.0, + "comment": "aus DXF: Block '3D_ILS_Bogen_30°', Layer '_3D_ILS_Bogen_30°', auf Makro-Block 'Vario_Kurve_rechts_30_TEF_aussen' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Lage=aussen, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "Vario_Kurve_rechts_30_TEF_aussen", + "x": 223249.77, + "y": 149949.81, + "z": 2360.47, + "rotation": 0.0, + "comment": "aus DXF: Block '3D_ILS_Bogen_30°', Layer '_3D_ILS_Bogen_30°', auf Makro-Block 'Vario_Kurve_rechts_30_TEF_aussen' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Lage=aussen, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "AS_Element_90_rechts", + "x": 217765.98, + "y": 151472.13, + "z": 2099.94, + "rotation": 0.0, + "comment": "aus DXF: Block 'Ausschleuselement_V3', Layer '_3D_ILS_AE', auf Makro-Block 'AS_Element_90_rechts' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Winkel=90, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "AS_Element_90_rechts", + "x": 221440.62, + "y": 151470.0, + "z": 2067.0, + "rotation": 0.0, + "comment": "aus DXF: Block 'Ausschleuselement_V3', Layer '_3D_ILS_AE', auf Makro-Block 'AS_Element_90_rechts' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Winkel=90, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "AS_Element_90_rechts", + "x": 225739.77, + "y": 151470.0, + "z": 2067.0, + "rotation": 0.0, + "comment": "aus DXF: Block 'Ausschleuselement_V3', Layer '_3D_ILS_AE', auf Makro-Block 'AS_Element_90_rechts' abgebildet; ACHTUNG geraten (im DXF nicht ableitbar): Winkel=90, Seite=rechts - bei Bedarf hier im JSON korrigieren" + }, + { + "function": "insert", + "block": "S-LP", + "x": 216525.22, + "y": 150670.0, + "z": 0.0, + "rotation": 0.0, + "comment": "aus DXF: Block 'S-LP', Layer '5SEP-LP'; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 217622.88, + "y": 151470.0, + "z": 0.0, + "rotation": 180.0, + "comment": "aus DXF: Block 'S-LP', Layer '5SEP-LP'; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 221298.51, + "y": 151470.0, + "z": 0.0, + "rotation": 180.0, + "comment": "aus DXF: Block 'S-LP', Layer '5SEP-LP'; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 224908.15, + "y": 150670.0, + "z": 0.0, + "rotation": 0.0, + "comment": "aus DXF: Block 'S-LP', Layer '5SEP-LP'; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 225508.15, + "y": 150670.0, + "z": 0.0, + "rotation": 0.0, + "comment": "aus DXF: Block 'S-LP', Layer '5SEP-LP'; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 225597.66, + "y": 151470.0, + "z": 0.0, + "rotation": 180.0, + "comment": "aus DXF: Block 'S-LP', Layer '5SEP-LP'; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 218325.21, + "y": 154009.54, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 219542.38, + "y": 151961.76, + "z": 0.0, + "rotation": 90.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 220498.95, + "y": 155645.73, + "z": 0.09, + "rotation": 90.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 220498.95, + "y": 155953.39, + "z": 0.09, + "rotation": 90.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 222000.85, + "y": 155698.7, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 222153.05, + "y": 149875.67, + "z": 0.0, + "rotation": 330.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 223150.84, + "y": 151972.72, + "z": -7.54, + "rotation": 90.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 223617.86, + "y": 149949.84, + "z": 0.0, + "rotation": 0.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 226300.0, + "y": 155718.69, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-SP", + "x": 227300.01, + "y": 151981.61, + "z": -7.54, + "rotation": 90.0, + "comment": "aus DXF: Block 'S-SP', Layer '5SEP-SP'; Block 'S-SP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "Scanner", + "x": 217622.88, + "y": 151190.0, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Scanner", + "x": 220218.95, + "y": 155609.84, + "z": 0.09, + "rotation": 180.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Scanner", + "x": 221298.51, + "y": 151190.0, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Scanner", + "x": 222013.05, + "y": 149633.18, + "z": 0.0, + "rotation": 240.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Scanner", + "x": 223617.86, + "y": 149669.84, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Scanner", + "x": 224882.1, + "y": 150392.28, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Scanner", + "x": 225597.66, + "y": 151190.0, + "z": 0.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'SCAN', Layer 'ZZSCANNER', auf Makro-Block 'Scanner' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 218326.39, + "y": 153912.84, + "z": 2066.06, + "rotation": 0.0, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 219544.61, + "y": 152086.0, + "z": 2318.68, + "rotation": 180.29, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 220500.0, + "y": 155763.99, + "z": 1876.16, + "rotation": 180.0, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 222001.54, + "y": 155600.71, + "z": 1923.14, + "rotation": 359.97, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 222001.69, + "y": 155900.21, + "z": 1905.78, + "rotation": 359.97, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 222201.22, + "y": 149847.53, + "z": 2318.04, + "rotation": 60.49, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 223149.87, + "y": 152086.02, + "z": 2318.68, + "rotation": 180.29, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 223747.95, + "y": 149950.46, + "z": 2386.66, + "rotation": 90.81, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 226300.69, + "y": 155600.71, + "z": 1923.14, + "rotation": 359.97, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 226300.84, + "y": 155900.21, + "z": 1905.78, + "rotation": 359.97, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "Separator_SP", + "x": 227298.05, + "y": 152086.02, + "z": 2318.68, + "rotation": 180.29, + "comment": "aus DXF: Block 'Separator 200000070', Layer '_3D_Separator', auf Makro-Block 'Separator_SP' abgebildet" + }, + { + "function": "insert", + "block": "S-LP", + "x": 216630.12, + "y": 150670.0, + "z": 2167.0, + "rotation": 90.0, + "comment": "aus DXF: Block 'Separator LP 3D', Layer '_3D_Separator', auf Makro-Block 'S-LP' abgebildet; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 217517.98, + "y": 151470.0, + "z": 2167.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'Separator LP 3D', Layer '_3D_Separator', auf Makro-Block 'S-LP' abgebildet; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 221193.61, + "y": 151470.0, + "z": 2167.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'Separator LP 3D', Layer '_3D_Separator', auf Makro-Block 'S-LP' abgebildet; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 225013.06, + "y": 150670.0, + "z": 2167.0, + "rotation": 90.0, + "comment": "aus DXF: Block 'Separator LP 3D', Layer '_3D_Separator', auf Makro-Block 'S-LP' abgebildet; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 225492.76, + "y": 151470.0, + "z": 2167.0, + "rotation": 270.0, + "comment": "aus DXF: Block 'Separator LP 3D', Layer '_3D_Separator', auf Makro-Block 'S-LP' abgebildet; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + }, + { + "function": "insert", + "block": "S-LP", + "x": 225613.06, + "y": 150670.0, + "z": 2167.0, + "rotation": 90.0, + "comment": "aus DXF: Block 'Separator LP 3D', Layer '_3D_Separator', auf Makro-Block 'S-LP' abgebildet; Block 'S-LP' ist in data/ils/ NICHT vorhanden - erzeugt im Testlauf einen Platzhalter" + } +] diff --git a/tests/testdata/mubea.json b/tests/testdata/mubea.json index 53ae501..3a6fe08 100644 --- a/tests/testdata/mubea.json +++ b/tests/testdata/mubea.json @@ -52,7 +52,7 @@ "function": "insert", "block": "S-LP", "x": 400, - "y": -3815, + "y": -4038, "z": 1467, "rotation": 90, "assigned_to": "Kreisel_links" @@ -62,7 +62,7 @@ "function": "insert", "block": "Scanner", "x": 400, - "y": -3925, + "y": -4083, "z": 1467, "rotation": 0, "assigned_to": "Kreisel_links" @@ -83,7 +83,7 @@ "block": "Scanner", "x": 10310, "y": -610, - "z": 1467, + "z": 1941, "rotation": 0, "assigned_to": "Kreisel_rechts" }, @@ -264,13 +264,23 @@ "rotation": 180, "assigned_to": "Kreisel_unten" }, + { + "comment": "Scanner unterer Kreisel", + "function": "insert", + "block": "Scanner", + "x": 1415, + "y": -5100, + "z": 1467, + "rotation": 270, + "assigned_to": "Kreisel_unten" + }, { "comment": "Separator S-LP #11 Y-Abgleich mit Gefaellestrecken (Sep-Y=8777, GF_Mubea_04-Y=9126, diff=349mm, enger Treffer)", "function": "insert", "block": "S-LP", "x": 9510, - "y": 13649, - "z": 1467, + "y": 14334, + "z": 1941, "rotation": 270, "assigned_to": "Kreisel_rechts" }, @@ -293,13 +303,33 @@ "z": 1941, "rotation": 90, "assigned_to": "Kreisel_rechts" + }, + { + "comment": "Separator Eingang 3 Rechter_Kreisel", + "function": "insert", + "block": "S-LP", + "x": 10310, + "y": -5073, + "z": 1941, + "rotation": 90, + "assigned_to": "Kreisel_rechts" + }, + { + "comment": "", + "function": "insert", + "block": "S-LP", + "x": 8725, + "y": -4743, + "z": 1941, + "rotation": 180, + "assigned_to": "GF_Mubea" }, { "comment": "Separator S-LP #8 Y-Abgleich mit Gefaellestrecken (Sep-Y=-4823, GF_Mubea_20-Y=-3673, diff=1150mm, naechstgelegene Reihe, kein enger Treffer)", "function": "insert", "block": "S-LP", "x": 9510, - "y": -3887, + "y": -3636, "z": 1941, "rotation": 270, "assigned_to": "GF_Mubea_20" @@ -309,7 +339,7 @@ "function": "insert", "block": "Scanner", "x": 9510, - "y": -3787, + "y": -3557, "z": 1941, "rotation": 0, "assigned_to": "Kreisel_rechts" diff --git a/tests/testdata/object_data.md b/tests/testdata/object_data.md index 3a643cd..3b8ce9e 100644 --- a/tests/testdata/object_data.md +++ b/tests/testdata/object_data.md @@ -49,6 +49,7 @@ Grad). Richtung `hz`/`hz_grad`: 0=Ost(+X), 90=Nord(+Y), 180=West(-X), | 4.3 | Omniflo TEF/Zubehoer | `omni:insert-dxf` (fester Sivasnr) | Position ja, Punkt interaktiv | 1 Punkt (nur via Wrapper) | [#43](#43-omniflo-tefzubehoer) | | 4.4 | Omniflo TV_*/APBW_* | — (DUMMY) | **nein** | — | [#44](#44-omniflo-tv_-apbw_) | | 5 | Separator/Scanner/Sensor | `ils-insert-sensor (fname)` + `_.INSERT` | **nein** (Schleife) | 1 Punkt `x,y,z` (nur via Wrapper) | [#5](#5-separatorscannersensor) | +| 6 | Waagrechte Foerderstrecke (`gerade`) | `insert-hz-scaled-block-by-ks (blockname startpunkt laenge hz vert)` | **ja** | 1 Punkt `startpunkt` (+ `endpunkt` als Doku) | [#6](#6-waagrechte-foerderstrecke-gerade) | Legende Scriptbar-Spalte: - **ja** — direkt per JSON aufrufbar. @@ -541,7 +542,53 @@ Vollstaendiges JSON (wie in `mubea.json`s Separator-Eintraegen): --- -## 6. Zusammenfassung: scriptbar vs. nicht scriptbar +## 6. Waagrechte Foerderstrecke (`gerade`) + +Produktionsfunktion: +`insert-hz-scaled-block-by-ks (blockname startpunkt laenge hz-winkel vert-winkel)` +(`Lisp/vf_etage.lsp`) — dieselbe Primitive, mit der der Etage-Foerderer seine +waagrechten Abschnitte baut. Skaliert `Staustrecke_SP_1000_mm` auf `laenge` +(Massstab = `laenge / 1000`) und richtet sie ueber `KS_EIN`/`KS_AUS` am +`startpunkt` aus. + +Dieser Typ entstand beim Nachbau von `results/ST500592_05.dxf`: ein +Antriebs-/Umlenkstation-Paar **ohne** Hoehendifferenz ist fachlich kein +VarioFoerderer. Dessen Solver (`berechne-standard` → +`berechne-alle-winkel`) muss einen Bogenwinkel aus der Kandidatenliste +3…51 Grad waehlen, was bei `deltaH ≈ 0` nicht aufgeht — der Eintrag waere +unbaubar. `lib/dxf_scan_components.py` trennt solche Paare darum ueber +`GERADE_MAX_DELTA_H` (10 mm) ab und gibt sie als `gerade` aus. + +| Feld | Typ | Pflicht | Bedeutung | Default | +|---|---|---|---|---| +| `test_id` | string | **ja** | Kennung, Praefix `GERADE_` (steuert den Dispatcher) | - | +| `typ` | string | nein | `"gerade"` (Dokumentation, nicht ausgewertet) | - | +| `laenge` | real (mm) | **ja** | horizontale Laenge; Massstab der Staustrecke | - | +| `hz` | real (Grad) | nein | Fahrtrichtung in der XY-Ebene | 0.0 | +| `startpunkt` | [x,y,z] (mm) | **ja** | Anfangspunkt (`KS_EIN` wird hierhin gelegt) | - | +| `endpunkt` | [x,y,z] (mm) | nein | Endpunkt — reine Dokumentation/Herkunft, **kein** Parameter; die Geometrie folgt aus `startpunkt` + `laenge` + `hz` | - | + +`vert-winkel` wird vom Treiber fest auf `0` gesetzt (waagrecht). Die Funktion +gibt den **Ausgangspunkt** zurueck, nicht die Entity — ein Treiber holt den +Block ueber `(entlast)`. Bei fehlender Blockdatei ruft sie `(exit)` auf und +wuerde einen Testlauf abbrechen, darum vorher mit `ssg-ils-block-laden` +pruefen (so macht es `hundm05:build-gerade` in `tests/test_hundm05.lsp`). + +Vollstaendiges JSON (wie in `hundm05.json`): +```json +{ + "test_id": "GERADE_HundM05_08", + "typ": "gerade", + "laenge": 3305.26, + "hz": 143.35, + "startpunkt": [223150.89, 152953.65, 0.0], + "endpunkt": [220499.01, 154926.53, 0.0] +} +``` + +--- + +## 7. Zusammenfassung: scriptbar vs. nicht scriptbar | Element | Produktionsfunktion | Scriptbar? | |---|---|---|