[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:
+386
-32
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user