Files
dxfmakros/lib/dxf_abbild.py
T
s.ayadi bbc9e9b9f9 [FEAT] Werkzeuge zum Auslesen fremder Zeichnungen und der VF-Eingabe-Journale
Grundlage fuer reproduzierbare Nachbau-Testfaelle aus echten Projekt-
zeichnungen (HundM05).

- lib/vf_journal_export.py (neu): liest die Eingabe-Journale der VF_n-Bloecke
  aus der XDATA (App SSG_VF_EDIT, Marker "linienzug") und schreibt sie als
  Frage-Antwort-Protokoll im Schema von tests/testdata/linienzug_tests.json -
  Gegenstueck zu vfl-entry->string, ohne ezdxf (reine Gruppencode-Lesung).
  Optional --csv fuer Strecken-ID und Erwartungswerte je Kette.
- lib/extract_polylines.py (neu): LWPOLYLINE-Objekte als JSON, inkl.
  OCS->Welt-Umrechnung fuer gekippte Ebenen (Handles, Segment-/Gesamtlaengen).
- lib/dxf_scan_components.py: erkennt jetzt zusaetzlich, ob ein Block ein
  2D-Schema oder ein 3D-Modell ist (TYPEN_3D, ist_3d_block/darstellung_von) -
  eine Anlagenzeichnung enthaelt beides. Dazu Sensor-Erfassung,
  Zwillings-Zusammenfuehrung und Hoehenschaetzung aus der Umgebung.
- lib/dxf_abbild.py: gibt die erkannte Darstellung je Element mit aus.
- tests/test_vf_journal_grammatik.py (neu): prueft den Journal-Dekoder gegen
  handgebaute Journale - ohne CAD und ohne die grosse DXF. Deckt die zwei
  Zweige ab, die in den echten Ketten nicht vorkommen: Glied "Linie-VF" und
  eine VF-Einheit mit gewinkeltem Erstkoerper (dort fragt vfl-vf-einheit
  KEINE Separator-/Endpunkt-Fragen vorab, anders als beim horizontalen).
- data/polylines.md, tests/testdata/hundm05_linienzug_polylines.json:
  ausgewertete Streckenzuege der Quellzeichnung.
- .gitignore: die Quellzeichnungen selbst (data/polylines.dxf 124 MB,
  data/polylines.dwg, tests/HM5.dwg) bleiben draussen - eingecheckt ist die
  Auswertung, nicht die Zeichnung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:13:56 +02:00

345 lines
14 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, darstellung_von, 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, block_info=None,
dim_memo=None):
"""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 ""
if block_info is not None:
# 2D-Schema oder 3D-Modell - die Zeichnung enthaelt beides fuer
# dieselbe Anlage (siehe dxf_scan_components.TYPEN_3D).
eintrag["darstellung"] = darstellung_von(rec["name"], block_info,
dim_memo)
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, block_info=None):
"""Alle Modelspace-INSERTs als Abbild-Baum beschreiben.
Rueckgabe: (elemente, statistik). Jedes Element der obersten Ebene enthaelt
seine aufgeloesten Unterkomponenten unter "unterkomponenten".
"""
stat = collections.Counter()
dim_memo = {}
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, block_info,
dim_memo)
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, block_info, dim_memo)
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,
block_info) = 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,
block_info=block_info)
# 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()