[ADD] Nachbau ILS-3.0-Showroom: tests/testdata/showroom_ils.json aus Showroom_ILS_3_0.dxf (6 Kreisel, 11 Gefaellestrecken, Separatoren/Scanner; Hoehen noch Platzhalter), Treiber tests/test_showroom_ils.lsp (TEST_SHOWROOM_ILS, Menue Tests -> ILS 3.0 Showroom), in alltests.json bis zu den Hoehen deaktiviert; [ADD] dxf_scan_components.py: --flach (waagrechte Staustrecken mit AS an genau einem Ende als GF), --kreisel-achse (Kreisel ohne AN8/SP8, z.B. Schleifen mit Weichenboegen), zerstueckelte Staustrecken-Linien werden vereint, ES am unteren Ende wird erkannt und verbraucht, AS/ES-Seite ohne Seitenangabe im Namen aus der Lage zur Fahrtrichtung, Kurznamen AE-*/EE-*; [FIX] Kreisel-Paarung: zwei Kreisel auf derselben Achse wurden ueber die Luecke gepaart (800 + 12832 statt 9033 + 3000) - Tausch-Nachbesserung auf minimale Abstandssumme; [CHANGE] test_dematic_arvato.lsp: Lauf als dematic_arvato:bau-datei herausgeloest (vom Showroom-Treiber mitgenutzt); [ADD] Tests in test_dxf_scan_gf.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:00:45 +02:00
parent f1583d2d41
commit 4d0d689815
9 changed files with 824 additions and 47 deletions
+209 -17
View File
@@ -153,6 +153,15 @@ GF_LINIE_MIN_DZ = 100.0
# rechts), nicht der kleinste Abstand.
GF_AS_MAX_ENTLANG = 2000.0
GF_AS_MAX_QUER = 700.0
# Wie weit (mm) das AS/ES VOR dem Linienende liegen darf, also schon auf der
# Kreisel-Seite. In Dematic_arvato liegt es innerhalb der Linie; im
# Showroom_ILS_3_0-Plan endet die Staustrecke 305 mm vor dem Element, das auf
# der Kreisel-Kette sitzt. Kleiner als das Linienraster quer zur Kette
# (Nachbar-GF der Gegenrichtung lag dort 1200 mm entfernt).
GF_AS_MAX_VOR = 800.0
# Toleranz (mm) fuer zusammenstossende Linienenden beim Zusammenfassen
# zerstueckelter Staustrecken (linien_zusammenfassen).
GF_LINIE_STOSS_TOL = 1.0
# Groesste Luecke (mm) zwischen Linienende und Kreisel-Kette, bis zu der die
# Linie an diesen Kreisel angeschlossen wird. Gezeichnet ist nur die
@@ -303,8 +312,9 @@ FAMILIEN = [
("omniflo_weiche", ["Weiche *", "Weichen-TEF*"]),
("bogen", ["3D_ILS_Bogen_*", "3D_FF_Bogen_*", "TEF_Bogen_*",
"tef30*", "tef90*"]),
# "AE-D"/"EE-R": Kurznamen der aelteren ILS-Plaene (Showroom_ILS_3_0.dxf)
("schleuselement", ["Ausschleuselement*", "Einschleuselement*",
"EE_Neu*", "AE_Neu*"]),
"EE_Neu*", "AE_Neu*", "AE-*", "EE-*"]),
("sensor", ["S-LP", "S-SP", "SCAN", "SCAN_*", "Scanner*",
"Separator*", "Stopper*", "Stopperschleuse*"]),
]
@@ -399,12 +409,12 @@ def mappe_block(bname):
geraten)
# Aus-/Einschleuselement -> AS_Element / ES_Element
if "ausschleus" in low or low.startswith("ae_"):
if "ausschleus" in low or low.startswith(("ae_", "ae-")):
winkel, geschaetzt = winkel_aus_name(bname, DEFAULT_SCHLEUS_WINKEL)
if geschaetzt:
geraten.append("Winkel=%d" % winkel)
return "AS_Element_%d_%s" % (winkel, seite_aus()), geraten
if "einschleus" in low or low.startswith("ee_"):
if "einschleus" in low or low.startswith(("ee_", "ee-")):
winkel, geschaetzt = winkel_aus_name(bname, DEFAULT_SCHLEUS_WINKEL)
if geschaetzt:
geraten.append("Winkel=%d" % winkel)
@@ -1047,17 +1057,25 @@ def kreisel_paare_bilden(an_liste, sp_liste):
19 m daneben gepaart). Zusaetzlich muessen beide auf einer gemeinsamen
Achse liegen (Querversatz <= KREISEL_MAX_QUER).
Danach Tausch-Nachbesserung auf minimale Abstandssumme (siehe unten).
Rueckgabe: (paare [(an, sp, abstand)] nach AN-Position sortiert,
AN8 ohne Partner, SP8 ohne Partner)
"""
def abstand(an, sp):
"""Mittenabstand, oder None wenn nicht auf gemeinsamer Achse."""
dx = abs(sp["x"] - an["x"])
dy = abs(sp["y"] - an["y"])
if min(dx, dy) > KREISEL_MAX_QUER:
return None
return math.hypot(dx, dy)
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))
d = abstand(an, sp)
if d is not None:
kandidaten.append((d, ia, isp))
kandidaten.sort()
an_frei = set(range(len(an_liste)))
sp_frei = set(range(len(sp_liste)))
@@ -1066,7 +1084,29 @@ def kreisel_paare_bilden(an_liste, sp_liste):
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.append([an_liste[ia], sp_liste[isp], dist])
# Nachbesserung: zwei Paare tauschen ihre SP8, solange die Summe der
# Abstaende dadurch kleiner wird. "Kuerzestes Paar zuerst" allein
# scheitert an zwei Kreiseln auf DERSELBEN Achse: die Luecke zwischen
# SP8 des einen und AN8 des anderen ist kuerzer als beide Kreisel und
# wird zuerst gepaart (Showroom_ILS_3_0: 800 + 12832 statt 9033 + 3000).
# Die echte Zuordnung hat immer die kleinere Summe - jede vertauschte
# Paarung ueberspannt mindestens eine Luecke doppelt.
verbessert = True
while verbessert:
verbessert = False
for i in range(len(paare)):
for j in range(i + 1, len(paare)):
a1, s1, d1 = paare[i]
a2, s2, d2 = paare[j]
n1, n2 = abstand(a1, s2), abstand(a2, s1)
if n1 is None or n2 is None or n1 + n2 >= d1 + d2 - 1e-6:
continue
paare[i] = [a1, s2, n1]
paare[j] = [a2, s1, n2]
verbessert = True
paare = [tuple(p) for p in paare]
paare.sort(key=lambda p: (p[0]["x"], p[0]["y"]))
return (paare,
[an_liste[i] for i in sorted(an_frei)],
@@ -1320,6 +1360,10 @@ def as_am_ende(p, richtung, as_liste, belegt):
auf dieser Seite liegen - das trennt ihn vom Nachbar-AS der Linie daneben.
Bereits vergebene AS (belegt = Menge von id()) zaehlen nicht.
Funktioniert genauso fuer Einschleuselemente am unteren Ende (es_liste in
gf_eintraege_aus_linien), dann mit der Richtung vom unteren Ende in die
Linie hinein.
Rueckgabe: (treffer, zielblock, geraten) oder None.
"""
beste = None
@@ -1329,7 +1373,8 @@ def as_am_ende(p, richtung, as_liste, belegt):
vx, vy = t["x"] - p[0], t["y"] - p[1]
entlang = vx * richtung[0] + vy * richtung[1]
quer = richtung[0] * vy - richtung[1] * vx
if not (0.0 <= entlang <= GF_AS_MAX_ENTLANG and abs(quer) <= GF_AS_MAX_QUER):
if not (-GF_AS_MAX_VOR <= entlang <= GF_AS_MAX_ENTLANG
and abs(quer) <= GF_AS_MAX_QUER):
continue
block, geraten = mappe_block(t["block"])
if "Seite=%s" % DEFAULT_SEITE not in (geraten or []):
@@ -1376,8 +1421,78 @@ def kreisel_anschluss(p, richtung, kreisel):
return beste
def linien_zusammenfassen(linien):
"""Kollineare, aneinanderstossende Linien desselben Layers vereinen.
Eine Staustrecke ist im Plan oft in Stuecke zerlegt (Showroom_ILS_3_0:
vier LINEs je GF, an den Separatoren getrennt). Als Einzelstuecke fehlte
jedem das AS am einen oder das ES am anderen Ende. Vereint wird nur bei
gleicher Richtung (in 3D) und Stoss innerhalb GF_LINIE_STOSS_TOL.
"""
def richtung(ln):
d = [ln["b"][i] - ln["a"][i] for i in range(3)]
n = math.sqrt(sum(c * c for c in d))
return [c / n for c in d] if n else None
def kollinear(r1, r2):
kreuz = (r1[1] * r2[2] - r1[2] * r2[1], r1[2] * r2[0] - r1[0] * r2[2],
r1[0] * r2[1] - r1[1] * r2[0])
# 1e-3 rad (~0.06 Grad): die Plaene sind nicht exakt gezeichnet -
# im Showroom-Plan sitzt das Separatorstueck 0,3 mm neben der Achse,
# das verkippt das bis dahin vereinte Stueck um ~2.6e-4.
return math.sqrt(sum(c * c for c in kreuz)) < 1e-3
def gleich(p, q):
return math.dist(p, q) <= GF_LINIE_STOSS_TOL
rest = [{"layer": ln["layer"], "a": list(ln["a"]), "b": list(ln["b"])}
for ln in linien]
fertig = []
while rest:
akt = rest.pop(0)
gewachsen = True
while gewachsen:
gewachsen = False
ra = richtung(akt)
for ln in rest:
rb = richtung(ln)
if (ln["layer"] != akt["layer"] or ra is None or rb is None
or not kollinear(ra, rb)):
continue
# Die vier moeglichen Stoesse; das Ergebnis behaelt die
# beiden aeusseren Enden.
for p_akt, q_akt in (("a", "b"), ("b", "a")):
for p_ln, q_ln in (("a", "b"), ("b", "a")):
if gleich(akt[p_akt], ln[p_ln]):
akt = {"layer": akt["layer"], "a": akt[q_akt],
"b": ln[q_ln]}
gewachsen = True
break
if gewachsen:
break
if gewachsen:
rest.remove(ln)
break
fertig.append(akt)
return fertig
def seite_aus_lage(t, p, r):
"""Seite eines AS/ES aus seiner Lage zur GF-Fahrtrichtung.
t = Element, p = Punkt auf der Achse, r = Fahrtrichtung der GF (bergab).
Links der Fahrtrichtung -> "links", sonst "rechts" - dieselbe Zuordnung
wie in Dematic_arvato ("Ausschleuselement links" liegt links der
Fahrtrichtung, siehe as_am_ende). Nur fuer Bloecke ohne Seite im Namen
("AE-D"/"EE-R"); gespiegelte Inserts landen dadurch automatisch auf der
Gegenseite.
"""
vx, vy = t["x"] - p[0], t["y"] - p[1]
return "links" if r[0] * vy - r[1] * vx > 0 else "rechts"
def gf_eintraege_aus_linien(linien, as_liste, kreisel=None, sep_liste=None,
winkel_default=3.0):
winkel_default=3.0, es_liste=None, flach=False):
"""Gefaellestrecken (Modus 1, Schema 2.1) aus Staustrecken-Linien.
Eine Gefaellestrecke laeuft immer bergab: Start ist das HOEHERE
@@ -1407,15 +1522,35 @@ def gf_eintraege_aus_linien(linien, as_liste, kreisel=None, sep_liste=None,
die Gefaellestrecke selbst mit, als Einzel-Insert stuenden sie doppelt in
der Zeichnung (und im Sivas-Export). Der AS eines uebersprungenen
Steigfoerderers bleibt unverbraucht.
es_liste: Einschleuselemente; eines am unteren Ende liefert es_seite/
es_winkel und wird ebenfalls verbraucht. Ohne es_liste bleiben beide
Defaults (Dematic_arvato hat kein ES im Plan).
flach: 2D-Plan ohne Hoehen (alle Linien waagrecht, z.B.
Showroom_ILS_3_0). Dann ist eine waagrechte Linie eine GF, wenn genau an
EINEM Ende ein Ausschleuselement sitzt - dieses Ende ist oben. Ohne flach
werden waagrechte Linien wie bisher uebergangen.
"""
eintraege, hinweise = [], []
belegt = set()
waagrecht = 0
kandidaten = []
for ln in linien:
for ln in linien_zusammenfassen(linien):
a, b = ln["a"], ln["b"]
if abs(a[2] - b[2]) < GF_LINIE_MIN_DZ:
waagrecht += 1
lxy = math.hypot(b[0] - a[0], b[1] - a[1])
if not (flach and lxy > 0.0):
waagrecht += 1
continue
r_ab = ((b[0] - a[0]) / lxy, (b[1] - a[1]) / lxy)
as_a = as_am_ende(a, r_ab, as_liste, belegt)
as_b = as_am_ende(b, (-r_ab[0], -r_ab[1]), as_liste, belegt)
if (as_a is None) == (as_b is None):
waagrecht += 1
continue
hoch, tief = (a, b) if as_a is not None else (b, a)
kandidaten.append((hoch, tief, lxy, ln["layer"]))
continue
hoch, tief = (a, b) if a[2] > b[2] else (b, a)
lxy = math.hypot(tief[0] - hoch[0], tief[1] - hoch[1])
@@ -1495,6 +1630,13 @@ def gf_eintraege_aus_linien(linien, as_liste, kreisel=None, sep_liste=None,
belegt.add(id(t))
teile = block.split("_") # AS_Element_<winkel>_<seite>
eintrag["as_winkel"], eintrag["as_seite"] = teile[2], teile[3]
if "Seite=%s" % DEFAULT_SEITE in geraten:
eintrag["as_seite"] = seite_aus_lage(t, hoch, r)
geraten = [g for g in geraten if not g.startswith("Seite=")]
block = "AS_Element_%s_%s" % (eintrag["as_winkel"],
eintrag["as_seite"])
kommentar += ("; as_seite %s aus der Lage zur Fahrtrichtung"
% eintrag["as_seite"])
kommentar += ("; AS aus Block '%s' bei (%.2f, %.2f) -> %s, als "
"Einzel-Insert entfernt (baut die GF selbst)"
% (t["block"], t["x"], t["y"], block))
@@ -1520,7 +1662,28 @@ def gf_eintraege_aus_linien(linien, as_liste, kreisel=None, sep_liste=None,
kommentar += ("; Separator auf der Achse entfernt (die GF baut "
"ihren eigenen): %s" % ", ".join(weg))
kommentar += "; es_seite/es_winkel sind Defaults (kein ES im DXF)."
unten = (as_am_ende(tief, (-r[0], -r[1]), es_liste, belegt)
if es_liste else None)
if unten is not None:
t, block, geraten = unten
belegt.add(id(t))
teile = block.split("_") # ES_Element_<winkel>_<seite>
eintrag["es_winkel"], eintrag["es_seite"] = teile[2], teile[3]
if "Seite=%s" % DEFAULT_SEITE in geraten:
eintrag["es_seite"] = seite_aus_lage(t, tief, r)
geraten = [g for g in geraten if not g.startswith("Seite=")]
block = "ES_Element_%s_%s" % (eintrag["es_winkel"],
eintrag["es_seite"])
kommentar += ("; es_seite %s aus der Lage zur Fahrtrichtung"
% eintrag["es_seite"])
kommentar += ("; ES aus Block '%s' bei (%.2f, %.2f) -> %s, als "
"Einzel-Insert entfernt (baut die GF selbst)"
% (t["block"], t["x"], t["y"], block))
if geraten:
kommentar += " - geraten: %s" % ", ".join(geraten)
kommentar += "."
else:
kommentar += "; es_seite/es_winkel sind Defaults (kein ES im DXF)."
eintrag["comment"] = kommentar
eintraege.append(eintrag)
@@ -1532,12 +1695,20 @@ def gf_eintraege_aus_linien(linien, as_liste, kreisel=None, sep_liste=None,
def baue_eintraege(treffer, defaults, boegen, weichen,
bekannte_bloecke=None, linien=None, kreisel_hoehen=None):
bekannte_bloecke=None, linien=None, kreisel_hoehen=None,
kreisel_achsen=None, flach=False):
"""Erkannte Vorkommen auf die Schemata aus object_data.md abbilden.
kreisel_hoehen: Liste (x, y, z) vom Anwender - z gilt fuer den Kreisel,
dessen Einfuegepunkt (Eintrag x/y) hoechstens KREISEL_HOEHE_TOL entfernt
liegt, und fuer die Gefaellestrecken, die an ihm anschliessen.
kreisel_achsen: Liste (x1, y1, x2, y2) vom Anwender - Radmitten eines
Kreisels ohne AN8/SP8 im Plan (Showroom_ILS_3_0: Schleifen, die an beiden
Enden mit Weichenboegen schliessen). Wird als STANDARD-Kreisel
angenaehert, x1/y1 gilt als AN8-Seite.
flach: siehe gf_eintraege_aus_linien.
"""
nach_familie = {}
for t in treffer:
@@ -1555,6 +1726,12 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
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 x1, y1, x2, y2 in kreisel_achsen or []:
an = {"block": "(manuell AN8)", "layer": "--kreisel-achse",
"x": x1, "y": y1, "z": 0.0, "darstellung": "2D"}
sp = {"block": "(manuell SP8)", "layer": "--kreisel-achse",
"x": x2, "y": y2, "z": 0.0, "darstellung": "2D"}
kreisel_paare.append((an, sp, math.hypot(x2 - x1, y2 - y1)))
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
@@ -1632,9 +1809,12 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
if linien:
as_liste = [t for t in nach_familie.get("schleuselement", [])
if mappe_block(t["block"])[0].startswith("AS_Element")]
es_liste = [t for t in nach_familie.get("schleuselement", [])
if mappe_block(t["block"])[0].startswith("ES_Element")]
gf, belegt, gf_hinweise = gf_eintraege_aus_linien(
linien, as_liste, kreisel_geo, nach_familie.get("sensor", []),
defaults.get("gefaelle", {}).get("default_winkel", 3.0))
defaults.get("gefaelle", {}).get("default_winkel", 3.0),
es_liste, flach)
eintraege.extend(gf)
hinweise.extend(gf_hinweise)
for familie in ("schleuselement", "sensor"):
@@ -1873,6 +2053,17 @@ def main():
"(wie im Ausgabe-JSON); gilt auch fuer Start/Ende "
"der daran anschliessenden Gefaellestrecken. "
"Mehrfach angebbar")
parser.add_argument("--kreisel-achse", nargs=4, type=float,
action="append", metavar=("X1", "Y1", "X2", "Y2"),
help="Kreisel ohne AN8/SP8 im Plan (z.B. Schleife mit "
"Weichenboegen an beiden Enden): Radmitten der "
"beiden Enden, X1/Y1 = AN8-Seite. Wird als "
"STANDARD-Kreisel angenaehert. Mehrfach angebbar")
parser.add_argument("--flach", action="store_true",
help="2D-Plan ohne Hoehen: waagrechte Staustrecken-"
"Linien mit Ausschleuselement an genau einem "
"Ende als Gefaellestrecke ausgeben (Hoehen dann "
"ueber --kreisel-hoehe)")
parser.add_argument("--projekt",
help="Projektkennung in den erzeugten IDs "
"(Default: Name der DXF-Datei ohne Endung)")
@@ -1937,7 +2128,8 @@ def main():
eintraege, hinweise = baue_eintraege(treffer, defaults, boegen, weichen,
bekannte_bloecke, linien,
args.kreisel_hoehe)
args.kreisel_hoehe, args.kreisel_achse,
args.flach)
if args.ohne_hoehen:
print("%s --ohne-hoehen: Hoehe in %d Eintraegen auf 0 gesetzt"
% (PREFIX, hoehen_entfernen(eintraege)))