aaf47ab3c1
dxf_anlage_export.py und dxf_scan_components.py fuehrten dieselben Feldnamen (abstand, deltaL_mm, as_seite, ...) aus doc/json-input-lisp.md unabhaengig als lose String-Literale - ein Tippfehler oder eine Schema-Aenderung in nur einer Datei waere nicht aufgefallen. Neues Modul lib/testdata_schema.py buendelt die Feldnamen/festen Werte der dokumentierten Schemas (Kreisel, Einzelblock, VF Standard/Etage, Gefaellestrecke Modus 1) als benannte Konstanten; dxf_anlage_export.py nutzt sie jetzt durchgaengig. dxf_scan_components.py bewusst NICHT mitumgebaut: deutlich grösser/komplexer, mit zusaetzlichen Sonderfaellen (z.B. "startpunkt"/"gerade"-Form), ein vollstaendiger Umbau haette den Rahmen dieser Aenderung gesprengt und mehr Risiko als Nutzen gehabt. Verhaltensgleich: alle 9 Tests in tests/test_dxf_anlage_export.py laufen weiterhin gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
414 lines
17 KiB
Python
414 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
dxf_anlage_export.py - Eine mit unseren Makros GEBAUTE (und von Hand
|
|
nachbearbeitete) Anlage aus der Zeichnung zurueck in Nachbau-Testdaten
|
|
schreiben.
|
|
|
|
Gegenstueck zu lib/dxf_scan_components.py: der Scanner liest FREMDE
|
|
Zeichnungen (Rohbloecke, Linien) und muss vieles raten. Hier stehen unsere
|
|
Wrapper-Bloecke in der Zeichnung, und jeder traegt seine Bau-Parameter
|
|
selbst - als Attribute und XDATA, genau die Werte, die auch der jeweilige
|
|
Edit-Befehl (Doppelklick) zum Neuaufbau liest. Typischer Ablauf: Scanner-
|
|
Vorlage nachbauen, in BricsCAD von Hand korrigieren/ergaenzen, Zeichnung als
|
|
DXF speichern und mit diesem Skript die Testdaten aktualisieren.
|
|
|
|
Je Wrapper (Felder wie in tests/testdata/object_data.md bzw. mubea.json):
|
|
|
|
KREISEL_n -> Kreisel ("function": "insert", x/y/z = Einfuegepunkt,
|
|
abstand = Mittenabstand AN8-SP8 aus der Blockdefinition,
|
|
rotation, typ = KREISELART, drehrichtung = DREHRICHTUNG)
|
|
GF_n -> Gefaellestrecke Modus 1 (XDATA SSG_GF_EDIT wie
|
|
gf-xdata-lesen + SEITE_AS/SEITE_ES wie GEFAELLESTRECKE_EDIT).
|
|
Endet die Strecke auf der Kette eines Kreisels, wird statt
|
|
des gebauten Winkels dessen Hoehe als hoehe_ende_mm
|
|
uebernommen - der Treiber rechnet den Winkel daraus neu.
|
|
VF_n "standard"/"etage"
|
|
-> VarioFoerderer im mubea-Schema (Parameter wie
|
|
vfs-edit-basis-lesen: Einfuegepunkt, hz aus XDATA, DELTA_L/
|
|
DELTA_H/ANTRIEBFAHRTRICHTUNG/SEITE_AS/MOTORSEITE, dazu
|
|
VF_WINKEL und L_GF1 aus L_GF_m)
|
|
VF_n "linienzug"
|
|
-> das Eingabe-Journal UNVERAENDERT ("typ":
|
|
"linienzug_journal"). Bewusst KEINE Spec: der Bau-Dialog ist
|
|
neuer als die Spec-Grammatik (vf_linienzug_grammatik.py,
|
|
vf_spec.lsp), eine Uebersetzung wuerde desynchronisieren.
|
|
Der Treiber spielt das Journal per vsp-bau-journal ab.
|
|
sonstige Bloecke (Separator, Scanner, ...)
|
|
-> Einzel-Insert ("function": "insert", "block" ohne
|
|
_2D/_3D-Suffix, damit die Dimension der Sitzung gilt)
|
|
|
|
Nicht uebernommen (gemeldet): "linienzug2" (Modus 2 - Journal verweist auf
|
|
Pfad-Objekte der Zeichnung per Handle) und Bloecke ohne Bau-Parameter.
|
|
|
|
Aufruf:
|
|
python dxf_anlage_export.py <dxf> <output.json> [--projekt NAME]
|
|
|
|
Beispiel (Dematic/Arvato, Nachbau-Daten fuer TEST_DEMATIC_ARVATO):
|
|
python lib/dxf_anlage_export.py C:/develop/sps_skel/data/Dematic_TST.dxf \\
|
|
tests/testdata/dematic_arvato.json --projekt Arvato
|
|
|
|
Bewusst OHNE ezdxf: reine Gruppencode-Lesung (vf_journal_export.gruppen),
|
|
ASCII-DXF.
|
|
"""
|
|
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from vf_journal_export import TRENNER, gruppen # noqa: E402
|
|
from dxf_scan_components import arrays_einzeilig, kreisel_anschluss # noqa: E402
|
|
import testdata_schema as sch # noqa: E402
|
|
|
|
PREFIX = "[dxf_anlage_export]"
|
|
RUND = 2
|
|
|
|
# Kreisel-Durchmesser (Radmitte bis Kette), wie component_defaults.json.
|
|
KREISEL_RADIUS = 400.0
|
|
|
|
# Bis zu diesem Abstand (mm) gilt ein GF-Ende als auf der Kette eines
|
|
# Kreisels (der GF-Bau legt AS-/ES-Ursprung genau auf die Kette).
|
|
GF_KREISEL_TOL = 50.0
|
|
|
|
DIM_SUFFIX = re.compile(r"_(2D|3D)$")
|
|
|
|
|
|
# ============================================================
|
|
# DXF lesen
|
|
# ============================================================
|
|
|
|
def _float(v):
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def lese_zeichnung(dxf_pfad):
|
|
"""Modelspace-INSERTs (mit Attributen und XDATA je App) und die INSERTs
|
|
jeder Blockdefinition.
|
|
|
|
Rueckgabe: (inserts, block_inhalt)
|
|
inserts - Liste {name, handle, p, rot, attribs, xdata}; xdata ist
|
|
app -> Liste der 1000er-Werte in Reihenfolge
|
|
block_inhalt - blockname -> Liste {name, p} der direkt enthaltenen
|
|
INSERTs (fuer den Kreisel-Mittenabstand)
|
|
"""
|
|
paare = gruppen(dxf_pfad)
|
|
inserts = []
|
|
block_inhalt = {}
|
|
section = None
|
|
block = None
|
|
i, n = 0, len(paare)
|
|
while i < n:
|
|
code, wert = paare[i]
|
|
if code != "0":
|
|
i += 1
|
|
continue
|
|
if wert == "SECTION" and i + 1 < n:
|
|
section = paare[i + 1][1].strip()
|
|
elif wert == "ENDSEC":
|
|
section = None
|
|
elif wert == "BLOCK":
|
|
j = i + 1
|
|
while j < n and paare[j][0] != "0":
|
|
if paare[j][0] == "2":
|
|
block = paare[j][1]
|
|
break
|
|
j += 1
|
|
elif wert == "ENDBLK":
|
|
block = None
|
|
elif wert == "INSERT" and section in ("ENTITIES", "BLOCKS"):
|
|
e = {"name": None, "handle": None, "p": [0.0, 0.0, 0.0],
|
|
"rot": 0.0, "attribs": {}, "xdata": {}, "ps": 0}
|
|
app = None
|
|
j = i + 1
|
|
while j < n and paare[j][0] != "0":
|
|
c, v = paare[j]
|
|
if c == "2" and e["name"] is None:
|
|
e["name"] = v
|
|
elif c == "5" and e["handle"] is None:
|
|
e["handle"] = v
|
|
elif c in ("10", "20", "30") and app is None:
|
|
e["p"][{"10": 0, "20": 1, "30": 2}[c]] = _float(v)
|
|
elif c == "50":
|
|
e["rot"] = _float(v)
|
|
elif c == "67":
|
|
e["ps"] = int(_float(v))
|
|
elif c == "1001":
|
|
app = v
|
|
e["xdata"][app] = []
|
|
elif c == "1000" and app:
|
|
e["xdata"][app].append(v)
|
|
j += 1
|
|
# Attribute: ATTRIB-Entities bis SEQEND (Wert Code 1, Tag Code 2)
|
|
while j < n and paare[j] == ("0", "ATTRIB"):
|
|
k = j + 1
|
|
tag = val = None
|
|
while k < n and paare[k][0] != "0":
|
|
if paare[k][0] == "2" and tag is None:
|
|
tag = paare[k][1]
|
|
elif paare[k][0] == "1" and val is None:
|
|
val = paare[k][1]
|
|
k += 1
|
|
if tag:
|
|
e["attribs"][tag] = val if val is not None else ""
|
|
j = k
|
|
if section == "ENTITIES":
|
|
if e["ps"] != 1:
|
|
inserts.append(e)
|
|
elif block:
|
|
block_inhalt.setdefault(block, []).append(
|
|
{"name": e["name"], "p": e["p"]})
|
|
i = j
|
|
continue
|
|
i += 1
|
|
return inserts, block_inhalt
|
|
|
|
|
|
# ============================================================
|
|
# Wrapper -> Eintraege
|
|
# ============================================================
|
|
|
|
def _nummer(name):
|
|
m = re.search(r"_(\d+)", name)
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
def _r(v):
|
|
return round(float(v), RUND) + 0.0
|
|
|
|
|
|
def _richtung(hz):
|
|
rad = math.radians(hz)
|
|
return (math.cos(rad), math.sin(rad))
|
|
|
|
|
|
def kreisel_eintrag(e, block_inhalt, projekt):
|
|
"""Kreisel wie hundm05:bau-kreisel ihn erwartet."""
|
|
a = e["attribs"]
|
|
abstand = _float(a.get("ABSTAND"))
|
|
quelle = "Attribut ABSTAND (ganze mm)"
|
|
# Genauer aus der Blockdefinition: Mittenabstand der beiden Stationen
|
|
stationen = [s for s in block_inhalt.get(e["name"], [])
|
|
if s["name"] and s["name"].upper().startswith(("AN8", "SP8"))]
|
|
an = [s for s in stationen if s["name"].upper().startswith("AN8")]
|
|
sp = [s for s in stationen if s["name"].upper().startswith("SP8")]
|
|
if an and sp:
|
|
abstand = math.hypot(sp[0]["p"][0] - an[0]["p"][0],
|
|
sp[0]["p"][1] - an[0]["p"][1])
|
|
quelle = "Blockdefinition (AN8 -> SP8)"
|
|
nr = int(_float(a.get("NUMMER"))) or _nummer(e["name"])
|
|
return collections.OrderedDict([
|
|
(sch.F_ID, "Kreisel_%s_%02d" % (projekt, nr)),
|
|
(sch.F_FUNCTION, sch.FUNC_INSERT),
|
|
(sch.F_X, _r(e["p"][0])), (sch.F_Y, _r(e["p"][1])), (sch.F_Z, _r(e["p"][2])),
|
|
(sch.F_ABSTAND, _r(abstand)),
|
|
(sch.F_ROTATION, _r(e["rot"])),
|
|
(sch.F_TYP, a.get("KREISELART") or sch.TYP_STANDARD),
|
|
# Umlaufsinn: bestimmt die Reihenfolge im Kreisel-Umlauf und damit den
|
|
# TRO-Fluss - ohne ihn kam jeder Nachbau als UZS heraus (Dematic 1/4:
|
|
# GUZ). hundm05:bau-kreisel reicht ihn an kreisel-insert-script weiter.
|
|
(sch.F_DREHRICHTUNG, a.get("DREHRICHTUNG") or sch.DREHRICHTUNG_UZS),
|
|
(sch.F_COMMENT, "aus Zeichnung: %s (ID %s), abstand aus %s"
|
|
% (e["name"], a.get("ID", "?"), quelle)),
|
|
])
|
|
|
|
|
|
def kreisel_geo(kreisel):
|
|
"""kreisel_geo-Form (siehe dxf_scan_components.kreisel_anschluss) aus
|
|
einem Kreisel-Eintrag: Achse ab AN8-Radmitte = Einfuegepunkt + Radius."""
|
|
u = _richtung(kreisel["rotation"])
|
|
return {"id": kreisel["id"],
|
|
"a": (kreisel["x"] + KREISEL_RADIUS * u[0],
|
|
kreisel["y"] + KREISEL_RADIUS * u[1]),
|
|
"u": u, "laenge": kreisel["abstand"], "r": KREISEL_RADIUS,
|
|
"z": kreisel["z"]}
|
|
|
|
|
|
def kreisel_an_punkt(p, richtung, geo):
|
|
"""Kreisel, auf dessen Kette p liegt (oder None)."""
|
|
ans = kreisel_anschluss(p, richtung, geo)
|
|
if ans and abs(ans[2]) <= GF_KREISEL_TOL:
|
|
return ans[0]
|
|
return None
|
|
|
|
|
|
def gf_eintrag(e, geo, projekt, hinweise):
|
|
"""Gefaellestrecke Modus 1 - Felder wie GEFAELLESTRECKE_EDIT sie liest."""
|
|
a = e["attribs"]
|
|
xd = e["xdata"].get("SSG_GF_EDIT")
|
|
if not xd or len(xd) < 6:
|
|
hinweise.append("%s ohne SSG_GF_EDIT-XDATA - uebersprungen" % e["name"])
|
|
return None
|
|
hz, winkel = _float(xd[0]), _float(xd[1])
|
|
z_start, deltaL = _float(xd[4]), _float(xd[5])
|
|
start = (e["p"][0], e["p"][1])
|
|
r = _richtung(hz)
|
|
ende = (start[0] + deltaL * r[0], start[1] + deltaL * r[1])
|
|
eintrag = collections.OrderedDict([
|
|
(sch.F_TEST_ID, "GF_%s_%02d" % (projekt, _nummer(e["name"]))),
|
|
(sch.F_MODUS, sch.GF_MODUS_WERTEINGABE),
|
|
(sch.F_DELTAL_MM, _r(deltaL)),
|
|
(sch.F_HZ_GRAD, _r(hz)),
|
|
(sch.F_START_MM, [_r(start[0]), _r(start[1]), _r(z_start)]),
|
|
(sch.F_AS_SEITE, a.get("SEITE_AS") or sch.SEITE_LINKS),
|
|
(sch.F_ES_SEITE, a.get("SEITE_ES") or sch.SEITE_LINKS),
|
|
(sch.F_AS_WINKEL, xd[2] or sch.SCHLEUS_WINKEL_90),
|
|
(sch.F_ES_WINKEL, xd[3] or sch.SCHLEUS_WINKEL_90),
|
|
])
|
|
k_start = kreisel_an_punkt(start, r, geo)
|
|
k_ende = kreisel_an_punkt(ende, (-r[0], -r[1]), geo)
|
|
if k_start:
|
|
eintrag["kreisel_start"] = k_start["id"]
|
|
if k_ende:
|
|
eintrag["kreisel_ende"] = k_ende["id"]
|
|
eintrag["hoehe_ende_mm"] = k_ende["z"]
|
|
kommentar = ("Endhoehe = Hoehe von %s (%.1f), Winkel rechnet der "
|
|
"Treiber neu; gebaut war %.4f Grad -> HOEHE_BIS %s"
|
|
% (k_ende["id"], k_ende["z"], winkel,
|
|
a.get("HOEHE_BIS_mm", "?")))
|
|
else:
|
|
eintrag["winkel_grad"] = round(winkel, 4)
|
|
kommentar = "kein Kreisel am Ende - gebauter Winkel uebernommen"
|
|
eintrag[sch.F_COMMENT] = "aus Zeichnung: %s (ID %s); %s" % (
|
|
e["name"], a.get("ID", "?"), kommentar)
|
|
return eintrag
|
|
|
|
|
|
def vf_standard_eintrag(e, projekt, typ):
|
|
"""VarioFoerderer im mubea-Schema (mubea:vario-bauen)."""
|
|
a = e["attribs"]
|
|
xd = e["xdata"].get("SSG_VF_EDIT") or []
|
|
l_gf = [t for t in (a.get("L_GF_m") or "").split(",") if t.strip()]
|
|
eintrag = collections.OrderedDict([
|
|
(sch.F_TEST_ID, "VF_%s_%02d" % (projekt, _nummer(e["name"]))),
|
|
(sch.F_VF_TYP, typ),
|
|
(sch.F_RICHTUNG, a.get("ANTRIEBFAHRTRICHTUNG") or sch.RICHTUNG_AUF),
|
|
(sch.F_DELTA_L, _r(_float(a.get("DELTA_L_mm")))),
|
|
(sch.F_DELTA_H, _r(_float(a.get("DELTA_H_mm")))),
|
|
(sch.F_WINKEL, int(_float(a.get("VF_WINKEL")))),
|
|
(sch.F_L_GF1, _r(1000.0 * _float(l_gf[0])) if l_gf else 0.0),
|
|
(sch.F_SEITE, a.get("SEITE_AS") or sch.SEITE_RECHTS),
|
|
(sch.F_MOTORSEITE, (a.get("MOTORSEITE") or sch.SEITE_RECHTS).split(",")[0]),
|
|
(sch.F_HZ, _r(_float(xd[1]) if len(xd) > 1 else 0.0)),
|
|
(sch.F_X, _r(e["p"][0])), (sch.F_Y, _r(e["p"][1])), (sch.F_Z, _r(e["p"][2])),
|
|
(sch.F_COMMENT, "aus Zeichnung: %s (ID %s), Hoehe %s -> %s; deltaL/"
|
|
"deltaH aus den (ganzzahligen) Attributen"
|
|
% (e["name"], a.get("ID", "?"), a.get("HOEHE_VON_mm", "?"),
|
|
a.get("HOEHE_BIS_mm", "?"))),
|
|
])
|
|
return eintrag
|
|
|
|
|
|
def vf_journal_eintrag(e, projekt, nr):
|
|
"""Linienzug: Journal unveraendert (Tokens 'ART:wert')."""
|
|
a = e["attribs"]
|
|
roh = "".join(e["xdata"]["SSG_VF_EDIT"][1:])
|
|
tokens = [t for t in roh.split(TRENNER) if t]
|
|
return collections.OrderedDict([
|
|
(sch.F_TEST_ID, "VF_%s_LZ_%02d" % (projekt, nr)),
|
|
(sch.F_VF_TYP, "linienzug_journal"),
|
|
("journal", tokens),
|
|
(sch.F_COMMENT, "aus Zeichnung: %s (ID %s), %d Eingaben, Hoehe %s -> %s; "
|
|
"wird 1:1 abgespielt (vsp-bau-journal)"
|
|
% (e["name"], a.get("ID", "?"), len(tokens),
|
|
a.get("HOEHE_VON_mm", "?"), a.get("HOEHE_BIS_mm", "?"))),
|
|
])
|
|
|
|
|
|
def einzel_eintrag(e):
|
|
a = e["attribs"]
|
|
info = ", ".join("%s %s" % (k, a[k]) for k in ("ID", "ZUORDNUNG", "ARTINR")
|
|
if a.get(k))
|
|
return collections.OrderedDict([
|
|
(sch.F_FUNCTION, sch.FUNC_INSERT),
|
|
(sch.F_BLOCK, DIM_SUFFIX.sub("", e["name"])),
|
|
(sch.F_X, _r(e["p"][0])), (sch.F_Y, _r(e["p"][1])), (sch.F_Z, _r(e["p"][2])),
|
|
(sch.F_ROTATION, _r(e["rot"])),
|
|
(sch.F_COMMENT, "aus Zeichnung: %s%s" % (e["name"],
|
|
(" (%s)" % info) if info else "")),
|
|
])
|
|
|
|
|
|
def baue_eintraege(inserts, block_inhalt, projekt):
|
|
"""Reihenfolge wie mubea.json: Kreisel, VF, GF, Einzelbloecke - die VF
|
|
vor den GF, weil gf-init-bibliothek globale Elementmasse umsetzt."""
|
|
kreisel, vf, lz, gf, einzel, hinweise = [], [], [], [], [], []
|
|
for e in sorted(inserts, key=lambda e: (e["name"] or "", e["p"][0], e["p"][1])):
|
|
name = e["name"] or ""
|
|
if name.startswith("KREISEL_"):
|
|
kreisel.append(kreisel_eintrag(e, block_inhalt, projekt))
|
|
geo = [kreisel_geo(k) for k in kreisel]
|
|
for e in inserts:
|
|
name = e["name"] or ""
|
|
if name.startswith("KREISEL_"):
|
|
continue
|
|
if name.startswith("GF_"):
|
|
g = gf_eintrag(e, geo, projekt, hinweise)
|
|
if g:
|
|
gf.append(g)
|
|
elif name.startswith("VF_"):
|
|
xd = e["xdata"].get("SSG_VF_EDIT")
|
|
marker = xd[0] if xd else None
|
|
if marker in ("standard", "etage"):
|
|
vf.append(vf_standard_eintrag(e, projekt, marker))
|
|
elif marker == "linienzug":
|
|
lz.append(vf_journal_eintrag(e, projekt, len(lz) + 1))
|
|
else:
|
|
hinweise.append("%s: Marker %r nicht nachbaubar - uebersprungen"
|
|
% (name, marker))
|
|
elif name.startswith("*") or name.startswith("ZZ_"):
|
|
continue # anonyme Bloecke, Export-Marker
|
|
else:
|
|
einzel.append(einzel_eintrag(e))
|
|
gf.sort(key=lambda g: g["test_id"])
|
|
vf.sort(key=lambda v: v["test_id"])
|
|
kreisel.sort(key=lambda k: k["id"])
|
|
return kreisel + vf + lz + gf + einzel, hinweise
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(
|
|
description="Gebaute Anlage (Wrapper-Bloecke) aus einer Zeichnung in "
|
|
"Nachbau-Testdaten schreiben.")
|
|
p.add_argument("dxf", help="Zeichnung (ASCII-DXF)")
|
|
p.add_argument("output", help="Ziel-JSON")
|
|
p.add_argument("--projekt", help="Kennung in den IDs (Default: Dateiname)")
|
|
args = p.parse_args()
|
|
if not os.path.isfile(args.dxf):
|
|
print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, args.dxf))
|
|
sys.exit(1)
|
|
projekt = args.projekt or os.path.splitext(os.path.basename(args.dxf))[0]
|
|
|
|
print("%s lese %s ..." % (PREFIX, args.dxf))
|
|
inserts, block_inhalt = lese_zeichnung(args.dxf)
|
|
eintraege, hinweise = baue_eintraege(inserts, block_inhalt, projekt)
|
|
|
|
zaehler = collections.Counter(
|
|
"Kreisel" if sch.F_ABSTAND in e else
|
|
"GF" if sch.F_MODUS in e else
|
|
"VF Linienzug" if e.get(sch.F_VF_TYP) == "linienzug_journal" else
|
|
"VF %s" % e[sch.F_VF_TYP] if sch.F_VF_TYP in e and sch.F_TEST_ID in e else
|
|
e.get(sch.F_BLOCK, "?") for e in eintraege)
|
|
for k, v in sorted(zaehler.items()):
|
|
print("%s %-22s %d" % (PREFIX, k, v))
|
|
for h in hinweise:
|
|
print("%s ! %s" % (PREFIX, h))
|
|
|
|
with open(args.output, "w", encoding="utf-8") as fh:
|
|
fh.write(arrays_einzeilig(json.dumps(eintraege, ensure_ascii=False,
|
|
indent=2)))
|
|
fh.write("\n")
|
|
print("%s %d Eintraege geschrieben -> %s"
|
|
% (PREFIX, len(eintraege), args.output))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|