diff --git a/.gitignore b/.gitignore index 6474cf6..86fe85e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/data/polylines.md b/data/polylines.md new file mode 100644 index 0000000..e76761e --- /dev/null +++ b/data/polylines.md @@ -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. diff --git a/lib/dxf_abbild.py b/lib/dxf_abbild.py index 20cfb83..537a85d 100644 --- a/lib/dxf_abbild.py +++ b/lib/dxf_abbild.py @@ -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() diff --git a/lib/dxf_scan_components.py b/lib/dxf_scan_components.py index 85f3db6..2cb37f2 100644 --- a/lib/dxf_scan_components.py +++ b/lib/dxf_scan_components.py @@ -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) diff --git a/lib/extract_polylines.py b/lib/extract_polylines.py new file mode 100644 index 0000000..2cc8088 --- /dev/null +++ b/lib/extract_polylines.py @@ -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 [--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() diff --git a/lib/vf_journal_export.py b/lib/vf_journal_export.py new file mode 100644 index 0000000..b10921d --- /dev/null +++ b/lib/vf_journal_export.py @@ -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 [--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() diff --git a/tests/test_vf_journal_grammatik.py b/tests/test_vf_journal_grammatik.py new file mode 100644 index 0000000..943a7c1 --- /dev/null +++ b/tests/test_vf_journal_grammatik.py @@ -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]) diff --git a/tests/testdata/hundm05_linienzug_polylines.json b/tests/testdata/hundm05_linienzug_polylines.json new file mode 100644 index 0000000..90ef88f --- /dev/null +++ b/tests/testdata/hundm05_linienzug_polylines.json @@ -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 + } +]