fix(export): freie Separatoren nach Lage in die Kette ihrer Strecke einsortieren

Eigenstaendige Separatoren, deren Zuordnung eine VF/GF nennt, die aber nicht
beim Bau der Strecke entstanden, fehlten in deren sepliste und damit in
TrackIds und Nachbarn (Mubea VF_3: 0037/0038). Sie werden jetzt beim Export
nach Lage zwischen AS und ES aufgenommen - nur im Speicher des Laufs, die
sepliste-XDATA bleibt Bauprotokoll (Lisp paart sie per Anzahl je Typ mit den
Bausteinen und fuehrt reservierte AS/ES-IDs unter der lfdnr).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:22:36 +01:00
parent c3b4bf9db3
commit 2bb00bb48f
3 changed files with 364 additions and 0 deletions
+7
View File
@@ -42,6 +42,7 @@ from export_neighbors import (
compute_kreisel_touch_switches, compute_kreisel_touch_switches,
lisp_touch_switches, lisp_touch_switches,
load_lisp_kreisel_weichen, load_lisp_kreisel_weichen,
einsortieren_freie_separatoren,
compute_sep_kette, compute_sep_kette,
compute_kreisel_umlauf, compute_kreisel_umlauf,
SENSOR_TEILEARTEN, SENSOR_TEILEARTEN,
@@ -1543,6 +1544,12 @@ def process_blocks(blocks, lookup, dbg=None, lisp_weichen=None):
if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id: if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id:
item["merkmale"]["Naechster Separator"] = separator_id item["merkmale"]["Naechster Separator"] = separator_id
# Freie Separatoren, deren Zuordnung eine VF_n/GF_n nennt, die aber nicht
# beim Bau der Strecke entstanden sind (fehlen darum in ihrer sepliste),
# nach Lage in die Kette aufnehmen - nur fuer diesen Lauf, die XDATA am
# Block bleibt unveraendert (siehe einsortieren_freie_separatoren).
einsortieren_freie_separatoren(items, NEIGHBOR_TOLERANCE_MM, dbg=dbg)
# Ketteninterne Separator-/AS-/ES-Reihenfolge aus der neuen "sepliste"- # Ketteninterne Separator-/AS-/ES-Reihenfolge aus der neuen "sepliste"-
# XDATA (siehe ssg-sepliste-xdata-schreiben) - leeres dict, wenn keine # XDATA (siehe ssg-sepliste-xdata-schreiben) - leeres dict, wenn keine
# Strecke eine sepliste traegt (reiner Altbestand ohne neue XDATA). # Strecke eine sepliste traegt (reiner Altbestand ohne neue XDATA).
+189
View File
@@ -1255,6 +1255,190 @@ def _asel_collision_boxes(item, half_tol, box_dims):
# Ersetzt das bisherige Raten der Ketten-internen Reihenfolge (die Linie # Ersetzt das bisherige Raten der Ketten-internen Reihenfolge (die Linie
# selbst wird nicht exportiert) durch die tatsaechliche Bau-Reihenfolge. # selbst wird nicht exportiert) durch die tatsaechliche Bau-Reihenfolge.
def sepliste_einsortier_positionen(kette_xy, kandidaten):
"""Reiner Rechenkern fuer einsortieren_freie_separatoren: wohin gehoert
jeder Kandidat in eine vorhandene Kette?
kette_xy = [(x, y), ...] Lage der Kettenglieder in Kettenfolge (AS ...
ES), mindestens zwei Punkte. Sie bilden einen Polygonzug - so
folgt die Zuordnung auch einer Kette, die um eine Kurve laeuft,
statt nur einer Geraden AS->ES.
kandidaten = [(key, x, y), ...]
Jeder Kandidat wird dem naechstgelegenen Abschnitt (Glied i -> i+1)
zugeordnet. Liegt er dabei VOR dem ersten oder HINTER dem letzten Glied,
wird er verworfen - eingefuegt wird nur zwischen AS und ES.
Rueckgabe (einfuegen, verworfen): einfuegen = {i: [key, ...]} - nach
Glied i einzufuegen, untereinander in Kettenrichtung sortiert;
verworfen = [key, ...].
"""
abschnitte = []
for i in range(len(kette_xy) - 1):
(ax, ay), (bx, by) = kette_xy[i], kette_xy[i + 1]
dx, dy = bx - ax, by - ay
l2 = dx * dx + dy * dy
if l2 > 1e-12:
abschnitte.append((i, ax, ay, dx, dy, l2))
letzter = len(kette_xy) - 2
einfuegen = {}
verworfen = []
for key, x, y in kandidaten:
best = None
for i, ax, ay, dx, dy, l2 in abschnitte:
t = ((x - ax) * dx + (y - ay) * dy) / l2
tc = min(max(t, 0.0), 1.0)
d = math.hypot(x - (ax + tc * dx), y - (ay + tc * dy))
if best is None or d < best[0] - 1e-9:
best = (d, i, t)
if best is None:
verworfen.append(key)
continue
_d, i, t = best
if (i == 0 and t <= 0.0) or (i == letzter and t >= 1.0):
verworfen.append(key)
continue
einfuegen.setdefault(i, []).append((min(max(t, 0.0), 1.0), key))
return ({i: [key for _t, key in sorted(liste)] for i, liste in einfuegen.items()},
verworfen)
def _kettenglied_xy(entry, item, strecke):
"""Lage eines sepliste-Glieds fuer den Polygonzug: AS/ES ueber K1/K2 der
Strecke (Ein-/Auslauf - der ZZ_ASES_MARKER sitzt auf dem Einfuegepunkt
des Elements und kann bis ~600 mm seitlich davon liegen), sonst bzw.
ohne K1/K2 der Bounding-Box-Mittelpunkt des vertretenden Items."""
typ = entry.get("typ", "")
if typ in ("AS", "ES"):
k1_xy, k2_xy = _asel_kos_xy(strecke)
pt = k1_xy if typ == "AS" else k2_xy
if pt is not None:
return pt
bbox = (item or {}).get("_bbox")
if not bbox:
return None
return (bbox.get("cx", 0.0), bbox.get("cy", 0.0))
def einsortieren_freie_separatoren(items, tolerance_mm, dbg=None):
"""Nimmt freie Separatoren in die sepliste ihrer VF_n/GF_n-Strecke auf.
Die sepliste entsteht beim BAU der Strecke (ssg-sepliste-sammeln) und
kennt nur die Separatoren, die dabei erzeugt wurden. Ein eigenstaendiger
Separator_SP-Block, der spaeter (oder aus einer frueheren Fassung der
Strecke) auf ihr liegt, fehlt darin - obwohl seine ZUORDNUNG
(cs-zuordnung-lauf) die Strecke nennt. Ohne diesen Schritt fehlt er in
TrackIds und Nachbarn (realer Fall Mubea VF_3: 0037/0038 zwischen 0062
und 0063).
Aufgenommen wird ein Separator nur, wenn
- seine Zuordnung die Strecke nennt,
- er in keiner sepliste steht,
- sein Mittelpunkt in der Bounding-Box der Strecke liegt (+ tolerance_mm/2),
- er laengs der Kette zwischen AS und ES liegt
(sepliste_einsortier_positionen).
Bewusst NUR im Speicher dieses Exportlaufs: die sepliste-XDATA am Block
bleibt das Bauprotokoll. Lisp ordnet ihre Eintraege den eingebauten
Bausteinen ueber die Anzahl je Typ zu (csv:sepliste-kette-paare) und
fuehrt reservierte AS/ES-IDs unter der lfdnr - ein in die XDATA
eingefuegter fremder Separator verschoebe beides. Ausserdem schreibt jeder
Neuaufbau der Strecke die XDATA neu; hier wird bei jedem Export frisch
aus dem aktuellen Zeichnungsstand gerechnet.
Aendert items in-place: item["sepliste"] der Strecke bekommt neue
SEP-Eintraege (lfdnr "frei<TeileId>", "id" = TeileId des Separators,
"frei": True); der Separator bekommt "_in_strecke_kette" (Strecken-
TeileId, damit compute_kreisel_umlauf ihn nicht zusaetzlich einem
Kreisel zuschlaegt) und einen Hinweis in "warnung".
Muss NACH der Separator-Zuordnung und VOR compute_sep_kette laufen.
"""
half_tol = tolerance_mm / 2.0
by_teileid = {it.get("teileid", ""): it for it in items if it.get("teileid", "")}
in_ketten = set()
for it in items:
if it.get("teileart", "") in GF_VF_TEILEARTEN:
for e in it.get("sepliste") or []:
eid = str(e.get("id", "")).strip()
if eid:
in_ketten.add(eid)
if dbg:
dbg("=== einsortieren_freie_separatoren ===")
for strecke in items:
if strecke.get("teileart", "") not in GF_VF_TEILEARTEN:
continue
sepliste = strecke.get("sepliste")
sbbox = strecke.get("_bbox")
if not sepliste or not sbbox:
continue
sid = strecke.get("teileid", "")
sbounds = _bounds(sbbox, half_tol)
kandidaten = []
for it in items:
if it.get("teileart", "") != "ILS 2.0 Separator":
continue
tid = it.get("teileid", "")
if not tid or tid in in_ketten:
continue
zuordnung = ((it.get("merkmale") or {}).get("Zuordnung")
or it.get("_zuordnung_attr") or "").strip()
if zuordnung != sid:
continue
bbox = it.get("_bbox")
if not bbox:
continue
cx, cy = bbox.get("cx", 0.0), bbox.get("cy", 0.0)
if not (sbounds[0] <= cx <= sbounds[1] and sbounds[2] <= cy <= sbounds[3]):
if dbg:
dbg(f" Strecke {sid}: Separator {tid} ausserhalb der Strecken-BBox "
f"- nicht einsortiert")
continue
kandidaten.append((tid, cx, cy))
if not kandidaten:
continue
kette_xy = []
for e in sepliste:
pt = _kettenglied_xy(e, by_teileid.get(str(e.get("id", "")).strip()), strecke)
if pt is None:
break
kette_xy.append(pt)
if len(kette_xy) != len(sepliste) or len(kette_xy) < 2:
if dbg:
dbg(f" Strecke {sid}: Kettenglieder nicht vollstaendig lokalisierbar "
f"- freie Separatoren {[k[0] for k in kandidaten]} nicht einsortiert")
continue
einfuegen, verworfen = sepliste_einsortier_positionen(kette_xy, kandidaten)
if dbg and verworfen:
dbg(f" Strecke {sid}: {verworfen} liegen nicht zwischen AS und ES "
f"- nicht einsortiert")
if not einfuegen:
continue
neu = []
for i, entry in enumerate(sepliste):
neu.append(entry)
for tid in einfuegen.get(i, []):
neu.append({"typ": "SEP", "lfdnr": f"frei{tid}", "id": tid, "frei": True})
sep = by_teileid[tid]
sep["_in_strecke_kette"] = sid
hinweis = (f"nicht im Bau von {sid} erzeugt - per Lage in dessen "
f"Kette einsortiert")
bisherige = sep.get("warnung", "")
sep["warnung"] = f"{bisherige}; {hinweis}" if bisherige else hinweis
in_ketten.add(tid)
strecke["sepliste"] = neu
if dbg:
dbg(f" Strecke {sid}: Kette jetzt "
f"{' -> '.join(str(e.get('id', '')) for e in neu)}")
def compute_sep_kette(items, dbg=None): def compute_sep_kette(items, dbg=None):
"""Baut je VF_n/GF_n-Item MIT "sepliste" die ketteninterne Vorgaenger-/ """Baut je VF_n/GF_n-Item MIT "sepliste" die ketteninterne Vorgaenger-/
Nachfolger-Beziehung AS -> Sep1 -> ... -> SepN -> ES. Nachfolger-Beziehung AS -> Sep1 -> ... -> SepN -> ES.
@@ -1392,6 +1576,11 @@ def compute_kreisel_umlauf(items, tolerance_mm, durchmesser_mm=800.0,
for item in items: for item in items:
if item.get("teileart", "") not in KREISEL_UMLAUF_TEILEARTEN: if item.get("teileart", "") not in KREISEL_UMLAUF_TEILEARTEN:
continue continue
# Freier Separator, den einsortieren_freie_separatoren in die Kette
# einer Strecke aufgenommen hat - gehoert nicht zusaetzlich auf einen
# Kreisel, auch wenn seine Box einen beruehrt.
if item.get("_in_strecke_kette"):
continue
teileid = item.get("teileid", "") teileid = item.get("teileid", "")
bbox = item.get("_bbox") bbox = item.get("_bbox")
if not bbox: if not bbox:
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
test_export_freie_separatoren.py - freie Separatoren in der Kette einer Strecke.
Laeuft komplett ohne BricsCAD (reine Python-Logik, siehe lib/export_neighbors.py).
Szenario nach einem realen Mubea-Befund (VF_3 = 0015): die sepliste aus dem
Bau kennt nur AS 0061, SEP 0062, SEP 0063, ES 0064. Die eigenstaendigen
Separatoren 0037/0038 liegen auf der Strecke, ihre Zuordnung nennt 0015 - sie
fehlten aber in TrackIds und Nachbarn. einsortieren_freie_separatoren nimmt
sie nach Lage auf.
Alle Daten sind hier fest eingetragen (Stand des Mubea-Exports 2026-10-28) und
werden aus keiner Datei gelesen - aendert sich die Mubea-Zeichnung, bleibt
dieser Test unberuehrt. Er prueft den Algorithmus, nicht die Szene.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
from export_neighbors import ( # noqa: E402
compute_sep_kette,
einsortieren_freie_separatoren,
sepliste_einsortier_positionen,
)
from export_csv import ( # noqa: E402
map_sepliste_kette_items,
resolve_umlauf_und_sepliste_nachbarn,
write_separatorliste_merkmale,
)
TOL_MM = 100.0
def _sep(teileid, x, y, zuordnung):
return {
"teileart": "ILS 2.0 Separator",
"teileid": teileid,
"merkmale": {"Zuordnung": zuordnung},
"nachbarn": "",
"_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 150.0, "dy": 210.0},
}
def _ases(teileart, teileid, x, y, zuordnung):
return {
"teileart": teileart,
"teileid": teileid,
"_zuordnung": zuordnung,
"merkmale": {},
"nachbarn": "",
"_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 200.0, "dy": 200.0},
}
def _mubea_vf3():
strecke = {
"teileart": "ILS 2.0 Strecke",
"teileid": "0015",
"nachbarn": "0002-L",
"merkmale": {},
"k1": "", "k2": "",
"_bbox": {"cx": -631.28, "cy": 6543.11, "cz": 1624.70,
"dx": 1018.09, "dy": 12791.0},
"sepliste": [
{"typ": "AS", "lfdnr": 1, "id": "0061"},
{"typ": "SEP", "lfdnr": 2, "id": "0062"},
{"typ": "SEP", "lfdnr": 3, "id": "0063"},
{"typ": "ES", "lfdnr": 4, "id": "0064"},
],
}
return [
strecke,
_ases("ILS Ausschleuselement", "0061", -400.00, 270.54, "0015"),
_sep("0062", -898.87, 1134.60, "0015"),
_sep("0063", -702.98, 12307.81, "0015"),
_ases("ILS Einschleuselement", "0064", -400.00, 12832.58, "0015"),
_sep("0037", -1026.00, 10723.05, "0015"),
_sep("0038", -1026.00, 5250.05, "0015"),
]
def _by_id(items, teileid):
return next(it for it in items if it.get("teileid") == teileid)
def _pipeline(items):
einsortieren_freie_separatoren(items, TOL_MM)
sep_kette = compute_sep_kette(items)
sep_kette_by_key = map_sepliste_kette_items(items, sep_kette)
write_separatorliste_merkmale(items, sep_kette_by_key)
resolve_umlauf_und_sepliste_nachbarn(items, {}, sep_kette, sep_kette_by_key, {})
return items
class TestEinsortierPositionen:
"""Reiner Rechenkern, ohne Items."""
KETTE = [(0.0, 0.0), (0.0, 1000.0), (0.0, 5000.0), (0.0, 6000.0)]
def test_zwei_kandidaten_im_selben_abschnitt_sortiert(self):
einfuegen, verworfen = sepliste_einsortier_positionen(
self.KETTE, [("B", 50.0, 4000.0), ("A", -50.0, 2000.0)])
assert einfuegen == {1: ["A", "B"]}
assert verworfen == []
def test_vor_as_und_hinter_es_verworfen(self):
einfuegen, verworfen = sepliste_einsortier_positionen(
self.KETTE, [("vor", 0.0, -500.0), ("hinter", 0.0, 6500.0)])
assert einfuegen == {}
assert sorted(verworfen) == ["hinter", "vor"]
def test_kette_um_eine_kurve(self):
"""Polygonzug statt Gerade AS->ES: ein Separator auf dem zweiten
Schenkel einer L-Kette gehoert in DEN Abschnitt, nicht nach
seiner Projektion auf die Diagonale."""
kette = [(0.0, 0.0), (0.0, 3000.0), (3000.0, 3000.0)]
einfuegen, _ = sepliste_einsortier_positionen(kette, [("S", 1500.0, 3050.0)])
assert einfuegen == {1: ["S"]}
def test_entartete_kette(self):
einfuegen, verworfen = sepliste_einsortier_positionen(
[(0.0, 0.0), (0.0, 0.0)], [("S", 10.0, 10.0)])
assert einfuegen == {}
assert verworfen == ["S"]
class TestMubeaVf3:
def test_trackids_mit_freien_separatoren(self):
items = _pipeline(_mubea_vf3())
assert _by_id(items, "0015").get("trackids") == \
"0061, 0062, 0038, 0037, 0063, 0064"
def test_nachbarn_der_kette(self):
items = _pipeline(_mubea_vf3())
assert _by_id(items, "0062")["nachbarn"] == "0061, 0038"
assert _by_id(items, "0038")["nachbarn"] == "0062, 0037"
assert _by_id(items, "0037")["nachbarn"] == "0038, 0063"
assert _by_id(items, "0063")["nachbarn"] == "0037, 0064"
def test_hinweis_am_freien_separator(self):
items = _pipeline(_mubea_vf3())
assert "0015" in _by_id(items, "0037").get("warnung", "")
assert _by_id(items, "0062").get("warnung", "") == ""
def test_fremde_zuordnung_bleibt_draussen(self):
items = _mubea_vf3()
_by_id(items, "0037")["merkmale"]["Zuordnung"] = "0002-L"
_pipeline(items)
assert _by_id(items, "0015").get("trackids") == "0061, 0062, 0038, 0063, 0064"
def test_ausserhalb_der_strecken_bbox_bleibt_draussen(self):
items = _mubea_vf3()
_by_id(items, "0038")["_bbox"]["cx"] = -3000.0
_pipeline(items)
assert _by_id(items, "0015").get("trackids") == "0061, 0062, 0037, 0063, 0064"
def test_ohne_ids_kein_einsortieren(self):
"""Altbestand ohne aufgeloeste IDs: Glieder nicht lokalisierbar ->
sepliste bleibt wie gebaut."""
items = _mubea_vf3()
for e in _by_id(items, "0015")["sepliste"]:
e["id"] = ""
einsortieren_freie_separatoren(items, TOL_MM)
assert len(_by_id(items, "0015")["sepliste"]) == 4