#!/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 # --- 2D-Symbol oder 3D-Modell? -------------------------------------------- # ST500592_05.dxf enthaelt DIESELBE Anlage zweimal: als flaches Schema (70,6 % # der Modelspace-INSERTs, alle Z=0) und als 3D-Modell (29,4 %, echte Hoehen). # Nachgewiesen: alle 12 flachen Antriebsstationen haben eine 3D-Antriebsstation # in unter 1,5 m Abstand (Versatz ~340 mm, unterschiedliche Bezugspunkte). # Wer die beiden Darstellungen mischt, erfindet Geometrie: eine Paarung # "flaches Symbol bei Z=0" mit "3D-Modell bei Z=2338" ergibt eine # Hoehendifferenz, die es in der Anlage nicht gibt. # Die Einordnung erfolgt ueber die GEOMETRIE der Blockdefinition, nicht ueber # den Namen: nur 37 von 2099 Bloecken tragen "3D" im Namen, und die Layer-Regel # (_3D*/3d*) liegt bei 98,2 % - sie verfehlt u.a. "tef90i" auf Layer F-1. TYPEN_3D = frozenset([ "3DSOLID", "3DFACE", "SURFACE", "PLANESURFACE", "SWEPTSURFACE", "EXTRUDEDSURFACE", "REVOLVEDSURFACE", "LOFTEDSURFACE", "MESH", "REGION", "BODY", "POLYFACEMESH"]) # Gruppencodes mit Z-Anteil, die die Z-Ausdehnung eines Blocks aufspannen. Z_CODES_BLOCK = frozenset([30, 31, 32, 33, 38]) # Ab dieser Z-Ausdehnung (mm) gilt eine Blockdefinition als 3D. Z_TOLERANZ_3D = 0.001 # --- Achsentreue der Foerderer --------------------------------------------- # Die Anlage ist rechtwinklig aufgebaut: 89 von 91 Bauteil-Rotationen liegen # exakt auf einem Vielfachen von 90 Grad. Die Richtung eines Foerderers darf # darum NICHT einfach aus der Verbindung der beiden Einfuegepunkte kommen - # Antriebs- und Umlenkstation haben unterschiedliche Bezugspunkte. Gemessen: # vier baugleiche Fingerfoerderer haben alle denselben Querversatz von ~1000 mm # zwischen Antriebs- und Umlenk-Einfuegepunkt. Aus dem Verbindungsvektor ergaben # sich dadurch 105,34 Grad statt 90 und 3775 mm statt 3641 mm Laenge. # Vorgehen: Richtung auf die naechste Achse einrasten, Laenge darauf projizieren, # und den Querversatz als Pruefgroesse verwenden. ACHSE_RASTER = 90.0 # Querversatz (mm), bis zu dem er als Bezugspunkt-Unterschied gilt. Darueber # passen die beiden Stationen nicht zu einem geraden Foerderer und die Paarung # ist falsch (Beispiel: 6694 mm Querversatz bei einer angeblichen Laenge von # 13009 mm - das waeren zwei Stationen aus verschiedenen Foerderern). ACHSE_MAX_QUER = 1500.0 # Maximaler XY-Abstand (mm), bis zu dem ein 2D-Symbol und ein 3D-Modell als # DASSELBE Bauteil gelten. Gemessen an dieser Zeichnung: alle 12 flachen # Antriebsstationen haben ihren 3D-Zwilling in 337..353 mm Abstand, die # Umlenkstationen in 184..493 mm. 1500 mm laesst Luft und traf im Test 100 % # der Antriebsstationen, ohne benachbarte Bauteile zu verwechseln. ZWILLING_MAX_ABSTAND = 1500.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, block_info) 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 block_info - dict blockname -> {"typen", "zmin", "zmax", "kinder"}; Grundlage fuer ist_3d_block (2D-Symbol oder 3D-Modell) """ ms_inserts = [] block_inserts = {} block_base = {} block_info = {} 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 block_merken(name): return block_info.setdefault(name, {"typen": set(), "zmin": 0.0, "zmax": 0.0, "kinder": set()}) def z_merken(name, wert): """Z-Ausdehnung einer Blockdefinition mitschreiben. Grundlage der 2D/3D-Einordnung: ein flaches Symbol hat alle Z auf 0, ein 3D-Modell hat eine Z-Ausdehnung. Die Einfuegepunkt-Z verschachtelter INSERTs zaehlen mit - ein Block, der seine Teile auf verschiedenen Hoehen zusammensetzt, ist ebenfalls 3D. """ info = block_merken(name) if wert < info["zmin"]: info["zmin"] = wert if wert > info["zmax"]: info["zmax"] = wert 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 # Entity-Typen der Blockdefinition mitschreiben - daraus # ergibt sich, ob der Block ein 3D-Modell ist. if aktueller_block and value != "SEQEND": block_merken(aktueller_block)["typen"].add(value) 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]) block_merken(value) elif aktuell == "INSERT" and rec is not None: rec["name"] = value if aktueller_block: block_merken(aktueller_block)["kinder"].add(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 # Bewusst NACH der Kette und ohne elif: die Z-Ausdehnung einer # Blockdefinition wird aus ALLEN Entities darin gespeist, auch aus # denen, die oben schon behandelt wurden (z.B. Einfuegepunkt-Z # verschachtelter INSERTs). if aktueller_block and code in Z_CODES_BLOCK: z_merken(aktueller_block, value) if rec is not None: ablegen(rec) finally: mm.close() fh.close() return ms_inserts, block_inserts, block_base, statistik, block_info 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 ist_3d_block(name, block_info, memo=None, pfad=frozenset()): """Ist die Blockdefinition ein 3D-Modell (statt eines flachen Symbols)? 3D, wenn sie echte Volumen-/Flaechentypen enthaelt oder ihre Geometrie eine Z-Ausdehnung hat. Die Einordnung wird transitiv weitergegeben: ein Block, der einen 3D-Block enthaelt, ist selbst 3D. "memo" cached das Ergebnis je Blockname (der Aufrufer sollte ein dict mitgeben und wiederverwenden). """ if memo is None: memo = {} if name in memo: return memo[name] info = block_info.get(name) if info is None or name in pfad: return False memo[name] = False # Zyklenschutz waehrend der Rekursion ergebnis = bool(info["typen"] & TYPEN_3D) if not ergebnis: ergebnis = (info["zmax"] - info["zmin"]) > Z_TOLERANZ_3D if not ergebnis: for kind in info["kinder"]: if ist_3d_block(kind, block_info, memo, pfad | {name}): ergebnis = True break memo[name] = ergebnis return ergebnis def darstellung_von(name, block_info, memo=None): """"3D" oder "2D" fuer einen Blocknamen.""" return "3D" if ist_3d_block(name, block_info, memo) else "2D" 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, block_info=None): """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 = {} dim_memo = {} 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"], # 2D-Symbol oder 3D-Modell - entscheidet, mit wem dieses # Vorkommen gepaart werden darf (siehe paare_bilden). "darstellung": (darstellung_von(rec["name"], block_info, dim_memo) if block_info else "?"), }) 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)) # --------------------------------------------------------------------------- # Zusammenfuehrung der beiden Darstellungen # --------------------------------------------------------------------------- def hoehe_aus_umgebung(punkt, treffer, radius=3000.0, mindestens=2): """Einbauhoehe eines 2D-Bauteils aus 3D-Nachbarn schaetzen. Fuer Bauteile, die es NUR als flaches Symbol gibt (in dieser Zeichnung z.B. der Kreisel: AN8/SP8 sind Linien/Kreis/Text auf Z=0), steht im DXF keine Hoehe. Die 3D-Modelle ringsum stehen aber auf der echten Foerderhoehe - daraus laesst sich die Hoehe belegen statt raten. Rueckgabe: (hoehe, begruendung) oder (None, begruendung), wenn zu wenige 3D-Nachbarn in Reichweite sind. """ nah = [] for t in treffer: if t.get("darstellung") != "3D": continue d = math.hypot(t["x"] - punkt[0], t["y"] - punkt[1]) if d <= radius and Z_PLAUSIBEL[0] <= t["z"] <= Z_PLAUSIBEL[1]: nah.append((d, t)) if len(nah) < mindestens: return None, ("nur %d 3D-Nachbarn in %.0f mm - zu wenige fuer eine " "belegbare Hoehe" % (len(nah), radius)) # Haeufigster gerundeter Z-Wert; bei Gleichstand der naechstgelegene. haeufig = {} for d, t in nah: z = round(t["z"]) haeufig.setdefault(z, []).append(d) z_best = max(haeufig, key=lambda z: (len(haeufig[z]), -min(haeufig[z]))) return float(z_best), ( "aus %d 3D-Nachbarn in %.0f mm abgeleitet; haeufigster Z-Wert %d mm " "(%dx, naechster in %.0f mm)" % (len(nah), radius, z_best, len(haeufig[z_best]), min(haeufig[z_best]))) def zwillinge_zusammenfuehren(treffer): """2D-Symbol und 3D-Modell desselben Bauteils zu EINEM Vorkommen vereinen. Die Zeichnung enthaelt dieselbe Anlage zweimal. Ohne diesen Schritt liefert jede Anlage doppelt: einmal als 3D-Foerderer mit echter Hoehe und einmal als 2D-Strecke mit unbekannter Hoehe. Beides ist dasselbe Bauteil. Zusammengefuehrt wird nur bei gegenseitig naechstem Nachbarn derselben Familie innerhalb von ZWILLING_MAX_ABSTAND. Das 3D-Vorkommen bleibt der Traeger (es hat die belastbare Geometrie samt Hoehe), uebernimmt aber die Attribute des 2D-Zwillings - denn die Identitaet (BESCHR, ARTINR, KENNZEICHNUNG) steht im 2D-Schema. Die XY-Lage des 2D-Symbols wird NICHT uebernommen: der Versatz von ~340 mm ist ein anderer Bezugspunkt, und welcher davon zu unseren Bloecken passt, ist nicht belegt. Rueckgabe: (vorkommen, statistik) """ zwei_d = [t for t in treffer if t.get("darstellung") == "2D"] drei_d = [t for t in treffer if t.get("darstellung") == "3D"] rest = [t for t in treffer if t.get("darstellung") not in ("2D", "3D")] stat = {"zusammengefuehrt": 0, "nur_2d": 0, "nur_3d": 0, "attribute_uebernommen": 0} def dist(a, b): return math.hypot(b["x"] - a["x"], b["y"] - a["y"]) offen_2d = list(zwei_d) offen_3d = list(drei_d) paare = [] while True: bester = None for d3 in offen_3d: kand = [d2 for d2 in offen_2d if d2["familie"] == d3["familie"]] if not kand: continue d2 = min(kand, key=lambda k: dist(d3, k)) d = dist(d3, d2) if d > ZWILLING_MAX_ABSTAND: continue # Gegenprobe: ist d3 auch fuer d2 das naechste 3D-Vorkommen? gegen = [x for x in offen_3d if x["familie"] == d2["familie"]] if min(gegen, key=lambda k: dist(k, d2)) is not d3: continue if bester is None or d < bester[2]: bester = (d3, d2, d) if bester is None: break d3, d2, d = bester offen_3d.remove(d3) offen_2d.remove(d2) paare.append((d3, d2, d)) for d3, d2, d in paare: # Attribute des 2D-Zwillings uebernehmen, ohne vorhandene zu ueber- # schreiben - das 2D-Schema traegt die Identitaet, das 3D-Modell die # Geometrie. neu = dict(d2["attribs"]) neu.update(d3["attribs"]) if neu != d3["attribs"]: stat["attribute_uebernommen"] += 1 d3["attribs"] = neu d3["zwilling_2d"] = {"block": d2["block"], "layer": d2["layer"], "abstand": round(d, RUND)} stat["zusammengefuehrt"] += 1 stat["nur_2d"] = len(offen_2d) stat["nur_3d"] = len(offen_3d) # Zusammengefuehrte 2D-Vorkommen fallen weg (sie sind im 3D-Traeger drin), # eigenstaendige 2D-Vorkommen bleiben - z.B. der Kreisel, den es nur als # flaches Symbol gibt. return drei_d + offen_2d + rest, stat # --------------------------------------------------------------------------- # Paarbildung und Schema-Abbildung # --------------------------------------------------------------------------- def paare_bilden(antriebe, umlenkungen): """Gegenseitig naechste Nachbarn paaren. Gepaart wird nur innerhalb desselben Foerdersystems (TEF/FF/SCHRAEG) UND innerhalb derselben Darstellung (2D-Schema oder 3D-Modell). Letzteres ist entscheidend: die Zeichnung enthaelt dieselbe Anlage zweimal, und eine Paarung ueber die Darstellungsgrenze erfindet eine Hoehendifferenz, die es nicht gibt (flaches Symbol bei Z=0 gegen 3D-Modell bei Z=2338 ergab Foerderer mit deltaH=5827 mm und Startpunkten unter dem Hallenboden). Rueckgabe: (paare, rest_antriebe, rest_umlenkungen) """ def dist(a, b): return math.hypot(b["x"] - a["x"], b["y"] - a["y"]) def passt(a, b): if system_von(a["block"]) != system_von(b["block"]): return False if a.get("darstellung") != b.get("darstellung"): return False # Achstreue: die beiden Stationen muessen auf einer Achse liegen. Der # zulaessige Querversatz deckt den Bezugspunkt-Unterschied der # Blocktypen ab (~1000 mm gemessen), nicht mehr. Ohne diese Bedingung # paart der naechste-Nachbar-Ansatz Stationen aus verschiedenen # Foerderern und erzeugt Diagonalen quer durch die Anlage. return achse_einrasten(a, b)[2] <= ACHSE_MAX_QUER 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 passt(a, u)] 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 passt(x, u)] 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 achse_einrasten(a, u): """Foerderer-Achse aus einem Stationspaar bestimmen. Rueckgabe: (hz, laenge_axial, querversatz). "hz" ist auf das ACHSE_RASTER eingerastet, "laenge_axial" die Projektion des Verbindungsvektors auf diese Achse, "querversatz" der Rest senkrecht dazu (= Bezugspunkt-Unterschied der beiden Blocktypen, siehe ACHSE_MAX_QUER). """ dx = u["x"] - a["x"] dy = u["y"] - a["y"] roh = math.degrees(math.atan2(dy, dx)) % 360.0 hz = (round(roh / ACHSE_RASTER) * ACHSE_RASTER) % 360.0 rad = math.radians(hz) # Projektion auf die Achse und den dazu senkrechten Anteil axial = dx * math.cos(rad) + dy * math.sin(rad) quer = -dx * math.sin(rad) + dy * math.cos(rad) return hz, abs(axial), abs(quer) 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"] # Richtung und Laenge auf der Anlagenachse, NICHT aus dem Verbindungsvektor # der Einfuegepunkte (siehe achse_einrasten / ACHSE_MAX_QUER). hz, laenge, quer = achse_einrasten(a, u) laenge = round(laenge, RUND) neigung = round(math.degrees(math.atan2(abs(dz), laenge)), RUND) if laenge else 0.0 herkunft = ("aus DXF: %s -> %s (Layer %s / %s), Achse %.0f Grad, " "Laenge %.2f, Querversatz %.2f (Bezugspunkt-Unterschied), " "Neigung %.2f Grad" % (a["block"], u["block"], a["layer"], u["layer"], hz, laenge, quer, neigung)) # dist_xy war der reine Punktabstand; ab hier zaehlt die axiale Laenge. dist_xy = laenge # Aus dem 2D-Schema ist KEINE Hoehe ableitbar: dort liegt alles auf Z=0 # (die wenigen abweichenden Z-Werte - z.B. -3941 und +21677 auf Layer z-3 - # sind Ablage-Artefakte, keine Einbauhoehen). Ein Hoehenunterschied aus # solchen Werten waere erfunden, darum wird ein 2D-Paar immer als # waagrechte Strecke ausgewiesen und die Hoehe als unbekannt markiert. # Die echte Hoehe liefert der 3D-Zwilling (siehe Zusammenfuehrung). if a.get("darstellung") == "2D": return { "test_id": "GERADE_HundM05_%02d" % nummer, "typ": "gerade", "darstellung": "2D", "laenge": round(dist_xy, RUND), "hz": hz, "startpunkt": [a["x"], a["y"], a["z"]], "endpunkt": [u["x"], u["y"], u["z"]], "hoehe_unbekannt": True, "comment": herkunft + "; 2D-Schema - Z ist dort keine Einbauhoehe, " "darum waagrechte Strecke und hoehe_unbekannt=true. " "Einbauhoehe nur ueber den 3D-Zwilling zu bekommen.", } if abs(dz) < GERADE_MAX_DELTA_H: return { "test_id": "GERADE_HundM05_%02d" % nummer, "typ": "gerade", "darstellung": a.get("darstellung", "?"), "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, "darstellung": a.get("darstellung", "?"), "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", "darstellung": a.get("darstellung", "?"), "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, treffer=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) zw = t.get("zwilling_2d") if zw: kommentar += ("; 3D-Modell, zusammengefuehrt mit dem 2D-Symbol '%s' " "(Layer %s, %.0f mm entfernt) - Hoehe aus dem 3D-Modell, " "Identitaet aus dem 2D-Symbol" % (zw["block"], zw["layer"], zw["abstand"])) elif t.get("darstellung") == "2D": # Gleiche Begruendung wie bei den Foerderer-Paaren: im 2D-Schema ist Z # keine Einbauhoehe. Der Wert wird uebernommen (er steht so im DXF), # aber als nicht belastbar markiert. kommentar += ("; 2D-Schema ohne 3D-Zwilling - z ist keine Einbauhoehe " "(hoehe_unbekannt)") # Ein 2D-Symbol mit unmoeglichem Z (z.B. -3941 oder +21677 auf Layer z-3) # wuerde sonst 4 m unter bzw. 21 m ueber der Anlage eingebaut. Der Wert ist # dort kein Hoehenmass, also wird - wie beim Kreisel - die Hoehe aus den # 3D-Nachbarn abgeleitet, statt einen unmoeglichen Wert zu uebernehmen. z = t["z"] if (t.get("darstellung") == "2D" and treffer and not (Z_PLAUSIBEL[0] <= z <= Z_PLAUSIBEL[1])): ersatz, grund = hoehe_aus_umgebung((t["x"], t["y"]), treffer) if ersatz is not None: kommentar += ("; z=%.1f liegt ausserhalb des plausiblen Fensters " "und ist im 2D-Schema kein Hoehenmass - ersetzt " "durch %s" % (z, grund)) z = ersatz else: kommentar += ("; z=%.1f liegt ausserhalb des plausiblen Fensters, " "aber %s" % (z, grund)) return { "function": "insert", "block": block, "x": t["x"], "y": t["y"], "z": z, "rotation": t["rot"], "darstellung": t.get("darstellung", "?"), "hoehe_unbekannt": t.get("darstellung") == "2D", "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, treffer)) 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 # AN8/SP8 sind flache 2D-Symbole auf Z=0 - die Einbauhoehe steht nicht # im DXF. Sie wird aus den 3D-Modellen in der Umgebung abgeleitet # (Separatoren, Boegen: die stehen auf der echten Foerderhoehe). hoehe = an["z"] hoehe_hinweis = "" if an.get("darstellung") == "2D": geschaetzt, grund = hoehe_aus_umgebung((an["x"], an["y"]), treffer) if geschaetzt is not None: hoehe = geschaetzt hoehe_hinweis = (" ACHTUNG hoehe NICHT aus dem Kreisel selbst: " "AN8/SP8 sind flache 2D-Symbole auf Z=0, die " "Einbauhoehe steht nicht im DXF. Wert %s" % grund) + "." else: hoehe_hinweis = (" ACHTUNG hoehe=%.1f ist NICHT belegt: " "AN8/SP8 sind flache 2D-Symbole auf Z=0 und " "%s." % (hoehe, grund)) 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", "darstellung": an.get("darstellung", "?"), "hoehe": hoehe, "comment": ("aus DXF: AN8 (Layer %s) -> SP8 (Layer %s), Abstand " "%.2f; daraus folgt produktionsseitig abstand = %.2f - " "%.1f = %.2f und rotation = %.2f Grad." % (an["layer"], sp["layer"], dist, dist, durchmesser, dist - durchmesser, rotation)) + hoehe_hinweis, }) 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, treffer)) # --- 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, treffer)) # --- 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, treffer) 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, treffer) 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, block_info) = 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, block_info=block_info) 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])) # Beide Darstellungen derselben Anlage zu einem Bauteil vereinen treffer, dim_stat = zwillinge_zusammenfuehren(treffer) print("%s Darstellungen zusammengefuehrt: %d Zwillingspaare (davon %d mit " "uebernommenen Attributen), %d nur 2D, %d nur 3D -> %d Vorkommen" % (PREFIX, dim_stat["zusammengefuehrt"], dim_stat["attribute_uebernommen"], dim_stat["nur_2d"], dim_stat["nur_3d"], len(treffer))) 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()