7242e815f8
- lib/dxf_scan_components.py: erkennt ILS-/Omniflo-Komponenten in fremden
Projektzeichnungen und schreibt sie im Testdaten-JSON-Schema aus
- lib/dxf_abbild.py: getreue Abbildung der Bauteile einer Fremdzeichnung
(Attribute, Weltkoordinaten, Unterkomponenten) ohne Interpretation
- tests/test_hundm05.{lsp,py}, testdata/hundm05.json, conftest-Fixtures und
alltests.json-Eintrag fuer den Kreisel-Abschnitt aus ST500592_05.dxf
- menu: TEST_HUNDM05 im Testmenue, Connection_Insert/Edit in SSG_LIB.cui
- Doku: hartkodierte Pfade durch (getenv "DXFMAKRO") ersetzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
#!/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 <dxf> <output.json> [--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()
|