[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
+6
View File
@@ -193,3 +193,9 @@ data/block_libraries/ils_library.dxf
# Grosse Test-Zeichnungen (Arbeitsstaende, gehoeren nicht ins Repo)
/tests/test.dwg
/tests/test.dxf
/tests/HM5.dwg
# Quellzeichnungen der HundM05-Analyse (124 MB ASCII-DXF bzw. 24 MB DWG).
# Ausgewertet in data/polylines.md und tests/testdata/hundm05.json - die
# Auswertung ist eingecheckt, die Zeichnung selbst nicht.
/data/polylines.dwg
/data/polylines.dxf
+171
View File
@@ -0,0 +1,171 @@
# Polylinien aus `data/polylines.dxf`
Quelle: `data/polylines.dxf` (ASCII-DXF, AC1032 / AutoCAD 2018, ca. 143 MB).
Die Datei enthaelt genau **5 LWPOLYLINE**-Objekte, alle auf Layer `Z-6`,
alle direkt am Anfang der `ENTITIES`-Sektion (ab Zeile 159094). In der
`BLOCKS`-Sektion liegen keine Polylinien.
Alle Polylinien sind **offen** (Gruppencode `70 = 0`) und haben **keine Bulges**
(kein Gruppencode `42`), sind also reine Streckenzuege.
Die Spalte **Laenge** gibt jeweils die Strecke vom vorhergehenden zum aktuellen
Punkt an (Segmentlaenge). Da OCS ein orthonormales System ist, entspricht die
in der Polylinien-Ebene gemessene Laenge der echten 3D-Laenge im Weltsystem.
---
## Polylinie 1 - Handle `CA`
| Feld | Wert |
|---|---|
| Handle (5) | `CA` |
| Layer (8) | `Z-6` |
| Stuetzpunkte (90) | 5 |
| Geschlossen (70) | 0 (offen) |
| Elevation (38) | -271.758876 |
| Extrusion (210/220/230) | -1.3674923e-07 / -0.043577812 / -0.999050036 |
Achtung: Die Extrusionsrichtung ist **nicht** (0,0,1). Die Gruppencodes
`10`/`20` sind daher **OCS**-Koordinaten, keine Weltkoordinaten. Beide
Darstellungen sind unten aufgefuehrt.
### OCS-Koordinaten (Rohwerte 10/20)
| Nr. | X | Y | Laenge zum Vorgaenger |
|---:|---:|---:|---:|
| 1 | 8610.178314 | -1646.791925 | - |
| 2 | 8608.179544 | -6230.251177 | 4583.4597 |
| 3 | 9758.166544 | -6230.250709 | 1149.9870 |
| 4 | 9758.180524 | -5392.918048 | 837.3327 |
| 5 | 9758.945042 | -1661.656105 | 3731.2620 |
| | | **Gesamtlaenge** | **10302.0414** |
### Weltkoordinaten (aus OCS umgerechnet, Arbitrary-Axis-Algorithmus)
| Nr. | X | Y | Z | Laenge zum Vorgaenger |
|---:|---:|---:|---:|---:|
| 1 | 8610.1835 | 1657.0432 | 199.7371 | - |
| 2 | 8608.1991 | 6236.1483 | 0.0000 | 4583.4597 |
| 3 | 9758.1861 | 6236.1442 | 0.0000 | 1149.9870 |
| 4 | 9758.1975 | 5399.6070 | 36.4891 | 837.3327 |
| 5 | 9758.9503 | 1671.8896 | 199.0894 | 3731.2620 |
| | | | **Gesamtlaenge** | **10302.0414** |
---
## Polylinie 2 - Handle `CD`
| Feld | Wert |
|---|---|
| Handle (5) | `CD` |
| Layer (8) | `Z-6` |
| Stuetzpunkte (90) | 6 |
| Geschlossen (70) | 0 (offen) |
| Elevation (38) | 199.737126 |
| Extrusion (210/220/230) | Standard (0/0/1) |
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
| Nr. | X | Y | Laenge zum Vorgaenger |
|---:|---:|---:|---:|
| 1 | 4927.055794 | 1205.360995 | - |
| 2 | 4932.562971 | 7060.201678 | 5854.8433 |
| 3 | 7106.355038 | 7060.201678 | 2173.7921 |
| 4 | 7106.138574 | 4645.514574 | 2414.6871 |
| 5 | 6201.192989 | 4645.514574 | 904.9456 |
| 6 | 6176.269578 | 1205.360995 | 3440.2439 |
| | | **Gesamtlaenge** | **14788.5119** |
---
## Polylinie 3 - Handle `FB`
| Feld | Wert |
|---|---|
| Handle (5) | `FB` |
| Layer (8) | `Z-6` |
| Stuetzpunkte (90) | 5 |
| Geschlossen (70) | 0 (offen) |
| Elevation (38) | 2366.737126 |
| Extrusion (210/220/230) | Standard (0/0/1) |
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
| Nr. | X | Y | Laenge zum Vorgaenger |
|---:|---:|---:|---:|
| 1 | 12891.792045 | 1205.360995 | - |
| 2 | 12908.891320 | 6244.648400 | 5039.3164 |
| 3 | 12908.891320 | 6244.648400 | 0.0000 |
| 4 | 13904.931447 | 6244.648400 | 996.0401 |
| 5 | 13898.258832 | 1695.891177 | 4548.7621 |
| | | **Gesamtlaenge** | **10584.1187** |
---
## Polylinie 4 - Handle `12B`
| Feld | Wert |
|---|---|
| Handle (5) | `12B` |
| Layer (8) | `Z-6` |
| Stuetzpunkte (90) | 5 |
| Geschlossen (70) | 0 (offen) |
| Elevation (38) | 2266.737126 |
| Extrusion (210/220/230) | Standard (0/0/1) |
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
| Nr. | X | Y | Laenge zum Vorgaenger |
|---:|---:|---:|---:|
| 1 | 17198.353387 | 1205.360995 | - |
| 2 | 17215.452662 | 6244.648400 | 5039.3164 |
| 3 | 17215.452662 | 6244.648400 | 0.0000 |
| 4 | 18211.492790 | 6244.648400 | 996.0401 |
| 5 | 18204.820174 | 1695.891177 | 4548.7621 |
| | | **Gesamtlaenge** | **10584.1187** |
---
## Polylinie 5 - Handle `15A`
| Feld | Wert |
|---|---|
| Handle (5) | `15A` |
| Layer (8) | `Z-6` |
| Stuetzpunkte (90) | 5 |
| Geschlossen (70) | 0 (offen) |
| Elevation (38) | 2266.737126 |
| Extrusion (210/220/230) | Standard (0/0/1) |
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
| Nr. | X | Y | Laenge zum Vorgaenger |
|---:|---:|---:|---:|
| 1 | 21480.091053 | 1205.360995 | - |
| 2 | 21497.190328 | 6244.648400 | 5039.3164 |
| 3 | 21497.190328 | 6244.648400 | 0.0000 |
| 4 | 22493.230455 | 6244.648400 | 996.0401 |
| 5 | 22486.557840 | 1695.891177 | 4548.7621 |
| | | **Gesamtlaenge** | **10584.1187** |
---
## Zusammenfassung
| Nr. | Handle | Stuetzpunkte | Gesamtlaenge | kuerzestes Segment | laengstes Segment |
|---:|---|---:|---:|---:|---:|
| 1 | `CA` | 5 | 10302.0414 | 837.3327 | 4583.4597 |
| 2 | `CD` | 6 | 14788.5119 | 904.9456 | 5854.8433 |
| 3 | `FB` | 5 | 10584.1187 | 0.0000 | 5039.3164 |
| 4 | `12B` | 5 | 10584.1187 | 0.0000 | 5039.3164 |
| 5 | `15A` | 5 | 10584.1187 | 0.0000 | 5039.3164 |
## Auffaelligkeiten
- Polylinie 1 (`CA`) liegt in einer gekippten Ebene (Extrusion ca. 2.5 Grad
gegen die Z-Achse) und ist die einzige mit OCS-Koordinaten.
- Die Polylinien 3 (`FB`), 4 (`12B`) und 5 (`15A`) sind formgleich und nur in
X versetzt (Versatz ca. 4306.56 bzw. 4281.74).
- In den Polylinien 3, 4 und 5 ist jeweils Punkt 3 ein **Duplikat** von Punkt 2
(Segmentlaenge 0.0000). Falls diese Daten weiterverarbeitet werden, sollten
die Nullsegmente vorher entfernt werden.
+18 -7
View File
@@ -46,7 +46,8 @@ from ezdxf.math import Matrix44
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dxf_scan_components import ( # noqa: E402
RUND, familie_von, insert_matrix, lese_inserts, im_fenster)
RUND, darstellung_von, familie_von, insert_matrix, lese_inserts,
im_fenster)
PREFIX = "[dxf_abbild]"
@@ -146,13 +147,19 @@ def welt_daten(rec, mutter, block_base):
return mat, ursprung, rot
def element_dict(rec, ursprung, rot, tiefe):
def element_dict(rec, ursprung, rot, tiefe, block_info=None,
dim_memo=None):
"""Einen INSERT als Abbild-Eintrag beschreiben."""
attribs = dict(rec["attribs"])
typ, typ_quelle = typ_von(attribs, rec["name"])
eintrag = collections.OrderedDict()
eintrag["block"] = rec["name"]
eintrag["layer"] = rec["layer"] or ""
if block_info is not None:
# 2D-Schema oder 3D-Modell - die Zeichnung enthaelt beides fuer
# dieselbe Anlage (siehe dxf_scan_components.TYPEN_3D).
eintrag["darstellung"] = darstellung_von(rec["name"], block_info,
dim_memo)
eintrag["typ"] = typ
eintrag["typ_quelle"] = typ_quelle
eintrag["position"] = [round(ursprung.x, RUND) + 0.0,
@@ -183,13 +190,14 @@ def element_dict(rec, ursprung, rot, tiefe):
def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None,
max_tiefe=12, ohne_container=False):
max_tiefe=12, ohne_container=False, block_info=None):
"""Alle Modelspace-INSERTs als Abbild-Baum beschreiben.
Rueckgabe: (elemente, statistik). Jedes Element der obersten Ebene enthaelt
seine aufgeloesten Unterkomponenten unter "unterkomponenten".
"""
stat = collections.Counter()
dim_memo = {}
def kinder_von(name, mat, tiefe, pfad):
"""Verschachtelte INSERTs eines Blocks rekursiv beschreiben."""
@@ -203,7 +211,8 @@ def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None,
stat["container_uebersprungen"] += 1
continue
kind_mat, ursprung, rot = welt_daten(rec, mat, block_base)
eintrag = element_dict(rec, ursprung, rot, tiefe)
eintrag = element_dict(rec, ursprung, rot, tiefe, block_info,
dim_memo)
stat["unterkomponenten"] += 1
if rec["name"] not in pfad:
enkel = kinder_von(rec["name"], kind_mat, tiefe + 1,
@@ -224,7 +233,7 @@ def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None,
if ohne_container and GEOMETRIE_CONTAINER.match(rec["name"]):
stat["container_uebersprungen"] += 1
continue
eintrag = element_dict(rec, ursprung, rot, 0)
eintrag = element_dict(rec, ursprung, rot, 0, block_info, dim_memo)
eintrag["nr"] = nr
eintrag.move_to_end("nr", last=False)
kinder = kinder_von(rec["name"], mat, 1, frozenset({rec["name"]}))
@@ -264,14 +273,16 @@ def main():
groesse = os.path.getsize(args.dxf) / (1024.0 * 1024.0)
print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, groesse))
ms_inserts, block_inserts, block_base, statistik = lese_inserts(args.dxf)
(ms_inserts, block_inserts, block_base, statistik,
block_info) = lese_inserts(args.dxf)
print("%s %d INSERTs im Modelspace, %d INSERTs gesamt, %d mit gekippter OCS"
% (PREFIX, len(ms_inserts), statistik["inserts"],
statistik["ocs_gekippt"]))
elemente, stat = baue_abbild(ms_inserts, block_inserts, block_base,
fenster=fenster, max_tiefe=args.max_tiefe,
ohne_container=args.ohne_geometrie_container)
ohne_container=args.ohne_geometrie_container,
block_info=block_info)
# Kennzahlen, die belegen, wie belastbar die Typangaben sind
quellen = collections.Counter()
+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)
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
extract_polylines.py - Liest LWPOLYLINE-Objekte aus einem DXF und schreibt
sie als JSON (Handle, Layer, Stuetzpunkte in OCS und Weltkoordinaten,
Segment-/Gesamtlaengen).
Hintergrund: data/polylines.dxf enthaelt 5 LWPOLYLINE-Objekte auf Layer
"Z-6" - Streckenzuege, die reale Foerderer-Pfade der HundM05-Zeichnung
nachbilden (siehe data/polylines.md, von Hand ausgewertet). Dieses Skript
macht dieselbe Auswertung reproduzierbar und maschinenlesbar, als Grundlage
fuer einen neuen Testfall (siehe tests/testdata/object_data.md fuer das
allgemeine JSON-Schema).
Eine Polylinie kann auf einer gekippten Ebene liegen (Gruppencode
210/220/230 != (0,0,1)); die Gruppencodes 10/20 sind dann OCS-, keine
Weltkoordinaten. Die Umrechnung nutzt ezdxf (Arbitrary-Axis-Algorithmus),
darum reicht hier ezdxf.readfile() statt eines eigenen Streaming-Parsers -
polylines.dxf ist mit ca. 124 MB ASCII-DXF deutlich kleiner als die
binaeren GB-Zeichnungen, fuer die dxf_scan_components.py den mmap-Weg
braucht.
Aufruf:
python extract_polylines.py <dxf> <output.json> [--layer Z-6]
"""
import argparse
import json
import math
import os
import re
import sys
import ezdxf
PREFIX = "[extract_polylines]"
RUND = 4
def arrays_einzeilig(text):
"""Rein numerische JSON-Arrays auf eine Zeile ziehen (wie in
dxf_scan_components.py) - noetig, falls die Datei spaeter ueber
ssg-parse-json-array (zeilenweiser LISP-Parser) gelesen wird."""
def zusammen(m):
return "[" + " ".join(m.group(1).split()) + "]"
return re.sub(r"\[([\s\d.,+\-eE]*?)\]", zusammen, text)
def lese_polylinien(dxf_path, layer):
doc = ezdxf.readfile(dxf_path)
msp = doc.modelspace()
ergebnis = []
for pl in msp.query("LWPOLYLINE"):
if layer and pl.dxf.layer != layer:
continue
elevation = pl.dxf.elevation
ocs = pl.ocs()
punkte_ocs = [(p[0], p[1]) for p in pl.get_points("xy")]
punkte_welt = [ocs.to_wcs((x, y, elevation)) for x, y in punkte_ocs]
laengen = [0.0]
for i in range(1, len(punkte_welt)):
a, b = punkte_welt[i - 1], punkte_welt[i]
laengen.append(math.dist((a.x, a.y, a.z), (b.x, b.y, b.z)))
ergebnis.append({
"handle": pl.dxf.handle,
"layer": pl.dxf.layer,
"geschlossen": bool(pl.closed),
"elevation": round(elevation, RUND),
"extrusion": [round(v, RUND) for v in tuple(pl.dxf.extrusion)],
"anzahl_punkte": len(punkte_welt),
"punkte_ocs": [[round(x, RUND), round(y, RUND)]
for x, y in punkte_ocs],
"punkte_welt": [[round(p.x, RUND), round(p.y, RUND),
round(p.z, RUND)] for p in punkte_welt],
"segment_laengen": [round(l, RUND) for l in laengen],
"gesamtlaenge": round(sum(laengen), RUND),
})
return ergebnis
def main():
parser = argparse.ArgumentParser(
description="Liest LWPOLYLINE-Objekte aus einem DXF und schreibt "
"sie (inkl. OCS->Welt-Umrechnung) als JSON.")
parser.add_argument("dxf", help="Quellzeichnung (ASCII- oder "
"binaeres DXF)")
parser.add_argument("output", help="Ziel-JSON")
parser.add_argument("--layer", default="Z-6",
help="Nur Polylinien auf diesem Layer (Default: "
"Z-6, leer = alle Layer)")
args = parser.parse_args()
if not os.path.isfile(args.dxf):
print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, args.dxf))
sys.exit(1)
groesse = os.path.getsize(args.dxf) / (1024.0 * 1024.0)
print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, groesse))
ergebnis = lese_polylinien(args.dxf, args.layer)
print("%s %d LWPOLYLINE-Objekte auf Layer '%s' gefunden"
% (PREFIX, len(ergebnis), args.layer or "*"))
for e in ergebnis:
print("%s Handle %-4s %d Punkte Gesamtlaenge %.2f mm%s"
% (PREFIX, e["handle"], e["anzahl_punkte"], e["gesamtlaenge"],
" (gekippte Ebene)" if e["extrusion"] != [0.0, 0.0, 1.0]
else ""))
ziel_dir = os.path.dirname(os.path.abspath(args.output))
if ziel_dir and not os.path.isdir(ziel_dir):
os.makedirs(ziel_dir)
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(arrays_einzeilig(json.dumps(ergebnis, ensure_ascii=False,
indent=2)))
fh.write("\n")
print("%s %d Eintraege geschrieben -> %s"
% (PREFIX, len(ergebnis), args.output))
if __name__ == "__main__":
main()
+654
View File
@@ -0,0 +1,654 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
vf_journal_export.py - Liest die SSG_VF_EDIT-Eingabe-Journale der VF_n-Bloecke
aus einer Zeichnung und schreibt sie als Frage-Antwort-Protokoll (JSON) im
Schema von tests/testdata/linienzug_tests.json.
Hintergrund: Der Linienzug-Modus (Lisp/vf_linienzug.lsp) ist nicht scriptbar -
er fragt jede Eingabe einzeln ab (getpoint/getstring/getint/getreal) und
zeichnet sie in einem Journal auf, das nach dem Bau als XDATA (App
SSG_VF_EDIT, Marker "linienzug") am fertigen VF_n-Block landet. Dieses Journal
ist die vollstaendige, wiederabspielbare Bau-Anleitung der Kette
(vfl-journal-replay-start). Dieses Skript dreht die Serialisierung aus
vfl-entry->string wieder zurueck und annotiert jeden Wert mit der Frage, die
er beantwortet - abgeleitet aus dem Frageablauf von vf-linienzug-modus.
Bewusst OHNE ezdxf: gebraucht werden nur INSERT-Kopf (Blockname, Einfuegepunkt,
Drehung) und die 1000er-Gruppen der XDATA - das ist reine Gruppencode-Paar-
Lesung und funktioniert auch bei ASCII-DXF im 100-MB-Bereich.
Zwei Eigenheiten der Quelldaten, die hier aufgeloest werden:
1. Das Newline zwischen zwei Journal-Eintraegen steht im ASCII-DXF als
Steuerzeichen-Notation "^J" (zwei Zeichen) - ein echtes 0x0A wuerde die
zeilenweise DXF-Struktur zerreissen. Getrennt wird darum an "^J".
2. Kopierte Ketten teilen ihr Journal: die vier VF_46-Bloecke in
data/polylines.dxf sind Kopien EINER gebauten Kette und tragen deshalb
Wort fuer Wort dasselbe Journal - inklusive des Startpunkts der zuerst
gebauten Kette. Alle uebrigen Eintraege sind relativ (Distanz, Winkel,
Zielhoehen), nur dieser eine Punkt ist absolut. Er wird darum je Instanz
auf deren echten Blockeinfuegepunkt gezogen (--kein-startpunkt-fix
schaltet das ab), damit jeder Eintrag fuer sich abspielbar ist.
Aufruf:
python vf_journal_export.py <dxf> <output.json> [--csv <sivas_export.csv>]
[--kein-startpunkt-fix]
Beispiel (erzeugt tests/testdata/hundm05.json):
python lib/vf_journal_export.py data/polylines.dxf \
tests/testdata/hundm05.json --csv results/HundM_export.csv
"""
import argparse
import csv
import json
import os
import re
import sys
PREFIX = "[vf_journal_export]"
XDATA_APP = "SSG_VF_EDIT"
MARKER = "linienzug"
# Steuerzeichen-Notation des Eintrags-Trenners im ASCII-DXF (siehe Kopf).
TRENNER = "^J"
RUND = 6
# Antwort-Klartexte der Menues aus vf_linienzug.lsp (nur zur Annotation).
JA_NEIN = {"1": "Ja", "2": "Nein"}
SEITE = {"1": "links", "2": "rechts"}
VARIANTE = {"1": "aussen", "2": "innen"}
GF_MODUS = {"1": "ueber Zielhoehe", "2": "ueber Neigungswinkel"}
ENDPUNKT3 = {"1": "Ja, nur Motorstation", "2": "Nein, weiterbauen",
"3": "Ja, Motorstation + Kettenende am Zielpunkt"}
KETTENENDE3 = {"1": "Ja, mit ES-Element", "2": "Ja, ohne ES-Element",
"3": "Nein, weiterbauen"}
KETTENENDE2 = {"1": "Ja, Separator + ES-Element", "2": "Nein, weiterbauen"}
VERTEILUNG = {"1": "halbe Staustrecke am Ausgang", "2": "alles am Einlauf"}
IM_VF = {"1": "Horizontaler Foerderer", "2": "Vario-Kurve",
"3": "Auf/Ab-Foerderer"}
MENU_START = {"1": "Neue Linie: GF", "2": "Neue Linie: Ab/Auf VF",
"3": "Horizontal-VF", "4": "Neue Linie bis Kettenende"}
MENU_WEITER = {"1": "GF-Bogen", "2": "Neue Linie: GF",
"3": "Neue Linie: Ab/Auf VF", "4": "Horizontal-VF",
"5": "Neue Linie bis Kettenende"}
STEP_KOMMENTAR = ("Glied-Marker (vfl-journal-mark) - beim Replay "
"uebersprungen, Label = gewaehlter Segmenttyp")
# ============================================================
# DXF lesen
# ============================================================
def gruppen(dxf_pfad):
"""Alle (Gruppencode, Wert)-Paare der Datei."""
with open(dxf_pfad, "r", encoding="utf-8", errors="replace") as fh:
zeilen = fh.read().splitlines()
return [(zeilen[i].strip(), zeilen[i + 1])
for i in range(0, len(zeilen) - 1, 2)]
def lese_vf_inserts(dxf_pfad):
"""INSERTs mit SSG_VF_EDIT-XDATA einsammeln.
Rueckgabe: Liste von Dicts (handle, block, punkt, rotation, marker,
journal_roh). Das Journal steckt in den 1000er-Gruppen NACH dem Marker
und ist auf 250 Zeichen je Gruppe gestueckelt (DXF-Limit 255).
"""
paare = gruppen(dxf_pfad)
treffer = []
i = 0
while i < len(paare):
code, wert = paare[i]
if code != "0" or wert != "INSERT":
i += 1
continue
e = {"handle": None, "block": None, "x": 0.0, "y": 0.0, "z": 0.0,
"rotation": 0.0, "marker": None, "chunks": []}
in_app = False
j = i + 1
while j < len(paare) and paare[j][0] != "0":
c, v = paare[j]
if c == "5" and e["handle"] is None:
e["handle"] = v
elif c == "2" and e["block"] is None:
e["block"] = v
elif c == "10":
e["x"] = float(v)
elif c == "20":
e["y"] = float(v)
elif c == "30":
e["z"] = float(v)
elif c == "50":
e["rotation"] = float(v)
elif c == "1001":
in_app = (v == XDATA_APP)
elif c == "1000" and in_app:
if e["marker"] is None:
e["marker"] = v
else:
e["chunks"].append(v)
j += 1
if e["marker"] is not None:
e["punkt"] = [e["x"], e["y"], e["z"]]
e["journal_roh"] = "".join(e["chunks"])
del e["chunks"]
treffer.append(e)
i = j
return treffer
# ============================================================
# Journal dekodieren
# ============================================================
class Journal(object):
"""Ein Journal (kind:payload je Zeile) in das Frage-Antwort-Protokoll
uebersetzen. Der Ablauf folgt vf-linienzug-modus (Modus 1):
Praeambel: Startpunkt, Starthoehe, AS-Element (Ja/Nein, Winkel, Seite)
Hauptschleife je Glied: STEP-Marker, Menuewahl, dann die Fragen des
gewaehlten Segmenttyps
Innerhalb einer VF-Einheit (Horizontal-VF / Linie): Separator vor/nach,
"Ist der Endpunkt der Foerderer?", danach Horizontal / Vario-Kurve /
Auf-Ab bis die Einheit schliesst
Kettenende: ES-Glied (Winkel, Seite)
"""
def __init__(self, roh):
self.tok = []
for zeile in roh.split(TRENNER):
if zeile:
art, _, wert = zeile.partition(":")
self.tok.append((art, wert))
self.pos = 0
self.out = []
self.erster_step = True
self.erster_dl = True
# --- Tokenzugriff ---
def art(self):
return self.tok[self.pos][0] if self.pos < len(self.tok) else None
def label(self):
return self.tok[self.pos][1] if self.pos < len(self.tok) else None
def nimm(self, erwartet):
if self.pos >= len(self.tok):
raise ValueError("Journal endet vor %s" % erwartet)
art, wert = self.tok[self.pos]
if art != erwartet:
raise ValueError("Token %d: %s erwartet, %s:%s gefunden"
% (self.pos, erwartet, art, wert))
self.pos += 1
return wert
# --- Ausgabe ---
def add(self, typ, kommentar, **felder):
eintrag = {"typ": typ}
eintrag.update(felder)
eintrag["kommentar"] = kommentar
self.out.append(eintrag)
def frage_str(self, kommentar_fmt, tabelle=None):
w = self.nimm("STR")
self.add("string", kommentar_fmt % (tabelle.get(w, "?") if tabelle
else w), wert=w)
return w
def frage_real(self, kommentar_fmt):
w = float(self.nimm("REAL"))
self.add("real", kommentar_fmt % w, wert=w)
return w
def frage_int(self, kommentar_fmt):
w = int(self.nimm("INT"))
self.add("int", kommentar_fmt % w, wert=w)
return w
def marker(self, label):
self.add("step", STEP_KOMMENTAR, wert=label)
def frage_linie(self, was):
"""Segmentlaenge; die gesnappte Fahrtrichtung steht nur beim ersten
Segment der Kette im Journal (vfl-in-abstand), danach erbt jedes
Segment die Richtung des Vorgaengers."""
dl = float(self.nimm("DL"))
if self.erster_dl and self.art() == "REAL":
hz = float(self.nimm("REAL"))
self.erster_dl = False
self.add("point_rel",
"getpoint %s: dL=%.1f mm, gesnappte Fahrtrichtung "
"hz=%.0f Grad (nur beim ersten Segment der Kette "
"journalisiert)" % (was, dl, hz), dL=dl, hz=hz)
else:
self.erster_dl = False
self.add("point_rel",
"getpoint %s: dL=%.1f mm (Fahrtrichtung vom "
"Vorgaenger geerbt)" % (was, dl), dL=dl)
return dl
# --- Ablauf ---
def dekodiere(self):
self.praeambel()
while self.pos < len(self.tok):
label = self.nimm("STEP")
self.marker(label)
if label == "ES":
self.glied_es(marker_gelesen=True)
continue
if self.erster_step:
tabelle, kopf = MENU_START, (
"Naechstes Element am Kettenanfang (1=Neue Linie GF, "
"2=Neue Linie Ab/Auf VF, 3=Horizontal-VF, 4=Neue Linie "
"bis Kettenende) -> %s")
else:
tabelle, kopf = MENU_WEITER, (
"Naechstes Element (1=GF-Bogen, 2=Neue Linie GF, "
"3=Neue Linie Ab/Auf VF, 4=Horizontal-VF, 5=Neue Linie "
"bis Kettenende) -> %s")
self.frage_str("getstring " + kopf, tabelle)
self.erster_step = False
if label == "Linie-GF":
self.glied_linie_gf()
elif label == "Linie-VF":
self.glied_linie_vf()
elif label == "Horizontal-VF":
self.glied_horizontal_vf()
elif label == "Linie":
self.glied_linie_kettenende()
elif label == "GF-Bogen":
self.frage_int("getint GF-Bogen Winkel (30/60/90 Grad) "
"-> %d")
self.frage_str("getstring GF-Bogen Seite (1=links, "
"2=rechts) -> %s", SEITE)
else:
raise ValueError("unbekanntes Glied: %s" % label)
if self.pos != len(self.tok):
raise ValueError("nicht alle Tokens verbraucht (%d von %d)"
% (self.pos, len(self.tok)))
return self.out
def praeambel(self):
punkt = [float(v) for v in self.nimm("PT").split(",")]
self.add("point_abs", "getpoint Startpunkt der Kette (absoluter "
"Weltpunkt, einziger Anker der ganzen Kette)", wert=punkt)
self.frage_real("getreal Hoehe (Z) Startpunkt -> %.1f mm")
if self.frage_str("getstring AS-Element setzen? (1=Ja, 2=Nein) "
"-> %s", JA_NEIN) == "1":
w = self.nimm("STR")
self.add("string", "getstring AS-Element Winkel (Wert direkt, "
"90 oder 30 Grad) -> %s Grad" % w, wert=w)
self.frage_str("getstring AS-Seite (1=links, 2=rechts) -> %s",
SEITE)
def glied_linie_gf(self):
self.frage_linie("Endpunkt der GF-Linie")
if self.frage_str("getstring Gefaelle festlegen (1=ueber Zielhoehe, "
"2=ueber Neigungswinkel) -> %s", GF_MODUS) == "2":
self.frage_real("getreal Neigungswinkel -> %.1f Grad")
else:
self.frage_real("getreal Zielhoehe (Z) Linienendpunkt "
"-> %.1f mm")
if self.frage_str("getstring Ist das Kettenende? (1=Ja mit ES, "
"2=Ja ohne ES, 3=Nein weiterbauen) -> %s",
KETTENENDE3) == "1":
self.glied_es()
def glied_horizontal_vf(self):
"""VF-Einheit mit HORIZONTALEM Anfangskoerper (winkel1=0 in
vfl-vf-einheit): dort stellt vfl-baue-horizontal-koerper im
Ziel-Modus die Separator-/Endpunkt-Fragen VOR der
Laengenberechnung."""
self.frage_linie("Endpunkt des horizontalen VF-Stuecks")
self.vf_einheit(auto_ende=False, erster_horizontal=True)
def glied_linie_vf(self):
""""Neue Linie: Ab/Auf VF" - Laenge, Zielhoehe, ggf. Winkelwahl
(vfl-vf-entscheidung), dann eine VF-Einheit mit GEWINKELTEM
Anfangskoerper. Zu dem Koerper wird nichts gefragt (vfl-vf-einheit
baut ihn direkt), die erste Frage der Einheit ist also die
Endpunkt-Frage der Fortsetzungsschleife."""
self.frage_linie("Endpunkt des Ab/Auf-VF-Segments")
self.frage_real("getreal Zielhoehe (Z) Linienendpunkt -> %.1f mm")
self.frage_winkelwahl()
self.vf_einheit(auto_ende=False, erster_horizontal=False)
def glied_linie_kettenende(self):
""""Neue Linie bis Kettenende": Laenge + Zielhoehe, danach entscheidet
vfl-segment-entscheidung selbst. Reine Gefaellestrecke -> ES-Element
ohne weitere Frage; VF-Einheit -> vf_einheit mit auto-ende."""
self.frage_linie("Endpunkt der Linie bis Kettenende (Ziel-XY der "
"Kette)")
self.frage_real("getreal Zielhoehe (Z) am Kettenende -> %.1f mm")
self.frage_winkelwahl()
if self.art() == "STEP" and self.label() == "ES":
self.glied_es()
elif self.art() == "STR":
# vfl-segment-entscheidung liefert fuer typ="VF" immer einen
# Winkel > 0 (die VF-Winkelstufen beginnen bei 3 Grad), der erste
# Koerper ist hier also nie horizontal.
self.vf_einheit(auto_ende=True, erster_horizontal=False)
def glied_es(self, marker_gelesen=False):
if not marker_gelesen:
label = self.nimm("STEP")
if label != "ES":
raise ValueError("ES-Marker erwartet, %s gefunden" % label)
self.marker(label)
w = self.nimm("STR")
self.add("string", "getstring ES-Element Winkel (Wert direkt, 90 "
"oder 30 Grad) -> %s Grad" % w, wert=w)
self.frage_str("getstring ES-Seite (1=links, 2=rechts) -> %s", SEITE)
def frage_winkelwahl(self):
"""Winkelwahl (vfl-waehle-winkel) steht nur im Journal, wenn mehrere
Kandidatenwinkel gueltig waren - bei genau einem faellt die Frage
weg."""
if self.art() == "INT":
self.frage_int("getint Winkelwahl aus den gueltigen "
"Kandidaten (vfl-waehle-winkel) -> Option %d")
def horizontal_koerper(self):
"""vfl-baue-horizontal-koerper im Ziel-Modus: Separator vor/nach und
die Endpunkt-Frage stehen VOR der Laengenberechnung. Rueckgabe: die
Endpunkt-Antwort, die die Fortsetzungsschleife weiterverwendet."""
self.frage_str("getstring Separator VOR dem horizontalen Stueck? "
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
self.frage_str("getstring Separator NACH dem horizontalen Stueck? "
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
return self.frage_str("getstring Ist der Endpunkt der Foerderer? "
"(1=Ja nur Motorstation, 2=Nein weiterbauen, "
"3=Ja Motorstation + Kettenende) -> %s",
ENDPUNKT3)
def vario_kurve(self):
label = self.nimm("STEP")
if label != "Vario-Kurve":
raise ValueError("Vario-Kurve-Marker erwartet, %s" % label)
self.marker(label)
self.frage_int("getint Vario-Kurve Winkel (30/60/90 Grad, Wert "
"direkt) -> %d Grad")
self.frage_str("getstring Vario-Kurve Seite (1=links, 2=rechts) "
"-> %s", SEITE)
self.frage_str("getstring Vario-Kurve Variante (1=aussen, 2=innen) "
"-> %s", VARIANTE)
def vf_einheit(self, auto_ende, erster_horizontal):
"""Eine VF-Einheit: GF-Verteilung, erster Koerper, dann die
Fortsetzungsschleife (vfl-vf-einheit) bis Motorstation/Kettenende,
danach der Abschluss (vfl-vf-einheit-abschluss).
erster_horizontal spiegelt die Verzweigung ueber winkel1 in
vfl-vf-einheit: nur der horizontale Erstkoerper laeuft durch
vfl-baue-horizontal-koerper und fragt darum Separator vor/nach und
die Endpunkt-Frage schon vorab (deren Antwort die Schleife dann
uebernimmt, statt erneut zu fragen). Ein gewinkelter Erstkoerper
wird ohne jede Frage gebaut."""
self.frage_str("getstring GF-Verteilung (1=halbe Staustrecke am "
"Ausgang, 2=alles am Einlauf) -> %s", VERTEILUNG)
vorantwort = self.horizontal_koerper() if erster_horizontal else None
ziel_ende = False
es_gewuenscht = True
while True:
if vorantwort is None:
antwort = self.frage_str(
"getstring Ist der Endpunkt der Foerderer? (1=Ja nur "
"Motorstation, 2=Nein weiterbauen, 3=Ja Motorstation + "
"Kettenende) -> %s", ENDPUNKT3)
else:
antwort, vorantwort = vorantwort, None
if antwort == "1":
break
if antwort == "3":
es_gewuenscht = self.frage_str(
"getstring ES-Element setzen? (1=Ja, 2=Nein - Kette "
"endet direkt am Zielpunkt) -> %s", JA_NEIN) != "2"
self.frage_linie("Zielpunkt des Kettenendes")
self.frage_real("getreal Zielhoehe (Z) am Kettenende "
"-> %.1f mm")
self.frage_winkelwahl()
ziel_ende = True
break
wahl = self.frage_str("getstring Naechstes in der VF-Einheit "
"(1=Horizontaler Foerderer, "
"2=Vario-Kurve, 3=Auf/Ab-Foerderer) "
"-> %s", IM_VF)
if wahl == "1":
self.frage_linie("Endpunkt des horizontalen VF-Stuecks")
vorantwort = self.horizontal_koerper()
elif wahl == "2":
self.vario_kurve()
else:
self.frage_linie("Endpunkt des Auf/Ab-VF-Segments")
self.frage_real("getreal Zielhoehe (Z) am Segmentende "
"-> %.1f mm")
self.frage_winkelwahl()
if ziel_ende and not es_gewuenscht:
return
if auto_ende or ziel_ende:
self.glied_es()
return
if self.frage_str("getstring Ist das Kettenende? (1=Ja, Separator + "
"ES-Element, 2=Nein, weiterbauen) -> %s",
KETTENENDE2) == "1":
self.glied_es()
else:
self.frage_str("getstring Separator an dieser Stelle einfuegen? "
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
# ============================================================
# Sivas-Export als Quervergleich
# ============================================================
def lese_strecken(csv_pfad):
"""Die "ILS 2.0 Strecke"-Zeilen eines Sivas-Exports (EXPORTSIVAS).
Dient nur der Zuordnung/Dokumentation: die Merkmale (DeltaL, DeltaH,
L_GF_m, L_VF_m, ...) sind die Attribute des fertigen VF_n-Blocks und
damit der Erwartungswert zu einem nachgebauten Foerderer.
"""
strecken = []
with open(csv_pfad, encoding="utf-8") as fh:
for zeile in csv.DictReader(fh, delimiter=";"):
if zeile.get("TeileArt") != "ILS 2.0 Strecke":
continue
if not zeile.get("Nachbarn"):
continue # unverbunden - gehoert zu keinem Kreisel
strecken.append({
"id": zeile["TeileId"],
"bezeichnung": zeile["Bezeichnung"],
"planquadrat": zeile["Planquadrat"],
"x": float(zeile["Position"].split(",")[0]),
"merkmale": json.loads(zeile["Merkmale"]),
})
return strecken
def erwartung_hinweis(strecke):
m = strecke["merkmale"]
felder = ["DeltaL_mm", "DeltaH_mm", "Hoehe_Von_mm", "Hoehe_Bis_mm",
"L_GF_m", "L_VF_m", "VF_Winkel", "Anzahl_Separator"]
werte = ", ".join("%s=%s" % (f, m[f]) for f in felder if f in m)
return ("Sivas-Export (EXPORTSIVAS), Strecke %s: %s"
% (strecke["id"], werte))
# ============================================================
# Kurzbeschreibung der Kette
# ============================================================
def kurzbeschreibung(eintraege):
"""Kette in einer Zeile: die Glied-Marker mit ihren Kernwerten."""
teile = []
for i, e in enumerate(eintraege):
if e["typ"] != "step":
continue
folge = eintraege[i + 1:i + 8]
strings = [x["wert"] for x in folge if x["typ"] == "string"]
ints = [x["wert"] for x in folge if x["typ"] == "int"]
reals = [x["wert"] for x in folge if x["typ"] == "real"]
laengen = [x["dL"] for x in folge if x["typ"] == "point_rel"]
label = e["wert"]
if label == "Vario-Kurve":
teile.append("Vario-Kurve %d %s %s"
% (ints[0], SEITE.get(strings[0], "?"),
VARIANTE.get(strings[1], "?")))
elif label == "ES":
teile.append("ES %s %s" % (strings[0], SEITE.get(strings[1], "?")))
elif label == "Linie-GF":
teile.append("GF-Linie %.0f mm / %.0f Grad"
% (laengen[0], reals[0]))
elif label == "Horizontal-VF":
teile.append("Horizontal-VF %.0f mm" % laengen[0])
elif label == "Linie":
teile.append("Linie bis Kettenende %.0f mm auf Z%.0f"
% (laengen[0], reals[0]))
elif label == "GF-Bogen":
teile.append("GF-Bogen %d %s" % (ints[0],
SEITE.get(strings[0], "?")))
return " -> ".join(teile)
# ============================================================
# Testfaelle bauen
# ============================================================
def baue_testfaelle(inserts, strecken, startpunkt_fix, quelle_datei,
ziel_datei):
"""FLACHE Testfall-Liste: je Kette ein Kopf-Objekt (traegt "test_id"),
danach ihre Eingaben (tragen "typ") - Struktur wie linienzug_tests.json.
Bewusst NICHT verschachtelt ("eingaben": [...]): ssg-load-json /
ssg-parse-json-array (Lisp/ssg_core.lsp) liest die Datei ZEILENWEISE und
behandelt jedes "{" als Objektanfang. Ein verschachteltes Array wuerde die
Kopf-Objekte verschlucken. Der Treiber gruppiert wieder ueber "test_id"
(siehe hundm05:gruppiere in tests/test_hundm05.lsp).
"""
# Anlagenkuerzel der test_id kommt aus dem Namen der Ziel-JSON (die
# Testfall-Sammlung), nicht aus dem Zeichnungsnamen: tests/testdata/
# hundm05.json -> VF_hundm05_LZ_01 .. _nn.
praefix = os.path.splitext(os.path.basename(ziel_datei))[0]
faelle = []
for nr, e in enumerate(sorted(inserts, key=lambda i: i["x"]), start=1):
eintraege = Journal(e["journal_roh"]).dekodiere()
journal_start = list(eintraege[0]["wert"])
punkt = [round(v, RUND) for v in e["punkt"]]
if startpunkt_fix:
eintraege[0]["wert"] = punkt
eintraege[1]["wert"] = punkt[2]
eintraege[1]["kommentar"] = ("getreal Hoehe (Z) Startpunkt "
"-> %.1f mm" % punkt[2])
else:
punkt = [round(v, RUND) for v in journal_start]
strecke = None
if strecken:
strecke = min(strecken, key=lambda s: abs(s["x"] - e["x"]))
kopf = {
"test_id": "VF_%s_LZ_%02d" % (praefix, nr),
# "anlagetyp", nicht "typ": "typ" markiert im flachen Array einen
# EINGABE-Eintrag (point_abs/real/string/int/step) - ein Kopf mit
# "typ" waere davon nicht zu unterscheiden.
"anlagetyp": "linienzug",
"modus": 1,
"anzahl_eingaben": len(eintraege),
"block": e["block"],
"dxf_handle": e["handle"],
"startpunkt_mm": punkt,
"rotation_grad": round(e["rotation"], 4),
"beschreibung": "Linienzug (Modus 1), Start Z%.0f: %s"
% (punkt[2], kurzbeschreibung(eintraege)),
}
if strecke:
kopf["sivas_id"] = strecke["id"]
kopf["sivas_bezeichnung"] = strecke["bezeichnung"]
kopf["planquadrat"] = strecke["planquadrat"]
kopf["erwartung_hinweis"] = erwartung_hinweis(strecke)
# Ohne Anfuehrungszeichen im Text: ssg-cfg-parse-value schneidet einen
# String am ersten/letzten Zeichen ab und kennt keine Escapes.
kopf["quelle"] = ("INSERT %s (Block %s) in %s, XDATA-App %s, Marker "
"%s; im Journal aufgezeichneter Kettenstart: [%s]"
% (e["handle"], e["block"],
os.path.basename(quelle_datei), XDATA_APP,
e["marker"],
", ".join("%.6f" % v for v in journal_start)))
faelle.append(kopf)
faelle.extend(eintraege)
return faelle
def arrays_einzeilig(text):
"""Rein numerische JSON-Arrays auf eine Zeile ziehen (wie
dxf_scan_components.py) - ssg-parse-json-array (Lisp/ssg_core.lsp) liest
zeilenweise und kann ein umgebrochenes Zahlen-Array nicht lesen."""
def zusammen(m):
return "[" + " ".join(m.group(1).split()) + "]"
return re.sub(r"\[([\s\d.,+\-eE]*?)\]", zusammen, text)
def main():
p = argparse.ArgumentParser(
description="Liest die SSG_VF_EDIT-Eingabe-Journale der VF_n-Bloecke "
"einer Zeichnung und schreibt sie als Frage-Antwort-"
"Protokoll (Schema linienzug_tests.json).")
p.add_argument("dxf", help="Quellzeichnung (ASCII-DXF)")
p.add_argument("output", help="Ziel-JSON")
p.add_argument("--csv", help="Sivas-Export (EXPORTSIVAS) der gleichen "
"Zeichnung - liefert Strecken-ID und "
"Erwartungswerte je Kette")
p.add_argument("--kein-startpunkt-fix", action="store_true",
help="Kettenstart NICHT auf den Blockeinfuegepunkt "
"ziehen (Journal unveraendert uebernehmen)")
args = p.parse_args()
if not os.path.isfile(args.dxf):
print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, args.dxf))
sys.exit(1)
mb = os.path.getsize(args.dxf) / (1024.0 * 1024.0)
print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, mb))
inserts = lese_vf_inserts(args.dxf)
print("%s %d INSERT(s) mit %s-XDATA gefunden"
% (PREFIX, len(inserts), XDATA_APP))
inserts = [e for e in inserts if e["marker"].startswith(MARKER)]
if not inserts:
print("%s FEHLER: kein Linienzug-Journal (Marker \"%s\") in der "
"Zeichnung" % (PREFIX, MARKER))
sys.exit(1)
strecken = lese_strecken(args.csv) if args.csv else []
if args.csv:
print("%s %d verbundene Strecken im Sivas-Export"
% (PREFIX, len(strecken)))
faelle = baue_testfaelle(inserts, strecken,
not args.kein_startpunkt_fix, args.dxf,
args.output)
koepfe = [f for f in faelle if "test_id" in f]
for f in koepfe:
print("%s %s %s (%s) %d Eingaben"
% (PREFIX, f["test_id"], f["block"],
f.get("sivas_bezeichnung", "ohne Sivas-Zuordnung"),
f["anzahl_eingaben"]))
ziel_dir = os.path.dirname(os.path.abspath(args.output))
if ziel_dir and not os.path.isdir(ziel_dir):
os.makedirs(ziel_dir)
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(arrays_einzeilig(json.dumps(faelle, ensure_ascii=False,
indent=2)))
fh.write("\n")
print("%s %d Ketten / %d JSON-Objekte geschrieben -> %s"
% (PREFIX, len(koepfe), len(faelle), args.output))
if __name__ == "__main__":
main()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
test_vf_journal_grammatik.py - Prueft den Journal-Dekoder in
lib/vf_journal_export.py gegen handgebaute Journale.
Warum: der Dekoder ist die ZWEITE Implementierung der Linienzug-Grammatik
(erste ist der Bau-Ablauf in Lisp/vf_linienzug.lsp selbst). Nur wenn beide
dieselbe Frage-Reihenfolge kennen, laesst sich ein Journal aus einer echten
Zeichnung verlustfrei in das Testdatenschema zuruecklesen
(tests/testdata/hundm05.json).
Die 5 echten HundM-Ketten deckten zwei Zweige NICHT ab, weil ihre Daten sie
nicht enthalten:
- Glied "Linie-VF" ("Neue Linie: Ab/Auf VF")
- eine VF-Einheit mit GEWINKELTEM Erstkoerper - dort stellt
vfl-vf-einheit KEINE Separator-/Endpunkt-Frage vorab (das tut nur
vfl-baue-horizontal-koerper beim horizontalen Erstkoerper, winkel1=0)
Genau diese beiden Faelle sichern die Tests hier ab. Laeuft ohne CAD und ohne
die grosse DXF - die Journale stehen als Token-Listen im Test.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
import vf_journal_export as vje
def _journal(*tokens):
"""Token-Liste in die Rohform bringen, in der das Journal in der XDATA
steht: "ART:wert", verbunden mit dem Trenner (die zwei Zeichen "^J" -
ein echter Zeilenumbruch wuerde die zeilenweise DXF-Lesung brechen)."""
return vje.TRENNER.join(tokens)
PRAEAMBEL_OHNE_AS = ("PT:0.0,0.0,5000.0", "REAL:5000.0", "STR:2")
ES_GLIED = ("STEP:ES", "STR:90", "STR:1")
def _dekodiere(*tokens):
j = vje.Journal(_journal(*tokens))
out = j.dekodiere() # wirft, wenn Tokens uebrig bleiben
return out
def _typen(eintraege):
return [e["typ"] for e in eintraege]
def _labels(eintraege):
return [e["wert"] for e in eintraege if e["typ"] == "step"]
class TestHorizontalVF:
"""Kontrollfall: VF-Einheit mit horizontalem Erstkoerper. Dieser Zweig
kommt in den echten HundM-Ketten vor und muss unveraendert bleiben."""
TOKENS = PRAEAMBEL_OHNE_AS + (
"STEP:Horizontal-VF",
"STR:3", # erste Sektion, 4 Optionen: 3 = Horizontal-VF
"DL:8000.0",
"REAL:0.0", # gesnappte Fahrtrichtung (nur erstes Segment)
"STR:2", # GF-Verteilung: alles am Einlauf
"STR:2", # Separator VOR dem horizontalen Stueck? Nein
"STR:2", # Separator NACH dem horizontalen Stueck? Nein
"STR:1", # Endpunkt des Foerderers? Ja, nur Motorstation
"STR:1", # Kettenende? Ja, Separator + ES
) + ES_GLIED
def test_dekodiert_vollstaendig(self):
eintraege = _dekodiere(*self.TOKENS)
assert _labels(eintraege) == ["Horizontal-VF", "ES"]
assert _typen(eintraege)[:3] == ["point_abs", "real", "string"]
def test_separatorfragen_vorhanden(self):
"""Der horizontale Erstkoerper fragt Separator vor/nach - das ist der
Unterschied zum gewinkelten Erstkoerper."""
kommentare = " | ".join(e["kommentar"] for e in
_dekodiere(*self.TOKENS))
assert "Separator VOR" in kommentare
assert "Separator NACH" in kommentare
class TestLinieVF:
"""Der bis dahin fehlende Zweig: Glied "Linie-VF" mit gewinkeltem
Erstkoerper der VF-Einheit."""
TOKENS = PRAEAMBEL_OHNE_AS + (
"STEP:Linie-VF",
"STR:2", # erste Sektion, 4 Optionen: 2 = Neue Linie Ab/Auf VF
"DL:12000.0",
"REAL:0.0", # gesnappte Fahrtrichtung
"REAL:3000.0", # Zielhoehe des Linienendpunkts
# keine Winkelwahl: nur ein Kandidat war gueltig
"STR:2", # GF-Verteilung: alles am Einlauf
# KEINE Separator-/Endpunktfrage vorab
"STR:1", # Endpunkt des Foerderers? Ja, nur Motorstation
"STR:1", # Kettenende? Ja, Separator + ES
) + ES_GLIED
def test_dekodiert_vollstaendig(self):
eintraege = _dekodiere(*self.TOKENS)
assert _labels(eintraege) == ["Linie-VF", "ES"]
def test_keine_separatorfragen(self):
"""Ein gewinkelter Erstkoerper wird ohne Frage gebaut. Wuerde der
Dekoder hier - wie vor dem Fix - horizontal_koerper() aufrufen,
verschluckte er die Endpunkt-Antwort als Separator-Antwort und liefe
aus den Tokens heraus."""
kommentare = " | ".join(e["kommentar"] for e in
_dekodiere(*self.TOKENS))
assert "Separator VOR" not in kommentare
assert "Zielhoehe" in kommentare
def test_winkelwahl_optional(self):
"""vfl-waehle-winkel fragt nur bei mehreren gueltigen Kandidaten -
dann steht ein INT im Journal, sonst nicht."""
mit_wahl = list(self.TOKENS)
mit_wahl.insert(mit_wahl.index("REAL:3000.0") + 1, "INT:2")
eintraege = _dekodiere(*mit_wahl)
assert [e["typ"] for e in eintraege].count("int") == 1
def test_vario_kurve_in_der_einheit(self):
tokens = PRAEAMBEL_OHNE_AS + (
"STEP:Linie-VF",
"STR:2",
"DL:12000.0",
"REAL:0.0",
"REAL:3000.0",
"STR:2", # GF-Verteilung
"STR:2", # Endpunkt? Nein, weiterbauen
"STR:2", # Naechstes in der VF-Einheit: Vario-Kurve
"STEP:Vario-Kurve",
"INT:90",
"STR:1", # Seite links
"STR:1", # Variante aussen
"STR:1", # Endpunkt? Ja, nur Motorstation
"STR:2", # Kettenende? Nein, weiterbauen
"STR:2", # Separator an dieser Stelle? Nein
"STEP:Linie",
"STR:5", # Folge-Sektion, 5 Optionen: 5 = Linie bis Ende
"DL:4000.0",
"REAL:3000.0", # Zielhoehe am Kettenende
) + ES_GLIED
eintraege = _dekodiere(*tokens)
assert _labels(eintraege) == ["Linie-VF", "Vario-Kurve", "Linie", "ES"]
class TestFehlerfaelle:
"""Der Dekoder darf nicht raten: fehlende oder ueberzaehlige Tokens sind
ein Fehler, kein stiller Teilerfolg."""
def test_unbekanntes_glied(self):
with pytest.raises(ValueError):
_dekodiere(*(PRAEAMBEL_OHNE_AS + ("STEP:Gibtsnicht", "STR:1")))
def test_tokens_uebrig(self):
with pytest.raises(ValueError):
_dekodiere(*(TestLinieVF.TOKENS + ("STR:1",)))
def test_journal_zu_kurz(self):
with pytest.raises(ValueError):
_dekodiere(*TestLinieVF.TOKENS[:-1])
+124
View File
@@ -0,0 +1,124 @@
[
{
"handle": "CA",
"layer": "Z-6",
"geschlossen": false,
"elevation": -271.7589,
"extrusion": [-0.0, -0.0436, -0.9991],
"anzahl_punkte": 5,
"punkte_ocs": [
[8608.0283, -5622.4633],
[8608.1795, -6230.2512],
[9758.1665, -6230.2507],
[9758.1805, -5392.918],
[9752.5609, -1913.0356]
],
"punkte_welt": [
[8608.046, 5628.9379, 26.4861],
[8608.1991, 6236.1483, 0.0],
[9758.1861, 6236.1442, 0.0],
[9758.1975, 5399.607, 36.4891],
[9752.5669, 1923.0303, 188.1348]
],
"segment_laengen": [0.0, 607.7879, 1149.987, 837.3327, 3479.887],
"gesamtlaenge": 6074.9945
},
{
"handle": "CD",
"layer": "Z-6",
"geschlossen": false,
"elevation": 199.7371,
"extrusion": [0.0, 0.0, 1.0],
"anzahl_punkte": 6,
"punkte_ocs": [
[4927.0558, 1205.361],
[4932.563, 7060.2017],
[7106.355, 7060.2017],
[7106.1386, 4645.5146],
[6189.1673, 2991.5761],
[6176.2696, 1205.361]
],
"punkte_welt": [
[4927.0558, 1205.361, 199.7371],
[4932.563, 7060.2017, 199.7371],
[7106.355, 7060.2017, 199.7371],
[7106.1386, 4645.5146, 199.7371],
[6189.1673, 2991.5761, 199.7371],
[6176.2696, 1205.361, 199.7371]
],
"segment_laengen": [0.0, 5854.8433, 2173.7921, 2414.6871, 1891.1237, 1786.2617],
"gesamtlaenge": 14120.7078
},
{
"handle": "8BA1",
"layer": "Z-6",
"geschlossen": false,
"elevation": -2636.2483,
"extrusion": [-0.0, -0.0436, -0.9991],
"anzahl_punkte": 5,
"punkte_ocs": [
[12892.9127, -5519.3396],
[12893.0639, -6127.1274],
[14043.0509, -6127.1269],
[14043.0649, -5289.7943],
[14037.4453, -1809.9118]
],
"punkte_welt": [
[12892.9303, 5628.9379, 2393.2232],
[12893.0835, 6236.1483, 2366.7371],
[14043.0705, 6236.1442, 2366.7371],
[14043.0818, 5399.607, 2403.2263],
[14037.4513, 1923.0303, 2554.8719]
],
"segment_laengen": [0.0, 607.7879, 1149.987, 837.3327, 3479.887],
"gesamtlaenge": 6074.9945
},
{
"handle": "8BCF",
"layer": "Z-6",
"geschlossen": false,
"elevation": -2636.2489,
"extrusion": [-0.0, -0.0436, -0.9991],
"anzahl_punkte": 5,
"punkte_ocs": [
[17202.5751, -4259.1639],
[17203.0502, -6127.1409],
[18353.0372, -6127.1404],
[18353.0512, -5289.8078],
[18347.4315, -1809.9253]
],
"punkte_welt": [
[17202.5888, 4369.9458, 2448.1395],
[17203.0698, 6236.1483, 2366.7371],
[18353.0568, 6236.1442, 2366.7371],
[18353.0681, 5399.607, 2403.2263],
[18347.4376, 1923.0303, 2554.8719]
],
"segment_laengen": [0.0, 1867.9771, 1149.987, 837.3327, 3479.887],
"gesamtlaenge": 7335.1837
},
{
"handle": "8BFD",
"layer": "Z-6",
"geschlossen": false,
"elevation": -2536.3444,
"extrusion": [-0.0, -0.0436, -0.9991],
"anzahl_punkte": 5,
"punkte_ocs": [
[21481.2117, -5523.7243],
[21481.3629, -6131.5121],
[22631.3499, -6131.5116],
[22631.3639, -5294.179],
[22625.7443, -1814.2965]
],
"punkte_welt": [
[21481.2293, 5628.9379, 2293.2232],
[21481.3825, 6236.1483, 2266.7371],
[22631.3695, 6236.1442, 2266.7371],
[22631.3808, 5399.607, 2303.2263],
[22625.7503, 1923.0303, 2454.8719]
],
"segment_laengen": [0.0, 607.7879, 1149.987, 837.3327, 3479.887],
"gesamtlaenge": 6074.9945
}
]