bbc9e9b9f9
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>
1465 lines
64 KiB
Python
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()
|