#!/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()