Files
dxfmakros/lib/dxf_scan_components.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

1465 lines
64 KiB
Python

#!/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 <dxf> <data_dir> <output.json>
[--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("<h", mm, i)[0]
i += 2
elif code in DOUBLE:
value = unpack("<d", mm, i)[0]
i += 8
elif code in INT32:
value = unpack("<i", mm, i)[0]
i += 4
elif code in INT64:
value = unpack("<q", mm, i)[0]
i += 8
elif code in BYTES:
value = mm[i]
i += 1
else: # nullterminierter String
end = mm.find(b"\x00", i)
value = decode_str(mm[i:end])
i = end + 1
yield code, value
# ---------------------------------------------------------------------------
# Familien-Erkennung (Blockname -> 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_<seite>_<winkel>_TEF_<aussen|innen>
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()