[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>
This commit is contained in:
2026-09-03 10:13:56 +02:00
parent 0015d6aa5b
commit bbc9e9b9f9
8 changed files with 1651 additions and 39 deletions
+386 -32
View File
@@ -76,6 +76,53 @@ 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
@@ -329,15 +376,18 @@ def system_von(bname):
def lese_inserts(dxf_path):
"""Liest alle INSERTs (Modelspace und Blockdefinitionen) plus Basispunkte.
Rueckgabe: (ms_inserts, block_inserts, block_base, statistik)
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
@@ -354,6 +404,24 @@ def lese_inserts(dxf_path):
"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
@@ -410,6 +478,10 @@ def lese_inserts(dxf_path):
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
@@ -417,8 +489,11 @@ def lese_inserts(dxf_path):
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):
@@ -444,12 +519,18 @@ def lese_inserts(dxf_path):
# 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
return ms_inserts, block_inserts, block_base, statistik, block_info
def insert_matrix(rec, block_base):
@@ -479,6 +560,39 @@ def insert_matrix(rec, block_base):
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:
@@ -487,7 +601,8 @@ def im_fenster(x, y, fenster):
return x0 <= x <= x1 and y0 <= y <= y1
def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12):
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
@@ -504,6 +619,7 @@ def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12):
"""
treffer = []
unbekannt = {}
dim_memo = {}
def lauf(recs, mutter, tiefe, pfad):
if tiefe > max_tiefe:
@@ -528,6 +644,11 @@ def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12):
"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
@@ -630,25 +751,155 @@ def sivasnr_weiche(bname, katalog):
"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 (nur innerhalb desselben Systems).
"""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 system_von(u["block"]) == system_von(a["block"])]
kand = [u for u in offen_u if passt(a, u)]
if not kand:
continue
u = min(kand, key=lambda k: dist(a, k))
@@ -656,7 +907,7 @@ def paare_bilden(antriebe, umlenkungen):
if d > MAX_PAAR_ABSTAND:
continue
# Gegenprobe: ist a auch fuer u der naechste Antrieb?
kand_a = [x for x in offen_a if system_von(x["block"]) == system_von(u["block"])]
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]:
@@ -670,6 +921,25 @@ def paare_bilden(antriebe, umlenkungen):
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.
@@ -687,16 +957,45 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
werden als eigener Typ "gerade" ausgewiesen.
"""
dz = u["z"] - a["z"]
hz = round(math.degrees(math.atan2(u["y"] - a["y"], u["x"] - a["x"])) % 360.0, RUND)
neigung = round(math.degrees(math.atan2(abs(dz), dist_xy)), RUND) if dist_xy else 0.0
herkunft = ("aus DXF: %s -> %s (Layer %s / %s), XY-Abstand %.2f, "
"Neigung %.2f Grad" % (a["block"], u["block"], a["layer"],
u["layer"], dist_xy, neigung))
# 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"]],
@@ -711,6 +1010,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, 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,
@@ -727,6 +1027,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, 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),
@@ -739,7 +1040,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
}
def sensor_eintrag(t, bekannte_bloecke=None, hinweise=None):
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
@@ -762,13 +1063,43 @@ def sensor_eintrag(t, bekannte_bloecke=None, hinweise=None):
% (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": t["z"],
"z": z,
"rotation": t["rot"],
"darstellung": t.get("darstellung", "?"),
"hoehe_unbekannt": t.get("darstellung") == "2D",
"comment": kommentar,
}
@@ -790,33 +1121,46 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
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))
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",
"hoehe": an["z"],
"comment": "aus DXF: AN8 (Layer %s) -> SP8 (Layer %s), Abstand "
"%.2f; daraus folgt produktionsseitig abstand = %.2f - "
"%.1f = %.2f und rotation = %.2f Grad. ACHTUNG hoehe: "
"AN8 und SP8 sind in dieser Zeichnung flache 2D-Symbole "
"(nur Linien/Kreis/Text auf Z=0) und auf Z=0 eingefuegt "
"- die Einfuegehoehe des Kreisels steht NICHT im DXF "
"und muss hier von Hand gesetzt werden (vgl. "
"mubea.json, wo z ebenfalls Handarbeit ist)."
% (an["layer"], sp["layer"], dist, dist, durchmesser,
dist - durchmesser, rotation),
"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))
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)
@@ -828,7 +1172,8 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
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))
eintraege.append(sensor_eintrag(t, bekannte_bloecke, hinweise,
treffer))
# --- Omniflo Boegen und Weichen ---------------------------------------
for familie, katalog, typ in (("omniflo_bogen", boegen, "bogen"),
@@ -868,14 +1213,14 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
"(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)
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)
eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise, treffer)
for t in posten)
return eintraege, hinweise
@@ -1031,7 +1376,8 @@ def main():
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 = lese_inserts(dxf_path)
(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%%), "
@@ -1040,8 +1386,8 @@ def main():
100.0 * statistik["ocs_gekippt"] / max(statistik["inserts"], 1),
statistik["papierbereich"]))
treffer, unbekannt, verworfen = sammle_vorkommen(ms_inserts, block_inserts,
block_base)
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"])
@@ -1053,6 +1399,14 @@ def main():
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)