Files
dxfmakros/lib/export_neighbors.py
T
Samer AyadiandClaude Sonnet 5 ab3958e0e4 export_csv: AS/ES direkt aus ZZ_ASES_MARKER klassifizieren, Kreisel<->Strecke-Kollisionserzeugung entfernen
Stufe 2 (Python-Seite von "sepliste: Bauteil-IDs eintragen"): AS/ES-Kettenenden
sind jetzt ganz normale Items, direkt aus dem dauerhaften ZZ_ASES_MARKER
klassifiziert (TeileId/Position/Boundingbox vom Marker-Block selbst) - keine
Kollisionsberechnung mehr, die sie erfindet.

- lib/export_neighbors.py: compute_strecke_kreisel_schleus entfernt (suchte
  bisher per Kollisionstest den anschliessenden Kreisel und erzeugte dabei bei
  JEDEM Export eine neue TeileId fuer denselben physischen Punkt). AUSSCHLEUS_/
  EINSCHLEUS_TEILEART sind jetzt Teil von KREISEL_UMLAUF_TEILEARTEN - ein AS/ES
  bringt seine eigene Bounding-Box vom Marker mit und wird dadurch, wie jeder
  freie Separator, ueber die normale BBox-Ueberschneidung einem Kreisel
  zugeordnet; die gesonderte "naechster Kreisel per sep_kette"-Suche in
  compute_kreisel_umlauf (dessen sep_kette-Parameter damit entfaellt) ist
  ueberfluessig geworden. compute_sep_kette gibt je Eintrag zusaetzlich das
  Feld "id" weiter (Bauteil-TeileId, siehe vorheriger Commit).
- lib/export_csv.py: process_blocks klassifiziert ZZ_ASES_MARKER direkt (TYP-
  Attribut waehlt Ausschleus-/Einschleuselement); build_strecke_schleus_items
  entfaellt komplett. map_separator_kette_items -> map_sepliste_kette_items:
  ordnet jetzt AS/SEP/ES exakt per ID-Gleichheit zu (sepliste-Feld "id" gegen
  die TeileId der klassifizierten Items) statt per Koordinaten-Naehe - SEP
  faellt bei fehlendem "id"-Feld weiterhin auf den alten Naehe-Abgleich zurueck
  (Altbestand), AS/ES ohne "id" bleiben unaufgeloest.
- Neu: pruefe_ases_in_beiden_containern prueft am Ende von process_blocks, ob
  jede AS-/ES-Zeile sowohl in den TrackIds ihrer eigenen Strecke als auch in
  denen mindestens eines Kreisels auftaucht, und traegt bei Abweichung eine
  Warnung ein (Spalte "Warnungen") - Konsistenzpruefung fuer genau die beiden
  "Container", zu denen ein AS/ES gehoert.
- Aufgeraeumt (tote Routinen): _strecke_ends, _closest_pt_on_segment,
  _distinct_kreisel_ids (nur von den entfernten Funktionen genutzt), sowie der
  temporaere Diagnose-Filter DEBUG_HIGHLIGHT_TEILEIDS/dbg_highlight (an die
  jetzt behobene Koordinaten-Matching-Ambiguitaet gebunden, in seinem eigenen
  Kommentar bereits als Wegwerf-Code markiert). LISP-seitiger Dead-Code-Scan
  (export.lsp, ssg_ks_insert.lsp - die diese Woche bearbeiteten Module):
  keine Funde.

Verifiziert an den echten HundM05-Daten (results/export_raw.json, 45 Bloecke):
TeileId-Bereich luecken- und duplikatfrei 1-45 (vorher 1-55 mit Luecke 24-33 +
Zusatz-IDs 46-55), alle 10 AS/ES-IDs korrekt in ihrer Strecke UND im Kreisel
06 TrackIds, keine Warnungen der neuen Pruefung. Zwei Testdateien auf die neue
Architektur umgestellt (test_export_kreisel_umlauf.py: AS/ES als Item statt
Kollisions-Erzeugung, neue Klasse TestAsEsImKreiselUmlauf fuer die BBox-
Teilnahme; test_export_separatorliste.py: id-basierte Zuordnung, Klasse
TestMarkerIdWiederverwendung durch TestMapSeplisteKetteItemsFallback ersetzt
- die alte testete inzwischen entfernte Mint-Logik). Volle Suite: 224 bestanden
(4 vorbestehende, unabhaengige Fixture-Failures unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:48:45 +02:00

1403 lines
64 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
export_neighbors.py - Ermittelt benachbarte Elemente ueber Bounding-Box-
Ueberschneidung in der x/y-Ebene (Grundriss, Z/Hoehe wird ignoriert).
Die Bloecke werden dazu in drei Gruppen eingeteilt und NUR innerhalb dieser
Kombinationen gegeneinander geprueft (nicht mehr alle Elemente einer Zeichnung
gemeinsam):
1. Kreisel/Eckrad gegen Kreisel/Eckrad
2. Kreisel/Eckrad gegen Gefaellestrecke/Foerderer/Strecke-Modul - diese
drei Kategorien werden NICHT gegeneinander getestet (nur relevant, wo
sie an einen Kreisel/Eckrad anschliessen)
3. Omniflo-Elemente (Bogen/Weiche/Gerade) gegen Omniflo-Elemente
Omniflo-Anlagen koennen mehrere hundert Elemente enthalten - ein Test jedes
gegen jedes (O(n^2)) waere dort zu teuer. Die Omniflo-Gruppe wird daher in
zwei Phasen geprueft:
* Broad-Phase: Ein shapely-STRtree ueber die (tolerierten) Bounding-Boxen
liefert alle Paare, deren BBoxen sich in x/y ueberschneiden - die
"moeglichen Nachbarn". Ist shapely nicht verfuegbar, faellt die
Broad-Phase auf ein Raster (Zellgroesse siehe [Nachbarschaft] ->
omniflo_zellgroesse_mm in cfg/export.cfg) mit 3x3-Zellennachbarschaft
zurueck.
* Narrow-Phase (KOS-Verfeinerung): Fuer jedes moegliche Nachbarpaar wird
zusaetzlich geprueft, ob sich ihre Anschluss-Koordinatensysteme K1-K4
tatsaechlich beruehren - nur wenn ein K-Punkt des einen Elements naeher
als omniflo_ks_toleranz_mm (Default 10mm, siehe [Nachbarschaft]) an einem
K-Punkt des anderen liegt, gelten sie als echte Nachbarn. Das trennt
Elemente, deren BBoxen sich zwar ueberlappen, die aber nicht an einem
gemeinsamen Anschlusspunkt zusammenstossen. Elemente ohne K1-K4 (z.B.
Altbestand) fallen auf die reine BBox-Ueberschneidung zurueck. Die
K-Punkte kommen aus den csv:trans-encode-Strings der CSV-Spalten K1-K4
(siehe _omniflo_kpoints); Geraden fuehren keine echten K-Bloecke, ihre
K1/K2 werden beim Export synthetisiert (Lisp/export.lsp).
Zwei Bounding-Boxes gelten als benachbart, wenn sie sich (nach Erweiterung
um die halbe Toleranz je Seite) in x/y ueberschneiden. Toleranz aus
cfg/export.cfg ([Nachbarschaft] -> toleranz_mm), Wert in mm.
compute_neighbor_errors() prueft zusaetzlich, ob jedes Element die fuer
seine TeileArt noetige Mindestanzahl an Partnern (Nachbarn) hat (siehe
MIN_PARTNER) - Ergebnis ist die CSV-Spalte "Fehler" in export_csv.py.
Nur von export_csv.py genutzt (EXPORTCSV), nicht von export_sivas.py.
"""
import math
import os
from export_blockpatterns import cfg_path_from_env, load_export_cfg, safe_float
# shapely-STRtree fuer die Broad-Phase der Omniflo-Nachbarschaft (optional -
# fehlt shapely, wird auf das Raster _candidate_pairs_grid zurueckgefallen).
try:
from shapely import STRtree
from shapely.geometry import box as _shapely_box
_HAVE_STRTREE = True
except ImportError: # pragma: no cover - shapely ist optional
STRtree = None
_shapely_box = None
_HAVE_STRTREE = False
# Base64-Alphabet + Fixed-Point-Faktor von csv:trans-encode (Lisp/export.lsp) -
# zum Zurueckrechnen der K1-K4-Positionsstrings in x/y-Weltkoordinaten (mm).
_B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
_B64_INDEX = {c: i for i, c in enumerate(_B64_CHARS)}
_TRANS_FAKTOR = 10.0
KREISEL_TEILEARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"}
STRECKEN_TEILEARTEN = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"}
OMNIFLO_TEILEARTEN = {"Omniflo Kurve", "Omniflo Weiche", "Omniflo Gerade"}
SENSOR_TEILEARTEN = {"ILS 2.0 Separator", "ILS 2.0 Scanner"}
# Frei auf der Bahn platzierte Elemente, die im Kreisel-Umlauf
# (compute_kreisel_umlauf) und in der Kreisel-CSV-Spalte "TrackIds"
# (export_csv.py write_kreisel_separatorliste_merkmale) als eigene Punkte
# auftreten koennen -
# wie ein Separator per Bounding-Box-Ueberschneidung einer Kreiselhaelfte/
# einem Kreisel zugeordnet (siehe compute_sensor_zuordnung/compute_kreisel_
# umlauf). BTMT-Beladung/-Entladung sind Stationen AUF der Strecke, keine
# Sensoren - eigene Konstante statt Erweiterung von SENSOR_TEILEARTEN, damit
# deren Sensor-spezifische Weiterverarbeitung (z.B. compute_scanner_nearest_
# separator) unberuehrt bleibt.
# Eingang/Ausgang (Materialfluss-Ein-/Ausgangssymbole, siehe pattern_eingang/
# pattern_ausgang in cfg/export.cfg) werden hier wie BTMT als frei platzierte
# Umlauf-Punkte gefuehrt - so tauchen sie, wenn sie auf einer Kreiselbahn
# liegen, in dessen "TrackIds"-Spalte auf (Nutzeranforderung: wie
# Separator/AS/ES behandeln). AUSSCHLEUS_TEILEART/EINSCHLEUS_TEILEART (AS/ES-
# Kettenenden, siehe unten): seit sie als dauerhafter ZZ_ASES_MARKER real in
# der Zeichnung stehen (statt wie frueher nur ueber eine Kreisel<->Strecke-
# Kollisionsberechnung synthetisiert zu werden, siehe git-Historie
# compute_strecke_kreisel_schleus), reicht ihre EIGENE Bounding-Box fuer die
# ganz normale BBox-Ueberschneidung mit einem Kreisel - keine gesonderte
# Naeherungs-/Kollisionslogik mehr noetig (siehe compute_kreisel_umlauf, das
# vorher zusaetzlich sep_kette nach der naechstgelegenen Kreisel-Achse absuchte).
# Die TeileArt-Strings sind bewusst OHNE "ILS 2.0"-Praefix (siehe
# export_csv.py) - dieselben Strings muessen hier stehen.
KREISEL_UMLAUF_TEILEARTEN = {
"ILS 2.0 Separator", "ILS 2.0 BTMT Beladung", "ILS 2.0 BTMT Entladung", "ILS Weiche",
"Eingang", "Ausgang", "ILS Ausschleuselement", "ILS Einschleuselement",
}
# Nur "ILS 2.0 Kreisel" (echte Kreisel-Bloecke mit AN8/Antrieb + SP8/Spann-
# station + ABSTAND-Attribut) werden links/rechts gesplittet - "ILS 2.0
# Eckrad" hat keine dieser zwei Stationen und bleibt eine einzelne BBox.
KREISEL_SPLIT_TEILEART = "ILS 2.0 Kreisel"
# TeileArt der automatisch am Kreisel-Beruehrpunkt erzeugten Weiche
# (compute_kreisel_touch_switches / export_csv.py).
WEICHE_TEILEART = "ILS Weiche"
# TeileArten der Ein-/Ausschleuselemente an den Strecken-Enden (AS/ES) einer
# Gefaellestrecke/eines Foerderers - siehe process_blocks (export_csv.py):
# direkt aus dem dauerhaften ZZ_ASES_MARKER klassifiziert (dessen Attribut
# TYP="AS"/"ES" waehlt die TeileArt), keine gesonderte Erzeugung mehr hier.
AUSSCHLEUS_TEILEART = "ILS Ausschleuselement"
EINSCHLEUS_TEILEART = "ILS Einschleuselement"
# Strecken-TeileArten, die Ein-/Ausschleuselemente bekommen: Gefaellestrecke
# und (Vario-)Foerderer. "ILS 2.0 Strecke - Modul" (Einzelkomponenten) bewusst
# NICHT - nur die kompletten GF_n/VF_n-Bloecke.
GF_VF_TEILEARTEN = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke"}
# Fallback-Masse (mm) der festen Element-Bounding-Box, falls cfg/export.cfg
# [Boundingbox] element_box_* nicht gesetzt/lesbar ist (siehe
# load_element_box_mm). NICHT direkt verwenden - die Rechenfunktionen bekommen
# die Masse als Parameter (Default = load_element_box_mm()), damit die cfg-Werte
# greifen. Laengsseite (LAENGE) quer zur Foerder-/Kreiselachse, BREITE laengs
# (siehe _oriented_box_aabb).
WEICHE_BOX_LAENGE_MM = 250.0
WEICHE_BOX_BREITE_MM = 100.0
WEICHE_BOX_HOEHE_MM = 30.0
def load_element_box_mm(cfg_path=None):
"""Feste Bounding-Box-Masse (Laenge, Breite, Hoehe in mm) der synthetischen
Export-Elemente (ILS-Weichen an Kreisel-Beruehrpunkten, ILS-Ein-/Aus-
schleuselemente an GF/VF-Enden) sowie der AS/ES-Kollisionsbox aus
cfg/export.cfg [Boundingbox] element_box_laenge_mm / _breite_mm / _hoehe_mm.
Fallback auf WEICHE_BOX_*_MM, falls die Sektion/Schluessel fehlen.
Rueckgabe: (laenge, breite, hoehe)."""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
return (
parser.getfloat("Boundingbox", "element_box_laenge_mm", fallback=WEICHE_BOX_LAENGE_MM),
parser.getfloat("Boundingbox", "element_box_breite_mm", fallback=WEICHE_BOX_BREITE_MM),
parser.getfloat("Boundingbox", "element_box_hoehe_mm", fallback=WEICHE_BOX_HOEHE_MM),
)
# Fallback-Masse (mm) der festen Separator-Symbol-Bounding-Box, falls
# cfg/export.cfg [Boundingbox] separator_box_* nicht gesetzt/lesbar ist (siehe
# load_separator_box_mm). LAENGE laengs des Symbols (bei Drehung 0 entlang X),
# BREITE quer, HOEHE = Z. Gemessenes Separator_SP-Symbol (~210 x 150 x 14 mm).
SEPARATOR_BOX_LAENGE_MM = 210.0
SEPARATOR_BOX_BREITE_MM = 150.0
SEPARATOR_BOX_HOEHE_MM = 14.0
def load_separator_box_mm(cfg_path=None):
"""Feste Bounding-Box-Masse (Laenge, Breite, Hoehe in mm) eines Separator-
Symbols aus cfg/export.cfg [Boundingbox] separator_box_laenge_mm /
_breite_mm / _hoehe_mm. Gebraucht fuer die in einer VF_n/GF_n-Kette
eingebetteten Separatoren (sepliste-XDATA), die kein reales INSERT mehr
sind und darum von vla-getboundingbox keine Box bekommen. Fallback auf
SEPARATOR_BOX_*_MM, falls die Sektion/Schluessel fehlen.
Rueckgabe: (laenge, breite, hoehe)."""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
return (
parser.getfloat("Boundingbox", "separator_box_laenge_mm", fallback=SEPARATOR_BOX_LAENGE_MM),
parser.getfloat("Boundingbox", "separator_box_breite_mm", fallback=SEPARATOR_BOX_BREITE_MM),
parser.getfloat("Boundingbox", "separator_box_hoehe_mm", fallback=SEPARATOR_BOX_HOEHE_MM),
)
def separator_bbox(x, y, rotation_grad, separator_box_mm=None, z=0.0):
"""Achsparallele Welt-Bounding-Box (_bbox-dict) eines Separator-Symbols an
Weltposition (x, y) mit Drehung rotation_grad. Die feste Symbol-Box
(separator_box_mm = (laenge, breite, hoehe)) wird um rotation_grad gedreht
und ihre achsparallele Welt-Huelle zurueckgegeben - fuer eingebettete
sepliste-Separatoren, die keine echte Zeichnungsgeometrie haben.
Rueckgabe: dict {"cx","cy","cz","dx","dy","dz"} wie csv:get-bbox / die
"_bbox" der realen Bloecke (siehe export_csv.bbox_columns)."""
if separator_box_mm is None:
separator_box_mm = load_separator_box_mm()
laenge, breite, hoehe = separator_box_mm
# Laenge liegt bei Drehung 0 entlang X, Breite entlang Y (anders als
# _oriented_box_aabb, das die Laengsseite QUER zur Achse legt - hier ist
# es die reine Symbol-AABB, konsistent mit der gemessenen Referenz).
rot = math.radians(rotation_grad or 0.0)
c, s = abs(math.cos(rot)), abs(math.sin(rot))
dx = laenge * c + breite * s
dy = laenge * s + breite * c
return {"cx": x, "cy": y, "cz": z, "dx": dx, "dy": dy, "dz": hoehe}
# Mindestanzahl Partner (Nachbarn) je TeileArt fuer die Fehlerspalte "Fehler"
# (siehe compute_neighbor_errors): Kreisel/Eckrad und Omniflo-Elemente
# brauchen mindestens einen Partner, Gefaellestrecke/Foerderer/Strecke-Modul
# muessen an zwei Seiten anschliessen (mindestens zwei Partner). TeileArten,
# die hier nicht auftauchen (z.B. die synthetische "Omniflo Sum"-Zeile),
# werden nicht geprueft.
MIN_PARTNER = {}
MIN_PARTNER.update({t: 1 for t in KREISEL_TEILEARTEN})
MIN_PARTNER.update({t: 1 for t in OMNIFLO_TEILEARTEN})
MIN_PARTNER.update({t: 2 for t in STRECKEN_TEILEARTEN})
def load_neighbor_tolerance_mm(cfg_path=None):
"""Liest die Nachbarschafts-Toleranz (mm) aus export.cfg. Default: 0."""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
return parser.getfloat("Nachbarschaft", "toleranz_mm", fallback=0.0)
def load_omniflo_cell_size_mm(cfg_path=None):
"""Liest die Rastergroesse (mm) fuer die Omniflo-Nachbarschaftspruefung.
Default 3000mm: groesser als die ueblichen Omniflo-Bauteile (AP110-
Geraden ca. 2000mm), damit ein Element nicht durch ein zu kleines
Raster in mehr als eine Zelle ueberlappt.
"""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
return parser.getfloat("Nachbarschaft", "omniflo_zellgroesse_mm", fallback=3000.0)
def load_omniflo_ks_tolerance_mm(cfg_path=None):
"""Liest die Toleranz (mm) fuer die KOS-basierte Verfeinerung der Omniflo-
Nachbarschaft (K1-K4-Anschlusspunkte) aus export.cfg. Default 10mm.
Nachdem der STRtree zwei Omniflo-Elemente anhand ihrer Bounding-Boxen als
moegliche Nachbarn erkannt hat, gelten sie nur dann als echte Nachbarn,
wenn ein K-Punkt des einen naeher als dieser Wert an einem K-Punkt des
anderen liegt. 0 = Verfeinerung aus (nur BBox). Siehe [Nachbarschaft] ->
omniflo_ks_toleranz_mm.
"""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
return parser.getfloat("Nachbarschaft", "omniflo_ks_toleranz_mm", fallback=10.0)
def load_kreisel_durchmesser_mm(cfg_path=None):
"""Liest den Kreisel-Durchmesser (mm) aus export.cfg. Default: 800.0
(siehe [Kreisel] durchmesser_mm - gespiegelter Wert von
data/json/component_defaults.json "kreisel"."durchmesser")."""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
return parser.getfloat("Kreisel", "durchmesser_mm", fallback=800.0)
def load_collision_debug_target(cfg_path=None):
"""Zielpfad fuer das Kollisions-/Nachbarschafts-Debuglog aus export.cfg.
[Nachbarschaft] -> debug_log: leer/0/false/no/off = aus (Rueckgabe None).
1/true/yes/on = DXFM_LOG/export_collision.dbg (bzw. <repo>/logs/, falls
DXFM_LOG nicht gesetzt ist). Jeder andere Wert wird als expliziter
Dateipfad interpretiert.
"""
if cfg_path is None:
cfg_path = cfg_path_from_env()
parser = load_export_cfg(cfg_path)
raw = parser.get("Nachbarschaft", "debug_log", fallback="").strip()
if not raw or raw.lower() in ("0", "false", "no", "off", "nein", "aus"):
return None
if raw.lower() in ("1", "true", "yes", "on", "ja", "ein"):
log_dir = os.environ.get("DXFM_LOG")
if not log_dir:
# cfg_path zeigt auf <repo>/cfg/export.cfg -> <repo>/logs
log_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(cfg_path))), "logs")
return os.path.join(log_dir, "export_collision.dbg")
return raw # expliziter Pfad
def _bounds(bbox, half_tol):
"""(minx, maxx, miny, maxy) einer Bounding-Box, je Seite um half_tol erweitert."""
return (
bbox["cx"] - bbox["dx"] / 2.0 - half_tol,
bbox["cx"] + bbox["dx"] / 2.0 + half_tol,
bbox["cy"] - bbox["dy"] / 2.0 - half_tol,
bbox["cy"] + bbox["dy"] / 2.0 + half_tol,
)
def _overlaps(bounds_a, bounds_b):
"""True, wenn sich zwei (minx, maxx, miny, maxy)-Rechtecke in x/y ueberschneiden."""
a_minx, a_maxx, a_miny, a_maxy = bounds_a
b_minx, b_maxx, b_miny, b_maxy = bounds_b
return a_minx <= b_maxx and b_minx <= a_maxx and a_miny <= b_maxy and b_miny <= a_maxy
def _add_neighbor(result, idx_a, idx_b, id_a, id_b):
result[idx_a].append(id_b)
result[idx_b].append(id_a)
def kreisel_half_bboxes(block, durchmesser_mm):
"""Teilt die Welt-Bounding-Box eines "ILS 2.0 Kreisel"-Blocks entlang der
Kreiselachse (Linie durch die Mitten von Antriebs- (AN8) und Spann-
station (SP8)) in eine linke und eine rechte Haelfte.
Geometrie (siehe KreiselInsert.lsp::draw-module): AN8 liegt in lokalen
(unrotierten) Blockkoordinaten bei (radius, 0), SP8 bei
(radius+abstand, 0) - radius = durchmesser_mm/2 (Default 400mm, siehe
[Kreisel] in cfg/export.cfg). Beide Stationen liegen exakt auf der
lokalen X-Achse (Y=0); die gesamte Geometrie (zwei Kreise + zwei
Tangenten bei Y=+-radius) ist symmetrisch dazu. Die Trennlinie durch die
Stationsmitten IST also die lokale X-Achse selbst, und teilt die
Bounding-Box in zwei GLEICH GROSSE Haelften mit je "radius" (Default
400mm) Breite - "links" = lokal Y in [0, +radius], "rechts" = lokal Y in
[-radius, 0] (Blickrichtung von Antriebs- zu Spannstation, +X; mathe-
matische Standardkonvention: mathematisch positive/CCW-Drehung von +X
fuehrt nach +Y, also links).
Block-Insertpunkt (world x,y) und -rotation (world "rotation", Grad,
CCW positiv) kommen direkt aus block (siehe csv:block-to-json). Die
ABSTAND-Attribut wird fuer die lokale Laenge (0..abstand+durchmesser)
benoetigt.
Rueckgabe: (bbox_links, bbox_rechts), je ein dict {"cx","cy","dx","dy"} -
die Welt-AABB der jeweils rotierten Haelfte (bei Rotationen, die kein
Vielfaches von 90 Grad sind, umschliesst die AABB die tatsaechliche
Haelfte etwas grosszuegiger, wie jede achsparallele Bounding-Box einer
gedrehten Flaeche - konsistent mit jeder anderen Bounding-Box in diesem
Modul).
"""
attribs = block.get("attribs", {})
abstand_mm = safe_float(attribs.get("ABSTAND", "0"), 0.0)
radius_mm = durchmesser_mm / 2.0
x0 = block.get("x", 0.0) or 0.0
y0 = block.get("y", 0.0) or 0.0
rot_rad = math.radians(block.get("rotation", 0.0) or 0.0)
cos_r, sin_r = math.cos(rot_rad), math.sin(rot_rad)
def world(local_x, local_y):
return (
x0 + local_x * cos_r - local_y * sin_r,
y0 + local_x * sin_r + local_y * cos_r,
)
# AN8-Kreis reicht lokal bis X=0 (Mitte radius, Radius radius); SP8-Kreis
# bis X=abstand+durchmesser (Mitte radius+abstand, Radius radius).
local_x_max = abstand_mm + durchmesser_mm
def half_bbox(y_lo, y_hi):
corners = [
world(0.0, y_lo), world(local_x_max, y_lo),
world(local_x_max, y_hi), world(0.0, y_hi),
]
xs = [c[0] for c in corners]
ys = [c[1] for c in corners]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
return {
"cx": (minx + maxx) / 2.0, "cy": (miny + maxy) / 2.0,
"dx": maxx - minx, "dy": maxy - miny,
}
bbox_links = half_bbox(0.0, radius_mm)
bbox_rechts = half_bbox(-radius_mm, 0.0)
return bbox_links, bbox_rechts
def _fmt_bounds(b):
"""(minx, maxx, miny, maxy) kompakt fuer das Debuglog."""
return f"[x {b[0]:.0f}..{b[1]:.0f} | y {b[2]:.0f}..{b[3]:.0f}]"
def _dbg_pair(dbg, id_a, id_b, overlap, detail=""):
if dbg:
dbg(f" {id_a} <-> {id_b}: "
f"{'NACHBARN' if overlap else 'kein Kontakt'}{detail}")
def _decode_trans_xy(text):
"""csv:trans-encode-String (K1-K4, 12 Zeichen, 3 Werte) -> (x, y) in mm
(Grundriss, Z wird nicht gebraucht), oder None bei leerem/ungueltigem
String.
Kodierung siehe Lisp/export.lsp (csv:trans-encode/csv:b64-encode-ints):
vorzeichenbehaftete 24-Bit-Fixed-Point-Werte (Faktor 10), je 4 Base64-
Zeichen, hoechstwertige 6 Bit zuerst. Es genuegen die ersten beiden Werte
(x, y) = die ersten 8 Zeichen.
"""
text = (text or "").strip()
if len(text) < 8:
return None
werte = []
for start in (0, 4):
wert = 0
for zeichen in text[start:start + 4]:
idx = _B64_INDEX.get(zeichen)
if idx is None:
return None
wert = (wert << 6) | idx
if wert >= 8388608: # 2^23 -> negativer 24-Bit-Wert (Zweierkompl.)
wert -= 16777216
werte.append(wert / _TRANS_FAKTOR)
return (werte[0], werte[1])
def _omniflo_kpoints(item):
"""Welt-(x,y)-Punkte der vorhandenen K1-K4-Koordinatensysteme eines
Omniflo-Elements, dekodiert aus den csv:trans-encode-Strings item["k1"]..
["k4"] (siehe export_csv.bbox_columns). Leere/fehlende Strings werden
uebersprungen - die Liste kann also 0 bis 4 Punkte enthalten. Fuer Geraden
stehen darin die beim Export synthetisierten Anfang/Ende-Punkte (K1/K2)."""
pts = []
for key in ("k1", "k2", "k3", "k4"):
p = _decode_trans_xy(item.get(key, ""))
if p is not None:
pts.append(p)
return pts
def _ks_within(kpts_a, kpts_b, tol_mm):
"""True, wenn irgendein K-Punkt aus a einem K-Punkt aus b naeher als tol_mm
kommt (euklidischer Abstand in x/y). Beide Listen muessen nicht leer sein -
das prueft der Aufrufer."""
for ax, ay in kpts_a:
for bx, by in kpts_b:
if math.hypot(ax - bx, ay - by) <= tol_mm:
return True
return False
def _test_group_pairs(group, result, dbg=None):
"""Alle Paare EINER Gruppe gegeneinander testen (O(n^2) - fuer Kreisel/
Eckrad-Stueckzahlen pro Zeichnung unkritisch).
idx_a == idx_b wird uebersprungen: seit dem Kreisel-Links/Rechts-Split
(siehe kreisel_half_bboxes) kann dieselbe Original-idx zweimal in der
Gruppe auftauchen (die linke und die rechte Haelfte DESSELBEN Kreisels) -
die beiden beruehren sich zwangslaeufig an der Trennlinie und waeren
sonst faelschlich gegenseitige "Nachbarn".
"""
n = len(group)
for a in range(n):
idx_a, bounds_a, id_a = group[a]
for b in range(a + 1, n):
idx_b, bounds_b, id_b = group[b]
if idx_a == idx_b:
continue
overlap = _overlaps(bounds_a, bounds_b)
_dbg_pair(dbg, id_a, id_b, overlap)
if overlap:
_add_neighbor(result, idx_a, idx_b, id_a, id_b)
def _test_group_against_group(group_a, group_b, result, dbg=None):
"""Jedes Element aus group_a gegen jedes Element aus group_b testen.
group_b wird NICHT gegen sich selbst getestet (Aufgabe des Aufrufers)."""
for idx_a, bounds_a, id_a in group_a:
for idx_b, bounds_b, id_b in group_b:
overlap = _overlaps(bounds_a, bounds_b)
_dbg_pair(dbg, id_a, id_b, overlap)
if overlap:
_add_neighbor(result, idx_a, idx_b, id_a, id_b)
def _grid_cell(bbox, cell_size_mm):
"""Rasterzelle (Quadrant) einer Bounding-Box nach ihrem Mittelpunkt."""
return (int(bbox["cx"] // cell_size_mm), int(bbox["cy"] // cell_size_mm))
def _candidate_pairs_strtree(group):
"""Broad-Phase per shapely-STRtree: liefert alle Gruppen-Indexpaare (a<b),
deren (bereits um die halbe Toleranz erweiterte) Bounding-Boxen sich in
x/y ueberschneiden. Gruppen-Eintrag = (idx, bounds, teileid, bbox, kpts)
mit bounds = (minx, maxx, miny, maxy)."""
boxes = [_shapely_box(bounds[0], bounds[2], bounds[1], bounds[3])
for (_idx, bounds, _tid, _bbox, _kpts) in group]
tree = STRtree(boxes)
pairs = set()
for a, geom in enumerate(boxes):
for b in tree.query(geom): # Envelope-Ueberschneidung (shapely 2.x: Indizes)
b = int(b)
if a < b:
pairs.add((a, b))
elif b < a:
pairs.add((b, a))
return pairs
def _candidate_pairs_grid(group, cell_size_mm, dbg=None):
"""Fallback-Broad-Phase ohne shapely: Omniflo-Elemente nach x/y-Koordinate
in Rasterzellen einteilen und nur Paare aus derselben oder einer der 8
angrenzenden Zellen (3x3-Nachbarschaft) als Kandidaten liefern - ohne
Ueberschneidungen an Zellgrenzen zu verpassen. Rueckgabe: Menge von
Gruppen-Indexpaaren (a<b)."""
buckets = {}
for gi, entry in enumerate(group):
bbox = entry[3]
buckets.setdefault(_grid_cell(bbox, cell_size_mm), []).append(gi)
if dbg:
dbg(f" {len(buckets)} belegte Rasterzelle(n): "
+ ", ".join(f"{cell}={len(v)}" for cell, v in sorted(buckets.items())))
pairs = set()
for (cx, cy), cell_items in buckets.items():
candidates = []
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
candidates.extend(buckets.get((cx + dx, cy + dy), []))
for a in cell_items:
for b in candidates:
if a < b:
pairs.add((a, b))
elif b < a:
pairs.add((b, a))
return pairs
def _test_omniflo(group, cell_size_mm, ks_tol_mm, result, dbg=None):
"""Omniflo-Nachbarschaft in zwei Phasen (siehe Modul-Docstring):
1. Broad-Phase (STRtree bzw. Raster-Fallback): alle Paare mit sich
ueberschneidenden Bounding-Boxen ("moegliche Nachbarn").
2. Narrow-Phase (KOS-Verfeinerung): ein Paar gilt nur dann als benachbart,
wenn beide Elemente K-Punkte fuehren UND ein K-Punkt des einen naeher
als ks_tol_mm an einem K-Punkt des anderen liegt. Hat mindestens eines
der beiden keine K-Punkte (Altbestand) oder ist ks_tol_mm <= 0, bleibt
es bei der reinen BBox-Ueberschneidung.
"""
if not group:
return
if _HAVE_STRTREE:
pairs = _candidate_pairs_strtree(group)
broad = "shapely-STRtree"
else:
pairs = _candidate_pairs_grid(group, cell_size_mm, dbg)
broad = "Raster-Fallback (shapely nicht verfuegbar)"
if dbg:
dbg(f" Broad-Phase: {broad}, {len(group)} Omniflo-Element(e), "
f"{len(pairs)} BBox-Kandidatenpaar(e); "
f"KOS-Verfeinerung Toleranz={ks_tol_mm}mm")
for a, b in sorted(pairs):
idx_a, bounds_a, id_a, _bbox_a, kpts_a = group[a]
idx_b, bounds_b, id_b, _bbox_b, kpts_b = group[b]
overlap = _overlaps(bounds_a, bounds_b)
detail = ""
if overlap and ks_tol_mm > 0 and kpts_a and kpts_b:
if _ks_within(kpts_a, kpts_b, ks_tol_mm):
detail = f" (KOS-Kontakt <= {ks_tol_mm:.0f}mm)"
else:
overlap = False
detail = f" (BBox ueberschneidet, aber kein KOS-Paar <= {ks_tol_mm:.0f}mm)"
_dbg_pair(dbg, id_a, id_b, overlap, detail)
if overlap:
_add_neighbor(result, idx_a, idx_b, id_a, id_b)
def compute_neighbor_ids(items, tolerance_mm, omniflo_cell_size_mm=3000.0,
kreisel_durchmesser_mm=800.0,
omniflo_ks_tolerance_mm=10.0, element_box_mm=None,
dbg=None):
"""Ermittelt je Item die IDs (item["teileid"]) benachbarter Elemente.
items = Liste von dict mit "teileart", "teileid" und optional "_bbox"
({"cx","cy","dx","dy",...}, siehe export_csv.py bbox_columns). Items
ohne _bbox (z.B. die synthetische Omniflo-Sum-Zeile) bleiben ohne
Nachbarn. Omniflo-Items koennen zusaetzlich "k1".."k4" (csv:trans-encode-
Strings) tragen - daraus wird die KOS-Verfeinerung gespeist (siehe
_test_omniflo / omniflo_ks_tolerance_mm).
omniflo_ks_tolerance_mm = Toleranz (mm) fuer die KOS-Verfeinerung der
Omniflo-Nachbarschaft (K1-K4). 0 = aus (nur BBox-Ueberschneidung).
"ILS 2.0 Kreisel"-Items werden dabei NICHT als eine BBox, sondern als
ZWEI gegeneinander getestet: eine linke und eine rechte Haelfte (siehe
kreisel_half_bboxes) - die Kreisel-Gruppe fuer die Kollisionspruefung
besteht also aus <teileid>-L/<teileid>-R statt <teileid> je Kreisel
("ILS 2.0 Eckrad" bleibt unveraendert eine einzelne BBox). Ein
angrenzendes Element (z.B. eine Gefaellestrecke) bekommt dadurch in
seiner eigenen Nachbarliste "<teileid>-L" oder "<teileid>-R" statt nur
"<teileid>" - je nachdem, welche Haelfte tatsaechlich ueberschneidet.
dbg = optionale Callable(str) fuer ein Debugprotokoll (Klassifikation,
Gruppen, jeder Ueberschneidungstest, Ergebnis). None = kein Protokoll.
Rueckgabe: Liste von kommaseparierten Nachbar-ID-Strings, positionsgleich
zu items. Innerhalb eines Items dedupliziert (identische IDs entfernt -
das passiert z.B., wenn ein Kreisel dieselbe Strecke ueber BEIDE
Haelften beruehrt), sonst nicht global ueber alle Items hinweg (TeileId
ist nicht zwingend eindeutig).
"""
half_tol = tolerance_mm / 2.0
if element_box_mm is None:
element_box_mm = load_element_box_mm()
result = [[] for _ in items]
kreisel_group = []
strecken_group = []
omniflo_group = []
skipped = 0
if dbg:
dbg("=== Nachbarschafts-/Kollisionserkennung ===")
dbg(f"Toleranz={tolerance_mm}mm (Bounding-Box je Seite +{half_tol}mm erweitert), "
f"Omniflo-Zellgroesse={omniflo_cell_size_mm}mm (nur Raster-Fallback), "
f"Omniflo-KOS-Toleranz={omniflo_ks_tolerance_mm}mm, "
f"Kreisel-Durchmesser={kreisel_durchmesser_mm}mm (Radius je Haelfte)")
dbg(f"Elemente gesamt: {len(items)}")
dbg("")
dbg("Klassifikation je Element:")
for idx, item in enumerate(items):
bbox = item.get("_bbox")
if not bbox:
skipped += 1
if dbg:
dbg(f" idx={idx} teileid={item.get('teileid', '')!r} "
f"teileart={item.get('teileart', '')!r} -> UEBERSPRUNGEN "
f"(keine Bounding-Box)")
continue
teileart = item.get("teileart", "")
teileid = item.get("teileid", "")
if teileart == KREISEL_SPLIT_TEILEART:
gruppe = "Kreisel (links/rechts gesplittet)"
bbox_links, bbox_rechts = kreisel_half_bboxes(item, kreisel_durchmesser_mm)
bounds_links = _bounds(bbox_links, half_tol)
bounds_rechts = _bounds(bbox_rechts, half_tol)
kreisel_group.append((idx, bounds_links, f"{teileid}-L"))
kreisel_group.append((idx, bounds_rechts, f"{teileid}-R"))
if dbg:
dbg(f" idx={idx} teileid={teileid!r} teileart={teileart!r} "
f"-> {gruppe}; links bbox(cx={bbox_links['cx']:.0f},cy={bbox_links['cy']:.0f},"
f"dx={bbox_links['dx']:.0f},dy={bbox_links['dy']:.0f}) bounds={_fmt_bounds(bounds_links)}; "
f"rechts bbox(cx={bbox_rechts['cx']:.0f},cy={bbox_rechts['cy']:.0f},"
f"dx={bbox_rechts['dx']:.0f},dy={bbox_rechts['dy']:.0f}) bounds={_fmt_bounds(bounds_rechts)}")
continue
bounds = _bounds(bbox, half_tol)
asel_bounds = None
if teileart in KREISEL_TEILEARTEN:
gruppe = "Kreisel/Eckrad"
kreisel_group.append((idx, bounds, teileid))
elif teileart in STRECKEN_TEILEARTEN:
# GF/VF (GF_VF_TEILEARTEN): NICHT die grosse Wrapper-Box gegen die
# Kreisel testen, sondern je eine kleine feste Box (element_box_mm) an
# der AS-Position (K1) und der ES-Position (K2) - nur dort beruehrt
# eine Strecke einen Kreisel. Wie beim Kreisel-Links/Rechts-Split
# koennen dadurch bis zu ZWEI Gruppeneintraege mit derselben idx/
# teileid entstehen; die spaetere dict.fromkeys()-Deduplizierung
# faengt Doppelnennungen ab. Gilt fuer ALLE Strecken-TeileArten
# (GF/VF UND "ILS 2.0 Strecke - Modul"), sofern sie K1/K2 fuehren -
# fehlt K1/K2 (Altbestand oder Modul ohne AS/ES), liefert
# _asel_collision_boxes None und es bleibt bei der Wrapper-Box.
asel_bounds = _asel_collision_boxes(item, half_tol, element_box_mm)
if asel_bounds is not None:
gruppe = f"Strecke/Foerderer (AS/ES-Box, {len(asel_bounds)}x)"
for b in asel_bounds:
strecken_group.append((idx, b, teileid))
else:
gruppe = "Strecke/Foerderer"
strecken_group.append((idx, bounds, teileid))
elif teileart in OMNIFLO_TEILEARTEN:
gruppe = "Omniflo"
omniflo_group.append((idx, bounds, teileid, bbox, _omniflo_kpoints(item)))
else:
gruppe = "(keine Gruppe - wird nicht geprueft)"
if dbg:
if asel_bounds is not None:
bounds_txt = "; ".join(_fmt_bounds(b) for b in asel_bounds)
dbg(f" idx={idx} teileid={teileid!r} teileart={teileart!r} "
f"-> {gruppe}; AS/ES-Box(en) bounds={bounds_txt}")
else:
dbg(f" idx={idx} teileid={teileid!r} teileart={teileart!r} "
f"-> {gruppe}; bbox(cx={bbox.get('cx', 0):.0f},cy={bbox.get('cy', 0):.0f},"
f"dx={bbox.get('dx', 0):.0f},dy={bbox.get('dy', 0):.0f}) "
f"bounds={_fmt_bounds(bounds)}")
if dbg:
dbg("")
dbg(f"Gruppengroessen: Kreisel/Eckrad={len(kreisel_group)}, "
f"Strecke/Foerderer={len(strecken_group)}, Omniflo={len(omniflo_group)}, "
f"ohne Bounding-Box={skipped}")
dbg("")
dbg("[1] Kreisel/Eckrad gegen Kreisel/Eckrad:")
# 1. Kreisel/Eckrad gegen Kreisel/Eckrad
_test_group_pairs(kreisel_group, result, dbg)
if dbg:
dbg("[2] Kreisel/Eckrad gegen Strecke/Foerderer/Gefaellestrecke:")
# 2. Kreisel/Eckrad gegen Gefaellestrecke/Foerderer/Strecke-Modul -
# diese drei Kategorien nicht gegeneinander (siehe Modul-Docstring)
_test_group_against_group(kreisel_group, strecken_group, result, dbg)
if dbg:
dbg("[3] Omniflo gegen Omniflo (STRtree-Broad-Phase + KOS-Verfeinerung):")
# 3. Omniflo gegen Omniflo: BBox-Broad-Phase (STRtree bzw. Raster-Fallback),
# dann KOS-Verfeinerung ueber K1-K4 (siehe _test_omniflo)
_test_omniflo(omniflo_group, omniflo_cell_size_mm,
omniflo_ks_tolerance_mm, result, dbg)
# dict.fromkeys() dedupliziert unter Erhalt der Reihenfolge - relevant
# seit dem Kreisel-Split: beruehrt eine Strecke BEIDE Haelften desselben
# Kreisels nicht (normalerweise ausgeschlossen), waere sie sonst nicht
# betroffen; umgekehrt kann derselbe Strecken-Nachbar im Kreisel-Eintrag
# theoretisch doppelt auftauchen, wenn (Rand-/Toleranzfall) beide
# Haelften ihn beruehren.
neighbor_id_lists = [", ".join(dict.fromkeys(neighbor_ids)) for neighbor_ids in result]
if dbg:
dbg("")
dbg("Ergebnis je Element:")
for idx, item in enumerate(items):
dbg(f" idx={idx} teileid={item.get('teileid', '')!r}: "
f"{len(result[idx])} Nachbar(n) [{neighbor_id_lists[idx]}]")
dbg("")
dbg(f"Summe gerichteter Nachbarschaftsbeziehungen: "
f"{sum(len(r) for r in result)}")
return neighbor_id_lists
def compute_neighbor_errors(items, neighbor_ids):
"""Prueft je Item die Mindestanzahl an Partnern (siehe MIN_PARTNER).
items = dieselbe Liste wie bei compute_neighbor_ids (braucht "teileart").
neighbor_ids = Rueckgabe von compute_neighbor_ids (positionsgleich zu items).
Rueckgabe: Liste von Fehlertexten, positionsgleich zu items:
"unverbunden" - keine Partner, obwohl mindestens einer noetig waere
"nur ein Partner" - genau ein Partner, obwohl mindestens zwei noetig waeren
"" - Mindestanzahl erreicht, oder TeileArt wird nicht geprueft
"""
errors = []
for item, nachbarn in zip(items, neighbor_ids):
min_partner = MIN_PARTNER.get(item.get("teileart", ""))
if min_partner is None:
errors.append("")
continue
anzahl = len([p for p in nachbarn.split(",") if p.strip()])
if anzahl == 0:
errors.append("unverbunden")
elif anzahl == 1 and min_partner >= 2:
errors.append("nur ein Partner")
else:
errors.append("")
return errors
def compute_sensor_zuordnung(items, tolerance_mm, kreisel_durchmesser_mm=800.0, dbg=None):
"""Ordnet jeden Separator/Scanner (SENSOR_TEILEARTEN) per Bounding-Box-
Ueberschneidung seiner Gefaellestrecke, seinem Foerderer oder einer
Kreiselhaelfte (links/rechts, siehe kreisel_half_bboxes) zu.
Eigener, unabhaengiger Durchlauf NACH compute_neighbor_ids (nicht in
dessen Gruppenaufbau integriert, da Separatoren/Scanner dort bewusst
keine eigene Gruppe sind - Nachbarschaft und Zuordnung sind zwei
unterschiedliche Fragen: Nachbarschaft = "beruehrt sich", Zuordnung =
"gehoert zu genau einem Trägerelement"). Prioritaet bei mehreren
Treffern: Gefaellestrecke vor Foerderer vor Kreiselhaelfte (ein
Separator/Scanner haengt fachlich an genau einem dieser drei).
items = dieselbe Liste wie bei compute_neighbor_ids (braucht "teileart",
"teileid" und "_bbox").
Rueckgabe: Liste von Zuordnungs-Strings, positionsgleich zu items -
"" fuer Items, die keine SENSOR_TEILEART sind oder keine BBox haben.
Bei Treffer: die TeileId der Gefaellestrecke/des Foerderers, oder
"<KreiselTeileId>-L"/"-R" bei einem Kreisel-Treffer. Kein Treffer bei
vorhandener BBox: "nicht zugeordnet".
"""
half_tol = tolerance_mm / 2.0
gf_entries = []
vf_entries = []
kreisel_entries = []
for item in items:
bbox = item.get("_bbox")
if not bbox:
continue
teileart = item.get("teileart", "")
teileid = item.get("teileid", "")
if teileart == "ILS 2.0 Gefaellestrecke":
gf_entries.append((teileid, _bounds(bbox, half_tol)))
elif teileart in ("ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"):
vf_entries.append((teileid, _bounds(bbox, half_tol)))
elif teileart == KREISEL_SPLIT_TEILEART:
bbox_links, bbox_rechts = kreisel_half_bboxes(item, kreisel_durchmesser_mm)
kreisel_entries.append((f"{teileid}-L", _bounds(bbox_links, half_tol)))
kreisel_entries.append((f"{teileid}-R", _bounds(bbox_rechts, half_tol)))
if dbg:
dbg("")
dbg("=== Separator/Scanner-Zuordnung (GF > Foerderer > Kreiselhaelfte) ===")
dbg(f"Kandidaten: {len(gf_entries)} Gefaellestrecke(n), "
f"{len(vf_entries)} Foerderer/Streckenmodul(e), "
f"{len(kreisel_entries)} Kreiselhaelfte(n)")
def _find(bounds, candidates):
for teileid, other_bounds in candidates:
if _overlaps(bounds, other_bounds):
return teileid
return None
result = []
for item in items:
teileart = item.get("teileart", "")
if teileart not in SENSOR_TEILEARTEN:
result.append("")
continue
bbox = item.get("_bbox")
if not bbox:
result.append("")
continue
bounds = _bounds(bbox, half_tol)
treffer = (_find(bounds, gf_entries)
or _find(bounds, vf_entries)
or _find(bounds, kreisel_entries)
or "nicht zugeordnet")
if dbg:
dbg(f" {item.get('teileart', '')} {item.get('teileid', '')!r} "
f"bounds={_fmt_bounds(bounds)} -> {treffer!r}")
result.append(treffer)
return result
def compute_scanner_nearest_separator(items, dbg=None):
"""Sucht fuer jeden Scanner ("ILS 2.0 Scanner") den raeumlich naechst-
gelegenen Separator ("ILS 2.0 Separator") - euklidischer Abstand der
BBox-Mittelpunkte (cx/cy, Grundriss, Z ignoriert wie ueberall in diesem
Modul).
Eigener, unabhaengiger Durchlauf, der NACH compute_sensor_zuordnung
aufgerufen wird (die Zuordnung der Separatoren zu ihrer Gefaellestrecke/
ihrem Foerderer/ihrer Kreiselhaelfte steht damit bereits fest - diese
Funktion aendert daran nichts, sie sucht unabhaengig davon zusaetzlich
den naechsten Separator zu jedem Scanner). Es gibt KEINE Abstands-
obergrenze - "immer der naechstgelegene" bedeutet auch bei grosser
Entfernung einen Treffer, solange ueberhaupt ein Separator existiert.
Rueckgabe: Liste von TeileId-Strings (des jeweils naechsten Separators),
positionsgleich zu items - "" fuer Nicht-Scanner-Items, fuer Scanner ohne
BBox oder wenn ueberhaupt kein Separator mit BBox in der Zeichnung ist.
"""
separators = [
(item.get("teileid", ""), item["_bbox"])
for item in items
if item.get("teileart", "") == "ILS 2.0 Separator" and item.get("_bbox")
]
if dbg:
dbg("")
dbg("=== Scanner -> naechstgelegener Separator ===")
dbg(f"Separator-Kandidaten mit BBox: {len(separators)}")
result = []
for item in items:
if item.get("teileart", "") != "ILS 2.0 Scanner":
result.append("")
continue
bbox = item.get("_bbox")
if not bbox or not separators:
result.append("")
if dbg:
dbg(f" Scanner {item.get('teileid', '')!r}: "
f"{'keine BBox' if not bbox else 'keine Separatoren vorhanden'} -> kein Treffer")
continue
cx, cy = bbox["cx"], bbox["cy"]
best_id, best_dist = "", None
for sep_id, sep_bbox in separators:
dist = math.hypot(sep_bbox["cx"] - cx, sep_bbox["cy"] - cy)
if best_dist is None or dist < best_dist:
best_dist = dist
best_id = sep_id
if dbg:
dbg(f" Scanner {item.get('teileid', '')!r} -> naechster Separator "
f"{best_id!r} (Abstand {best_dist:.0f}mm)")
result.append(best_id)
return result
# ---------------------------------------------------------------------------
# Kreisel-Beruehrpunkte -> ILS-Weichen
# ---------------------------------------------------------------------------
def _clamp(v, lo, hi):
return lo if v < lo else (hi if v > hi else v)
def _kreisel_capsule(item, durchmesser_mm):
"""Achsen-Segment (Kapsel) eines "ILS 2.0 Kreisel"-Blocks in Welt-
koordinaten.
Geometrie siehe kreisel_half_bboxes / KreiselInsert.lsp::draw-module: die
AN8-(Antriebs-) Stationsmitte liegt lokal bei (radius, 0), die SP8-(Spann-)
Stationsmitte bei (radius+abstand, 0), radius = durchmesser_mm/2. Beide auf
der lokalen X-Achse. Die gesamte Kreisel-Flaeche ist das um radius
"aufgeblasene" Segment zwischen diesen beiden Mitten (Stadium/Kapsel: zwei
Endkreise mit Radius radius + zwei Tangenten bei Y=+-radius).
Rueckgabe: dict {"a": (ax,ay), "b": (bx,by), "r": radius, "rot": rot_rad,
"z": z-Mitte} - a = AN8-Mitte, b = SP8-Mitte (Welt).
"""
attribs = item.get("attribs", {})
abstand = safe_float(attribs.get("ABSTAND", "0"), 0.0)
r = durchmesser_mm / 2.0
x0 = item.get("x", 0.0) or 0.0
y0 = item.get("y", 0.0) or 0.0
rot = math.radians(item.get("rotation", 0.0) or 0.0)
cos_r, sin_r = math.cos(rot), math.sin(rot)
def world(lx, ly):
return (x0 + lx * cos_r - ly * sin_r, y0 + lx * sin_r + ly * cos_r)
bbox = item.get("_bbox") or {}
z = bbox.get("cz", item.get("z", 0.0) or 0.0)
return {
"a": world(r, 0.0),
"b": world(r + abstand, 0.0),
"r": r,
"rot": rot,
"z": z,
}
def _closest_pts_segments(p1, q1, p2, q2):
"""Naechstgelegene Punkte zweier 2D-Strecken [p1,q1] und [p2,q2] (Algorithmus
aus Ericson, Real-Time Collision Detection). Rueckgabe (c1, c2, s, t) mit
c1 auf Strecke 1, c2 auf Strecke 2 und Parametern s,t in [0,1] (0=Anfang,
1=Ende) - s/t am Rand bedeutet: der naechste Punkt liegt auf dem Endkreis
(Cap), dazwischen auf der Tangentenflanke."""
eps = 1e-9
d1 = (q1[0] - p1[0], q1[1] - p1[1])
d2 = (q2[0] - p2[0], q2[1] - p2[1])
r = (p1[0] - p2[0], p1[1] - p2[1])
a = d1[0] * d1[0] + d1[1] * d1[1]
e = d2[0] * d2[0] + d2[1] * d2[1]
f = d2[0] * r[0] + d2[1] * r[1]
if a <= eps and e <= eps:
s = t = 0.0
elif a <= eps:
s = 0.0
t = _clamp(f / e, 0.0, 1.0)
else:
c = d1[0] * r[0] + d1[1] * r[1]
if e <= eps:
t = 0.0
s = _clamp(-c / a, 0.0, 1.0)
else:
b = d1[0] * d2[0] + d1[1] * d2[1]
denom = a * e - b * b
s = _clamp((b * f - c * e) / denom, 0.0, 1.0) if denom > eps else 0.0
t = (b * s + f) / e
if t < 0.0:
t = 0.0
s = _clamp(-c / a, 0.0, 1.0)
elif t > 1.0:
t = 1.0
s = _clamp((b - c) / a, 0.0, 1.0)
c1 = (p1[0] + d1[0] * s, p1[1] + d1[1] * s)
c2 = (p2[0] + d2[0] * t, p2[1] + d2[1] * t)
return c1, c2, s, t
def _oriented_box_aabb(rot_rad, laenge, breite, hoehe):
"""Achsparallele Welt-Bounding-Box (dx,dy,dz) einer Box, deren Laengsseite
(laenge) senkrecht zur Achsenrichtung rot_rad liegt und deren Breite
(breite) entlang der Achse - konsistent mit allen anderen BBox-Spalten
(Ausdehnung der gedrehten Flaeche in Welt-X/Y).
Achse = (cos rot, sin rot); Laengsseite senkrecht dazu = (-sin rot, cos rot).
"""
c = abs(math.cos(rot_rad))
s = abs(math.sin(rot_rad))
dx = laenge * s + breite * c
dy = laenge * c + breite * s
return (dx, dy, hoehe)
def compute_kreisel_touch_switches(items, tolerance_mm, durchmesser_mm=800.0,
element_box_mm=None, dbg=None):
"""Erzeugt fuer jedes sich beruehrende Paar echter Kreisel ("ILS 2.0
Kreisel") eine ILS-Weiche am Beruehrpunkt.
Kreisel-Modell: eine Kapsel (Stadium) - das Achsensegment AN8-Mitte ->
SP8-Mitte, aufgeblasen um r = durchmesser_mm/2 (siehe _kreisel_capsule).
Zwei Kreisel beruehren sich, wenn der minimale Abstand ihrer Achsen-
segmente <= 2r + tolerance_mm ist (2r = durchmesser_mm). Eigenstaendige,
praezise Geometriepruefung - unabhaengig vom groben Links/Rechts-AABB-
Nachbarschaftstest (compute_neighbor_ids), der nur ueberschneidende
Bounding-Boxen, nicht den exakten Beruehrpunkt liefert.
Beruehrpunkt (= Weichen-Position) = Mittelpunkt der beiden naechstgelegenen
Segmentpunkte. Die Weichen-Box (element_box_mm) wird mit ihrer Laengsseite
senkrecht zur Achse desjenigen Kreisels ausgerichtet, dessen beruehrendes
Merkmal ein Endkreis ist (naechster Segmentpunkt am Segmentende). Beruehren
sich zwei Endkreise oder zwei Tangentenflanken, wird der erstgenannte
Kreisel des Paares (kleinerer items-Index) genommen. Ausgegeben wird die
achsparallele Welt-Bounding-Box der gedrehten Box (siehe _oriented_box_aabb).
Rueckgabe: Liste von dicts (in items-Reihenfolge der Paare), je erzeugte
Weiche:
{"key": "WEICHE#<teileid_a>#<teileid_b>" - stabiler Schluessel, ueber den
compute_kreisel_umlauf die Weiche in BEIDE beruehrenden Kreisel-
Umlaeufe einhaengt (die Weiche gehoert als Verzweigungspunkt zu
beiden, anders als ein Separator oder ein AS/ES-Ende) UND
build_kreisel_weiche_items die zugehoerige TeileId nach dem Umlauf-
Aufbau wiederfindet,
"x","y","z": Beruehrpunkt (mm),
"dx","dy","dz": Welt-AABB der ausgerichteten Box (mm),
"nachbarn": [teileid_a, teileid_b],
"achse_von": teileid des orientierungsgebenden Kreisels,
"beruehrung": "Kreis/Tangente" | "Kreis/Kreis" | "Tangente/Tangente"}
"""
eps = 1e-6
if element_box_mm is None:
element_box_mm = load_element_box_mm()
box_l, box_b, box_h = element_box_mm
kreisel = []
for item in items:
if item.get("teileart", "") != KREISEL_SPLIT_TEILEART:
continue
kreisel.append((item.get("teileid", ""), _kreisel_capsule(item, durchmesser_mm)))
if dbg:
dbg("")
dbg("=== Kreisel-Beruehrpunkte -> ILS-Weichen ===")
dbg(f"Echte Kreisel ('{KREISEL_SPLIT_TEILEART}'): {len(kreisel)}; "
f"Beruehrschwelle Abstand <= {durchmesser_mm + tolerance_mm:.0f}mm "
f"(2r={durchmesser_mm:.0f} + Toleranz {tolerance_mm:.0f})")
switches = []
for i in range(len(kreisel)):
id_a, cap_a = kreisel[i]
for j in range(i + 1, len(kreisel)):
id_b, cap_b = kreisel[j]
c1, c2, s, t = _closest_pts_segments(cap_a["a"], cap_a["b"],
cap_b["a"], cap_b["b"])
dist = math.hypot(c1[0] - c2[0], c1[1] - c2[1])
schwelle = cap_a["r"] + cap_b["r"] + tolerance_mm
if dist > schwelle:
if dbg:
dbg(f" {id_a} <-> {id_b}: kein Kontakt "
f"(Achsabstand {dist:.0f}mm > {schwelle:.0f}mm)")
continue
# Beruehrpunkt = Mitte der beiden naechstgelegenen Segmentpunkte
tx = (c1[0] + c2[0]) / 2.0
ty = (c1[1] + c2[1]) / 2.0
tz = (cap_a["z"] + cap_b["z"]) / 2.0
# Endkreis (Cap) beruehrt, wenn der naechste Segmentpunkt am Ende
# liegt (s bzw. t == 0 oder 1); dazwischen Tangentenflanke.
cap1 = s <= eps or s >= 1.0 - eps
cap2 = t <= eps or t >= 1.0 - eps
if cap1 and not cap2:
achse_id, achse_rot, beruehrung = id_a, cap_a["rot"], "Kreis/Tangente"
elif cap2 and not cap1:
achse_id, achse_rot, beruehrung = id_b, cap_b["rot"], "Kreis/Tangente"
else:
achse_id, achse_rot = id_a, cap_a["rot"]
beruehrung = "Kreis/Kreis" if cap1 else "Tangente/Tangente"
dx, dy, dz = _oriented_box_aabb(achse_rot, box_l, box_b, box_h)
switches.append({
"key": f"WEICHE#{id_a}#{id_b}",
"x": tx, "y": ty, "z": tz,
"dx": dx, "dy": dy, "dz": dz,
"nachbarn": [id_a, id_b],
"achse_von": achse_id,
"beruehrung": beruehrung,
})
if dbg:
dbg(f" {id_a} <-> {id_b}: BERUEHRUNG ({beruehrung}, Achsabstand "
f"{dist:.0f}mm) -> Weiche bei ({tx:.0f}, {ty:.0f}, {tz:.0f}), "
f"Box-AABB ({dx:.0f}, {dy:.0f}, {dz:.0f}), Achse von Kreisel {achse_id}")
if dbg:
dbg(f"Erzeugte ILS-Weichen: {len(switches)}")
return switches
# ---------------------------------------------------------------------------
# Kreisel <-> Strecken-Enden: verfeinerte Kollisionsbox fuer compute_neighbor_
# ids (die "Nachbarn"-Spalte einer GF/VF-Strecke soll nur an ihrem echten AS-/
# ES-Ende auf einen Kreisel reagieren, nicht auf die gesamte, oft lange
# Wrapper-Bounding-Box).
# ---------------------------------------------------------------------------
def _asel_kos_xy(item):
"""(k1_xy, k2_xy) einer GF/VF-Strecke = Welt-(x,y) von AS (K1/KS_EIN) und
ES (K2/KS_AUS), dekodiert aus item["k1"]/["k2"] (csv:trans-encode, siehe
csv:vfgf-k-kos-strings in export.lsp). Fehlender/leerer String -> None.
Rueckgabe (None, None), wenn die Strecke keine K1/K2 fuehrt (Altbestand
ohne den neuen Export)."""
return _decode_trans_xy(item.get("k1", "")), _decode_trans_xy(item.get("k2", ""))
def _asel_axis_rot(k1_xy, k2_xy):
"""Rotationswinkel (rad) der Foerderachse AS->ES = Richtung K1->K2. Fuer die
Ausrichtung der festen AS/ES-Kollisionsbox (element_box_mm, Laengsseite quer
zur Achse - konsistent mit _oriented_box_aabb / den Schleuselementen).
Fehlt ein Punkt oder fallen beide zusammen, 0.0 (achsparallele Box)."""
if k1_xy is None or k2_xy is None:
return 0.0
dx, dy = k2_xy[0] - k1_xy[0], k2_xy[1] - k1_xy[1]
if math.hypot(dx, dy) < 1e-9:
return 0.0
return math.atan2(dy, dx)
def _asel_collision_boxes(item, half_tol, box_dims):
"""Kollisions-Bounding-Boxen einer GF/VF-Strecke fuer den Test gegen die
Kreisel: statt der GROSSEN Wrapper-Box je EINE kleine, fest dimensionierte
Box (box_dims, siehe load_element_box_mm) an der AS-Position (K1) und der
ES-Position (K2) - das sind die einzigen Stellen, an denen eine Strecke
einen Kreisel beruehrt. Die Box liegt mit ihrer Laengsseite quer zur
Foerderachse AS->ES (_asel_axis_rot / _oriented_box_aabb).
box_dims = (laenge, breite, hoehe) in mm.
Rueckgabe: Liste von (minx, maxx, miny, maxy)-bounds (0-2 Eintraege) oder
None, wenn die Strecke KEINE K1/K2 fuehrt (dann faellt der Aufrufer auf die
Wrapper-Box zurueck - Altbestand ohne AS/ES-Export)."""
k1_xy, k2_xy = _asel_kos_xy(item)
if k1_xy is None and k2_xy is None:
return None
rot = _asel_axis_rot(k1_xy, k2_xy)
dx, dy, _dz = _oriented_box_aabb(rot, box_dims[0], box_dims[1], box_dims[2])
boxes = []
for pt in (k1_xy, k2_xy):
if pt is None:
continue
boxes.append(_bounds({"cx": pt[0], "cy": pt[1], "dx": dx, "dy": dy}, half_tol))
return boxes
# ---------------------------------------------------------------------------
# Sepliste (LISP-XDATA, siehe ssg-sepliste-xdata-schreiben) -> Nachbarschaft
# ---------------------------------------------------------------------------
#
# "sepliste" (item["sepliste"], von csv:block-to-json aus SSG_VF_SEP/
# SSG_GF_SEP-XDATA gelesen) ist die beim Bau in Baureihenfolge gesammelte
# Liste der Separator-/AS-/ES-Sub-Bloecke EINER VF_n/GF_n-Kette:
# [{"typ": "AS"|"SEP"|"ES", "lfdnr": int, "x": float, "y": float}, ...].
# Ersetzt das bisherige Raten der Ketten-internen Reihenfolge (die Linie
# selbst wird nicht exportiert) durch die tatsaechliche Bau-Reihenfolge.
def compute_sep_kette(items, dbg=None):
"""Baut je VF_n/GF_n-Item MIT "sepliste" die ketteninterne Vorgaenger-/
Nachfolger-Beziehung AS -> Sep1 -> ... -> SepN -> ES.
Jeder Sepliste-Eintrag bekommt eine (innerhalb dieser Kette eindeutige)
Schluessel-ID "<strecke_teileid>#<lfdnr>" - das ist die ID, mit der
map_sepliste_kette_items die spaeteren globalen TeileIds verknuepft
(siehe export_csv.py). Vorgaenger/Nachfolger sind schlicht der vorherige/
naechste Eintrag in der Sepliste (die bereits in Baureihenfolge vorliegt)
- kein Rateverfahren noetig.
Rueckgabe: dict {key: {"typ", "x", "y", "id", "strecke_id", "vorgaenger",
"nachfolger"}} - vorgaenger/nachfolger sind wieder solche keys, oder None
am jeweiligen Kettenende (Verkettung mit dem Kreisel-Umlauf uebernimmt
compute_kreisel_umlauf). "id" ist die TeileId des Bauteils, das diesen
Eintrag in der Zeichnung vertritt (AS/ES ueber den dauerhaften
ZZ_ASES_MARKER, SEP ueber die beim Export ausgepackte temporaere
Separator_SP-Kopie - siehe csv:sepliste-ids-setzen, Lisp/export.lsp) -
leerer String, wenn (noch) keine ID zugewiesen wurde (Altbestand).
"""
result = {}
for item in items:
if item.get("teileart", "") not in GF_VF_TEILEARTEN:
continue
sepliste = item.get("sepliste")
if not sepliste:
continue
sid = item.get("teileid", "")
keys = [f"{sid}#{e.get('lfdnr')}" for e in sepliste]
for i, entry in enumerate(sepliste):
key = keys[i]
result[key] = {
"typ": entry.get("typ", ""),
"x": entry.get("x", 0.0),
"y": entry.get("y", 0.0),
"id": str(entry.get("id", "")).strip(),
"strecke_id": sid,
"vorgaenger": keys[i - 1] if i > 0 else None,
"nachfolger": keys[i + 1] if i < len(keys) - 1 else None,
}
if dbg:
kette_txt = " -> ".join(f"{e.get('typ')}:{e.get('lfdnr')}" for e in sepliste)
dbg(f" Strecke {sid}: Sepliste-Kette {kette_txt}")
return result
def compute_kreisel_umlauf(items, tolerance_mm, durchmesser_mm=800.0,
touch_switches=None, dbg=None):
"""Bringt fuer jeden echten Kreisel (KREISEL_SPLIT_TEILEART) alle dort
liegenden Punkte - frei platzierte Separatoren, BTMT-Beladung/-Entladung
UND die AS/ES-Enden aller andockenden VF_n/GF_n-Ketten (alle zusammen
KREISEL_UMLAUF_TEILEARTEN, einheitlich per BBox-Ueberschneidung einer
Kreiselhaelfte/eines Kreisels zugeordnet - wie compute_sensor_zuordnung;
ein AS/ES bringt seine eigene Bounding-Box vom dauerhaften ZZ_ASES_MARKER
mit, seit es als reales Item existiert - keine gesonderte Naeherungslogik
mehr noetig) UND die Kreisel-Kreisel-Weichen (touch_switches, siehe
compute_kreisel_touch_switches) - in eine geschlossene Umlauf-Reihenfolge
(zirkulaer, siehe DREHRICHTUNG-Attribut).
Eine Kreisel-Kreisel-Weiche liegt (anders als ein Separator oder ein
AS/ES-Ende) auf GENAU EINEM Punkt, gehoert aber zu ZWEI Kreiseln - man
kann von Kreisel A zu Kreisel B UND umgekehrt wechseln. Sie wird darum
in punkte_je_kreisel BEIDER beruehrender Kreisel eingetragen (gleiche
Weltposition, gleicher "key" - siehe touch_switches), damit sie im
Umlauf jedes der beiden Kreisel zwischen den jeweils angrenzenden
Separatoren/AS-ES-Enden auftaucht statt spurlos zu fehlen.
Winkel wird um den Kapsel-Mittelpunkt (Mitte AN8-/SP8-Achse, siehe
_kreisel_capsule) gemessen, 0 Grad = Kapsel-Achsrichtung. UZS
(Uhrzeigersinn, Default) sortiert nach fallendem Winkel, GUZ nach
steigendem - beides ergibt einen konsistenten Umlauf, die Achsrichtung
selbst ist nur der Nullpunkt. Die zurueckgegebene Liste beginnt IMMER bei
diesem Nullpunkt (kleinster Winkel >= 0 Grad in Umlaufrichtung) - das ist
fuer die reine Vorgaenger-/Nachfolger-Ermittlung (zirkulaer) irrelevant,
aber Voraussetzung fuer eine reproduzierbare, bei 0 Grad beginnende
"TrackIds"-Spalte (siehe export_csv.py write_kreisel_separatorliste_merkmale).
items = dieselbe Liste wie bei compute_neighbor_ids/compute_sensor_
zuordnung. touch_switches = Rueckgabe von compute_kreisel_touch_switches
(None/leer = wie bisher ohne Weichen im Umlauf).
Rueckgabe: dict {kreisel_teileid: [(punkt_key, vorgaenger_key,
nachfolger_key), ...]} in Umlaufreihenfolge, beginnend bei Winkel 0 Grad -
punkt_key ist die TeileId eines freien Punkts (Separator, BTMT-Beladung/
-Entladung ODER AS/ES-Ende, siehe KREISEL_UMLAUF_TEILEARTEN) oder ein
touch_switches-"key" (Kreisel-Kreisel-Weiche). Kreisel ohne zugeordnete
Punkte liefern eine leere Liste.
"""
half_tol = tolerance_mm / 2.0
kreisel = {}
for item in items:
if item.get("teileart", "") == KREISEL_SPLIT_TEILEART:
kreisel[item.get("teileid", "")] = (
_kreisel_capsule(item, durchmesser_mm),
(item.get("attribs", {}) or {}).get("DREHRICHTUNG", "UZS"))
# Freie Separatoren -> ihre Kreiselhaelfte (bereits per BBox-Ueberschneidung
# bestimmbar, wie compute_sensor_zuordnung es fuer die "Zuordnung"-Spalte
# tut - hier direkt gegen die volle Kreisel-BBox statt der Haelften, weil
# nur "gehoert zu diesem Kreisel" gebraucht wird, nicht links/rechts).
#
# Achsparallele Welt-AABB der Kapsel (a/b + Radius je Achse) statt einer
# Box mit fest "dx=durchmesser+achs_len, dy=durchmesser" - letzteres
# nimmt STILLSCHWEIGEND an, die Kreiselachse liegt entlang Welt-X. Bei
# einem um 90 Grad gedrehten Kreisel (Achse entlang Welt-Y, z.B. ein
# 20m-Kreisel laengs der Halle) drehte sich damit auch die Box um 90 Grad
# gegen die tatsaechliche Geometrie - schmal in Y (dort, wo der Kreisel in
# Wahrheit 20m lang ist) und ueberbreit in X (dort, wo er nur
# durchmesser_mm misst). Ergebnis: entfernte Punkte wurden faelschlich
# als "auf dem Kreisel" erkannt, waehrend tatsaechlich anliegende
# Separatoren durchfielen (realer Bug-Fall: Mubea, Kreisel 0002).
kreisel_bounds = {}
for tid, (cap, _dr) in kreisel.items():
r = cap["r"]
ax, ay = cap["a"]
bx, by = cap["b"]
minx, maxx = min(ax, bx) - r, max(ax, bx) + r
miny, maxy = min(ay, by) - r, max(ay, by) + r
kreisel_bounds[tid] = _bounds(
{"cx": (minx + maxx) / 2.0, "cy": (miny + maxy) / 2.0,
"dx": maxx - minx, "dy": maxy - miny},
half_tol)
punkte_je_kreisel = {tid: [] for tid in kreisel}
for item in items:
if item.get("teileart", "") not in KREISEL_UMLAUF_TEILEARTEN:
continue
teileid = item.get("teileid", "")
bbox = item.get("_bbox")
if not bbox:
if dbg:
dbg(f" Umlauf-Kandidat {teileid!r} ({item.get('teileart', '')!r}): "
f"KEINE Bounding-Box - kann keinem Kreisel zugeordnet werden")
continue
bounds = _bounds(bbox, half_tol)
matched_tid = None
for tid, kb in kreisel_bounds.items():
if _overlaps(bounds, kb):
punkte_je_kreisel[tid].append(
(teileid, bbox.get("cx", 0.0), bbox.get("cy", 0.0)))
matched_tid = tid
break
if dbg:
if matched_tid is not None:
dbg(f" Umlauf-Kandidat {teileid!r} ({item.get('teileart', '')!r}) "
f"bbox(cx={bbox.get('cx', 0):.0f},cy={bbox.get('cy', 0):.0f}) "
f"-> Kreisel {matched_tid}")
else:
dbg(f" Umlauf-Kandidat {teileid!r} ({item.get('teileart', '')!r}) "
f"bbox(cx={bbox.get('cx', 0):.0f},cy={bbox.get('cy', 0):.0f}) "
f"-> KEIN Kreisel in Reichweite (bounds={_fmt_bounds(bounds)})")
# Kreisel-Kreisel-Weichen: die beiden beruehrenden Kreisel-IDs stehen
# bereits in sw["nachbarn"] (siehe compute_kreisel_touch_switches) - kein
# Naeherungsverfahren wie bei Separator/AS/ES noetig, die Weiche wird in
# BEIDE Umlaeufe eingetragen.
for sw in (touch_switches or []):
for tid in sw.get("nachbarn", []):
if tid in punkte_je_kreisel:
punkte_je_kreisel[tid].append((sw["key"], sw["x"], sw["y"]))
result = {}
for tid, punkte in punkte_je_kreisel.items():
cap, drehrichtung = kreisel[tid]
acx = (cap["a"][0] + cap["b"][0]) / 2.0
acy = (cap["a"][1] + cap["b"][1]) / 2.0
achs_winkel = math.atan2(cap["b"][1] - cap["a"][1], cap["b"][0] - cap["a"][0])
cos_achs, sin_achs = math.cos(achs_winkel), math.sin(achs_winkel)
# Halbe Achslaenge (Mitte -> a bzw. b) und Radius der Kapsel = die
# beiden Halbachsen der Kreisel-BB im lokalen (gedrehten) Koordinaten-
# system - siehe kreisel_bounds oben, dieselbe "maximale Ausdehnung".
halblaenge = math.hypot(cap["b"][0] - cap["a"][0], cap["b"][1] - cap["a"][1]) / 2.0
radius = cap["r"]
uzs = (drehrichtung or "UZS").strip().upper() != "GUZ"
def _winkel(pxy):
# Abfolge ueber die BB des Kreisels statt ueber einen Winkel um den
# Mittelpunkt: Punkt zuerst ins lokale (um achs_winkel gedrehte)
# Koordinatensystem der Kreiselachse bringen (lx = Achsrichtung,
# ly = quer dazu), dann auf die BB-Halbachsen normieren
# (u = lx/halblaenge, v = ly/radius). Je nachdem, welche der beiden
# Groessen (u oder v) betragsmaessig ueberwiegt, liegt der Punkt auf
# der rechten/linken Stirnseite (u dominiert) oder der oberen/
# unteren Langseite (v dominiert) der BB - dort bestimmt NUR die
# jeweils andere (die entlang der Seite laufende) Koordinate die
# Position, der ueberwiegende Anteil legt nur noch die Seite fest.
# Ein grosser, asymmetrischer Block (z.B. BTMT-Beladung/-Entladung)
# hat einen Bbox-Mittelpunkt, der deutlich weiter quer zur Achse
# absteht als der Belt-Radius - beim reinen Winkel-um-Mittelpunkt
# (frueherer Ansatz) blies das seinen Winkel unverhaeltnismaessig
# auf und liess ihn mehrere eng benachbarte Punkte in der engen
# Sortierung ueberholen, obwohl er entlang der Achse klar der
# letzte ist (realer Bug-Fall: HundM05, BTMT-Entladung "0023").
# Hier wirkt sich dieselbe Ueberhoehung nicht mehr aus, solange sie
# nicht die Seiten-Zuordnung selbst umkippt.
#
# t liegt in [0,4) und laeuft GEGEN den Uhrzeigersinn (mathematische
# Standardrichtung) einmal um die BB, beginnend bei t=0 auf der
# rechten Stirnseite in Achsrichtung (lx=halblaenge, ly=0) - das
# ist derselbe Nullpunkt wie beim frueheren "0 Grad = Achsrichtung".
# 0..1 rechte Stirnseite (ly steigend), 1..2 obere Langseite (lx
# fallend), 2..3 linke Stirnseite (ly fallend), 3..4 untere
# Langseite (lx steigend).
dx0 = pxy[1] - acx
dy0 = pxy[2] - acy
lx = dx0 * cos_achs + dy0 * sin_achs
ly = -dx0 * sin_achs + dy0 * cos_achs
u = lx / halblaenge if halblaenge > 1e-9 else 0.0
v = ly / radius if radius > 1e-9 else 0.0
if u >= abs(v):
t = v / 2.0
elif v >= abs(u):
t = 1.0 - u / 2.0
elif u <= -abs(v):
t = 2.0 - v / 2.0
else:
t = 3.0 + u / 2.0
# UZS (Uhrzeigersinn, Default) negiert t (= absteigend sortieren
# auf t ist aequivalent zu aufsteigend auf -t) - identisches Muster
# wie beim vorherigen Winkel-Ansatz, siehe
# tests/test_export_kreisel_umlauf.py::TestKreiselUmlaufStartetBeiNullGrad.
t = -t if uzs else t
return t % 4.0
sortiert = sorted(punkte, key=lambda p: _winkel(p))
n = len(sortiert)
umlauf = []
for i, p in enumerate(sortiert):
vorgaenger = sortiert[(i - 1) % n][0] if n > 1 else None
nachfolger = sortiert[(i + 1) % n][0] if n > 1 else None
umlauf.append((p[0], vorgaenger, nachfolger))
result[tid] = umlauf
if dbg:
dbg(f" Kreisel {tid} ({drehrichtung}): Umlauf "
f"{' -> '.join(p[0] for p in sortiert)}")
for punkt_key, vorgaenger, nachfolger in umlauf:
dbg(f" Punkt {punkt_key!r}: vorgaenger={vorgaenger!r} "
f"nachfolger={nachfolger!r}")
return result