[ADD] Dematic/Arvato: Nachbau-Vorlage tests/testdata/dematic_arvato.json (Abschnitt Flowpuffer/Kreisel) + Treiber TEST_DEMATIC_ARVATO (Menue Tests, in alltests.json vorerst disabled); [FIX] dxf_scan_components.py: liest jetzt auch ASCII-DXF, Kreisel-Paarung global nach Abstand und nur auf gemeinsamer Achse (vorher klaute ein AN8 die SP8 des Nachbarn), Kreisel-Position aus der Radmitte statt dem Einfuegepunkt (AN8/SP8 Pinband haben das Rad ~15 m daneben), Kreisel als insert mit Mittenabstand statt connect (war an beiden Enden einen Radius zu kurz), AN8/SP8 Pinband erkannt, Projektkennung in den IDs per --projekt statt fest HundM05; [FIX] hundm05:bau-sensor brach bei Eintraegen ohne id mit bad argument type <NIL> ab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 15:39:11 +02:00
parent fe5cb9664c
commit 1250864849
7 changed files with 1688 additions and 29 deletions
+151 -29
View File
@@ -44,6 +44,7 @@ import struct
import sys
from ezdxf.lldxf.types import BINARY_DATA, INT16, INT32, INT64, DOUBLE, BYTES
from ezdxf.lldxf.types import cast_tag_value
from ezdxf.math import Matrix44, OCS
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -62,6 +63,10 @@ MAX_PAAR_ABSTAND = 60000.0
# 10 mm deckt Rundungs- und Zeichenungenauigkeiten der Fremdzeichnung ab.
GERADE_MAX_DELTA_H = 10.0
# Projektkennung in den erzeugten IDs (Kreisel_<PROJEKT>_01 ...); main()
# setzt sie aus --projekt bzw. dem Namen der DXF-Datei.
PROJEKT = "HundM05"
# Toleranz fuer die Dedup-Rundung (Nachkommastellen).
RUND = 2
@@ -190,6 +195,48 @@ def iter_binary_tags(mm):
yield code, value
def iter_ascii_tags(mm):
"""Streamt (code, value)-Tags aus einem ASCII-DXF.
Gegenstueck zu iter_binary_tags fuer Zeichnungen, die als Text-DXF
gespeichert wurden (z.B. Dematic_arvato.dxf). Die Werte werden ueber die
ezdxf-Typtabelle in float/int gewandelt, damit lese_inserts dieselben
Typen sieht wie beim binaeren Lesen.
"""
i = 0
n = len(mm)
while i < n:
end = mm.find(b"\n", i)
if end < 0:
break
code_raw = mm[i:end].strip()
i = end + 1
end = mm.find(b"\n", i)
if end < 0:
end = n
value_raw = mm[i:end].rstrip(b"\r")
i = end + 1
if not code_raw:
continue
code = int(code_raw)
value = decode_str(value_raw)
if code == 0:
value = value.strip()
else:
try:
value = cast_tag_value(code, value)
except ValueError:
pass
yield code, value
def iter_tags(mm):
"""Binaer- oder ASCII-DXF anhand der Kopfzeile erkennen."""
if mm[:22] == b"AutoCAD Binary DXF\r\n\x1a\x00":
return iter_binary_tags(mm)
return iter_ascii_tags(mm)
# ---------------------------------------------------------------------------
# Familien-Erkennung (Blockname -> Bauteilfamilie)
# ---------------------------------------------------------------------------
@@ -197,8 +244,8 @@ def iter_binary_tags(mm):
# mit Wildcards - die Blocknamen der Fremdzeichnungen enthalten Umlaute und
# Sonderzeichen (z.B. "3D_Schraegfoerderer_Antrieb", "TEF_Bogen_90Grad_...").
FAMILIEN = [
("kreisel_an", ["AN8", "AN8_*"]),
("kreisel_sp", ["SP8", "SP8_*"]),
("kreisel_an", ["AN8", "AN8_*", "AN8 Pinband*"]),
("kreisel_sp", ["SP8", "SP8_*", "SP8 Pinband*"]),
("foerderer_antrieb", ["3D_TEF_Antrieb*", "3D_FF_Antrieb*",
"3D_Schr*rderer_Antrieb*", "Antrieb FF"]),
("foerderer_umlenk", ["3D_TEF_Umlenk*", "3D_FF_Umlenk*",
@@ -443,7 +490,7 @@ def lese_inserts(dxf_path):
fh.close()
raise
try:
for code, value in iter_binary_tags(mm):
for code, value in iter_tags(mm):
if code == 0:
# Ein ATTRIB endet immer am naechsten 0-Tag. Erst hier steht
# sicher fest, dass Tag UND Wert gelesen sind - in einem ATTRIB
@@ -496,6 +543,19 @@ def lese_inserts(dxf_path):
block_merken(aktueller_block)["kinder"].add(value)
elif aktuell == "ATTRIB":
attrib_tag = value
elif aktuell == "CIRCLE" and aktueller_block and code in (10, 20, 30):
# Mittelpunkt des ERSTEN Kreises der Blockdefinition merken -
# bei AN8/SP8 ist das das Rad. Die Pinband-Varianten in
# Dematic_arvato.dxf haben ihr Rad ~15 m neben dem
# Einfuegepunkt; ohne diesen Wert laege der Kreisel dort.
# Ein zweiter Kreis beginnt mit Code 10, waehrend "kreis" schon
# existiert - ab da ist der erste fertig (Code 30 ist optional).
info = block_merken(aktueller_block)
if code == 10 and "kreis" in info:
info["kreis_fertig"] = True
if not info.get("kreis_fertig"):
kreis = info.setdefault("kreis", [0.0, 0.0, 0.0])
kreis[{10: 0, 20: 1, 30: 2}[code]] = value
elif aktuell == "BLOCK" and aktueller_block and code in (10, 20, 30):
block_base[aktueller_block][{10: 0, 20: 1, 30: 2}[code]] = value
elif aktuell == "INSERT" and rec is not None:
@@ -630,6 +690,13 @@ def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12,
xachse = mat.transform((1.0, 0.0, 0.0)) - ursprung
rot = math.degrees(math.atan2(xachse.y, xachse.x)) % 360.0
fam = familie_von(rec["name"])
# Kreisel-Stationen: Position = Radmitte, nicht Einfuegepunkt
# (siehe "kreis" in lese_inserts). Die Rotation oben bleibt die
# des Inserts.
kreis = ((block_info or {}).get(rec["name"], {}).get("kreis")
if fam in ("kreisel_an", "kreisel_sp") else None)
if kreis:
ursprung = mat.transform(tuple(kreis))
if fam:
treffer.append({
"familie": fam,
@@ -866,6 +933,48 @@ def zwillinge_zusammenfuehren(treffer):
# Paarbildung und Schema-Abbildung
# ---------------------------------------------------------------------------
# Querversatz (mm), bis zu dem AN8 und SP8 noch auf derselben Kreisel-Achse
# liegen. Beide Stationen sitzen auf der Mittellinie des Kreisels; in
# Dematic_arvato.dxf liegen echte Paare auf 0,2 mm genau auf einer Achse.
KREISEL_MAX_QUER = 100.0
def kreisel_paare_bilden(an_liste, sp_liste):
"""AN8 und SP8 zu Kreiseln paaren.
Global nach Abstand (kuerzestes Paar zuerst) statt AN8 fuer AN8 in
X-Reihenfolge: dort griff sich ein frueh sortierter AN8 die naechste noch
freie SP8 eines Nachbar-Kreisels, und alle folgenden Paare rutschten mit
(Dematic_arvato: ein 33,5-m-Kreisel wurde mit der SP8 eines 3,6-m-Kreisels
19 m daneben gepaart). Zusaetzlich muessen beide auf einer gemeinsamen
Achse liegen (Querversatz <= KREISEL_MAX_QUER).
Rueckgabe: (paare [(an, sp, abstand)] nach AN-Position sortiert,
AN8 ohne Partner, SP8 ohne Partner)
"""
kandidaten = []
for ia, an in enumerate(an_liste):
for isp, sp in enumerate(sp_liste):
dx = abs(sp["x"] - an["x"])
dy = abs(sp["y"] - an["y"])
if min(dx, dy) > KREISEL_MAX_QUER:
continue
kandidaten.append((math.hypot(dx, dy), ia, isp))
kandidaten.sort()
an_frei = set(range(len(an_liste)))
sp_frei = set(range(len(sp_liste)))
paare = []
for dist, ia, isp in kandidaten:
if ia in an_frei and isp in sp_frei:
an_frei.discard(ia)
sp_frei.discard(isp)
paare.append((an_liste[ia], sp_liste[isp], dist))
paare.sort(key=lambda p: (p[0]["x"], p[0]["y"]))
return (paare,
[an_liste[i] for i in sorted(an_frei)],
[sp_liste[i] for i in sorted(sp_frei)])
def paare_bilden(antriebe, umlenkungen):
"""Gegenseitig naechste Nachbarn paaren.
@@ -978,7 +1087,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
# Die echte Hoehe liefert der 3D-Zwilling (siehe Zusammenfuehrung).
if a.get("darstellung") == "2D":
return {
"test_id": "GERADE_HundM05_%02d" % nummer,
"test_id": "GERADE_%s_%02d" % (PROJEKT, nummer),
"typ": "gerade",
"darstellung": "2D",
"laenge": round(dist_xy, RUND),
@@ -993,7 +1102,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
if abs(dz) < GERADE_MAX_DELTA_H:
return {
"test_id": "GERADE_HundM05_%02d" % nummer,
"test_id": "GERADE_%s_%02d" % (PROJEKT, nummer),
"typ": "gerade",
"darstellung": a.get("darstellung", "?"),
"laenge": round(dist_xy, RUND),
@@ -1008,7 +1117,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
if dz < 0 and neigung >= gf_winkel_min:
return {
"test_id": "GF_HundM05_%02d" % nummer,
"test_id": "GF_%s_%02d" % (PROJEKT, nummer),
"modus": 1,
"darstellung": a.get("darstellung", "?"),
"deltaL_mm": round(dist_xy, RUND),
@@ -1025,7 +1134,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
}
return {
"test_id": "VF_HundM05_%02d" % nummer,
"test_id": "VF_%s_%02d" % (PROJEKT, nummer),
"typ": "standard",
"darstellung": a.get("darstellung", "?"),
"richtung": "Auf" if dz > 0 else "Ab",
@@ -1116,16 +1225,12 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
# --- Kreisel: AN8 (Antriebsstation) + SP8 (Spannstation) --------------
durchmesser = defaults.get("kreisel", {}).get("durchmesser", 800.0)
an_liste = sorted(nach_familie.get("kreisel_an", []), key=lambda t: (t["x"], t["y"]))
sp_liste = list(nach_familie.get("kreisel_sp", []))
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, 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"])
kreisel_paare, an_rest, sp_liste = kreisel_paare_bilden(
nach_familie.get("kreisel_an", []), nach_familie.get("kreisel_sp", []))
for an in an_rest:
hinweise.append("AN8 bei (%.2f, %.2f) ohne SP8-Partner" % (an["x"], an["y"]))
eintraege.append(sensor_eintrag(an, bekannte_bloecke, hinweise, treffer))
for i, (an, sp, dist) in enumerate(kreisel_paare, start=1):
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
@@ -1144,19 +1249,31 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
hoehe_hinweis = (" ACHTUNG hoehe=%.1f ist NICHT belegt: "
"AN8/SP8 sind flache 2D-Symbole auf Z=0 und "
"%s." % (hoehe, grund))
# an/sp sind Radmitten (siehe "kreis" in lese_inserts). draw-module
# (KreiselInsert.lsp) legt AN8 bei (radius, 0) und SP8 bei
# (radius + abstand, 0) in den Block: abstand ist also der
# Mittenabstand, und der Einfuegepunkt liegt einen Radius VOR der
# AN8-Mitte. Die fruehere "connect"-Ausgabe uebergab die Radmitten an
# kreisel-connect-script, das Aussenkanten erwartet - der Kreisel
# wurde dadurch an beiden Enden um einen Radius zu kurz.
radius = durchmesser / 2.0
richtung = math.radians(rotation)
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"],
"id": "Kreisel_%s_%02d" % (PROJEKT, i),
"function": "insert",
"x": round(an["x"] - radius * math.cos(richtung), RUND) + 0.0,
"y": round(an["y"] - radius * math.sin(richtung), RUND) + 0.0,
"z": hoehe,
"abstand": round(dist, RUND),
"rotation": round(rotation, RUND) + 0.0,
"typ": "STANDARD",
"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,
"comment": ("aus DXF: Radmitte %s (Layer %s) (%.2f, %.2f) -> "
"%s (Layer %s) (%.2f, %.2f), Mittenabstand %.2f, "
"rotation %.2f Grad; x/y = AN8-Mitte minus Radius."
% (an["block"], an["layer"], an["x"], an["y"],
sp["block"], sp["layer"], sp["x"], sp["y"], dist,
rotation)) + hoehe_hinweis,
})
for sp in sp_liste:
hinweise.append("SP8 bei (%.2f, %.2f) ohne AN8-Partner" % (sp["x"], sp["y"]))
@@ -1188,7 +1305,7 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
hinweise.append("Omniflo %s ohne Sivasnr: '%s' (%s)"
% (typ, t["block"], grund))
eintraege.append({
"id": sivasnr or ("%s_HundM05_%02d" % (typ.upper(), i)),
"id": sivasnr or ("%s_%s_%02d" % (typ.upper(), PROJEKT, i)),
"type": typ,
"sivasnr": sivasnr,
"x": t["x"], "y": t["y"], "z": t["z"],
@@ -1346,7 +1463,7 @@ def main():
description="Erkennt ILS-/Omniflo-Komponenten in einem fremden DXF "
"und schreibt sie im JSON-Schema aus "
"tests/testdata/object_data.md.")
parser.add_argument("dxf", help="Quellzeichnung (binaeres DXF)")
parser.add_argument("dxf", help="Quellzeichnung (binaeres oder ASCII-DXF)")
parser.add_argument("data_dir", help="Datenverzeichnis mit json/")
parser.add_argument("output", help="Ziel-JSON")
parser.add_argument("--fenster", nargs=4, type=float,
@@ -1354,8 +1471,13 @@ def main():
help="Nur Bauteile in diesem XY-Fenster (mm) "
"ausgeben - fuer den Nachbau eines einzelnen "
"Anlagenabschnitts statt der ganzen Zeichnung")
parser.add_argument("--projekt",
help="Projektkennung in den erzeugten IDs "
"(Default: Name der DXF-Datei ohne Endung)")
args = parser.parse_args()
dxf_path, data_dir, output_path = args.dxf, args.data_dir, args.output
global PROJEKT
PROJEKT = args.projekt or os.path.splitext(os.path.basename(dxf_path))[0]
fenster = None
if args.fenster:
x0, y0, x1, y1 = args.fenster