diff --git a/.agents/skills/sn-bpmn/Anleitung.md b/.agents/skills/sn-bpmn/Anleitung.md
index e9068b8..cd90a84 100644
--- a/.agents/skills/sn-bpmn/Anleitung.md
+++ b/.agents/skills/sn-bpmn/Anleitung.md
@@ -170,7 +170,8 @@ dort explizite Pfeile setzen, wo der Ablauf von der reinen Lesereihenfolge
abweicht (Verzweigung, Sprung zurück, Zusammenführung).
Rücksprünge (Schleifen, z. B. „Nachforderung" zurück zur Prüfung) werden
-automatisch erkannt und am Rand des Diagramms herumgeführt.
+automatisch erkannt und an allen Elementen vorbei zurückgeführt: mit Lanes
+über einen Kanal am Boden des eigenen Pools, ohne Lanes seitlich am Diagramm.
### Verantwortliche / Swimlanes
diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn
index 739c763..3d10bbc 100644
--- a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn
+++ b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn
@@ -69,7 +69,7 @@
-
+
@@ -78,10 +78,10 @@
-
+
-
+
@@ -139,8 +139,12 @@
-
-
+
+
+
+
+
+
@@ -160,16 +164,16 @@
-
+
-
+
-
+
diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn
index d31a72e..178d4f3 100644
--- a/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn
+++ b/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn
@@ -120,8 +120,8 @@
-
-
+
+
diff --git a/.agents/skills/sn-bpmn/md_to_bpmn.py b/.agents/skills/sn-bpmn/md_to_bpmn.py
index a0f18bd..321ecf8 100644
--- a/.agents/skills/sn-bpmn/md_to_bpmn.py
+++ b/.agents/skills/sn-bpmn/md_to_bpmn.py
@@ -399,6 +399,15 @@ LANE_HEADER_WIDTH = 30
LANE_PADDING = 40
LANE_ROW_HEIGHT = 150
LANE_MIN_HEIGHT = 150
+# Rücksprungkanten im Lanes-Layout (#12): Abstand der Kanalhöhen am Poolboden und
+# Länge des senkrechten Stücks unter Quelle bzw. Ziel. Unter jedem Element sind im
+# eigenen Zeilenslot mindestens (LANE_ROW_HEIGHT - 80) / 2 = 35 frei.
+RUECKSPRUNG_ABSTAND = 15
+RUECKSPRUNG_STUB = 20
+# Ohne Lanes: Abstand des seitlichen Kanals vom äußersten Element (MARGIN 80 lässt
+# links Platz für mehrere Kanäle).
+RUECKSPRUNG_SEITE = 30
+GROESSTE_BREITE = max(w for typ, (w, _) in DEFAULT_SIZE.items() if typ not in UNCONNECTED_TYPES)
def layout_lanes(steps, order, lanes):
@@ -595,6 +604,21 @@ def _ausweichachse(quelle, ziel):
return ziel + AUSWEICHABSTAND if quelle > ziel else ziel - AUSWEICHABSTAND
+def ruecksprung_points(ox, oy, tx, ty, src_spalte, tgt_spalte, kanal_y):
+ """Weg einer Rücksprungkante im Lanes-Layout vom Südpunkt der Quelle zum
+ Südpunkt des Ziels. Senkrecht wird nur in den freien Gängen zwischen den
+ Spalten gefahren (rechts der Quell-, links der Ziel-Spalte), waagrecht nur
+ knapp unter Quelle bzw. Ziel und im Kanal am Poolboden - so schneidet die
+ Kante keine Elemente, die in denselben Spalten weiter unten stehen (#12).
+ src_spalte/tgt_spalte sind die linken Kanten der Spalten (x_pos)."""
+ rechts = src_spalte + (GROESSTE_BREITE + LANE_COLUMN_WIDTH) / 2
+ links = tgt_spalte - (LANE_COLUMN_WIDTH - GROESSTE_BREITE) / 2
+ unter_quelle = oy + RUECKSPRUNG_STUB
+ unter_ziel = ty + RUECKSPRUNG_STUB
+ return [(ox, oy), (ox, unter_quelle), (rechts, unter_quelle), (rechts, kanal_y),
+ (links, kanal_y), (links, unter_ziel), (tx, unter_ziel), (tx, ty)]
+
+
def orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
src_is_gateway=False, tgt_is_gateway=False):
"""Baut einen rechtwinkligen Kantenverlauf zwischen zwei Anschlusspunkten, der
@@ -859,8 +883,17 @@ def build_bpmn(meta_md, steps, order, externals):
return x_pos[sid], y_pos[sid], w, h
pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in order), default=400) + MARGIN
+ back_flow_ids = [flow_id for flow_id, _, _, _, is_back in flows if is_back]
if has_lanes:
lane_top, lane_height, pool_height = lane_geo
+ if back_flow_ids:
+ # Kanal für Rücksprungkanten als Band am Boden der letzten Lane, erst
+ # nach der Platzierung angehängt - die Elemente bleiben, wo sie sind,
+ # und der Rückweg verläuft im eigenen Pool (#12).
+ band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1)
+ lane_height[lanes[-1]] += band
+ pool_height += band
+ lane_geo = (lane_top, lane_height, pool_height)
else:
pool_height = max((y_pos[sid] + bounds_of(sid)[3] for sid in order), default=300) + MARGIN
@@ -910,14 +943,31 @@ def build_bpmn(meta_md, steps, order, externals):
gateway_sides = assign_gateway_sides(steps, flows)
+ def zeile(sid):
+ """Die übrigen verbundenen Elemente, die mit sid auf einer Höhe stehen."""
+ y, h = y_pos[sid], bounds_of(sid)[3]
+ return [o for o in order if o != sid and steps[o].type not in UNCONNECTED_TYPES
+ and y_pos[o] < y + h and y_pos[o] + bounds_of(o)[3] > y]
+
+ def zeile_frei(sid, seite):
+ """True, wenn in der Zeile von sid zur Seite 'W' bzw. 'E' hin kein Element steht."""
+ if seite == 'W':
+ return all(x_pos[o] >= x_pos[sid] for o in zeile(sid))
+ rechts = x_pos[sid] + bounds_of(sid)[2]
+ return all(x_pos[o] + bounds_of(o)[2] <= rechts for o in zeile(sid))
+
for flow_id, src, tgt, label, is_back in flows:
if has_lanes:
src_side, tgt_side = ('E', 'W') if not is_back else ('S', 'S')
elif not is_back:
src_side, tgt_side = 'S', 'N'
else:
+ # Rücksprung ohne Lanes: seitlich hinaus zum Kanal - außer ein
+ # Nachbar in derselben Zeile steht im Weg, dann unten hinaus bzw.
+ # von oben hinein über die freie Lücke zwischen den Zeilen (#12).
same_side = 'W' if x_pos[src] <= x_pos[tgt] else 'E'
- src_side, tgt_side = same_side, same_side
+ src_side = same_side if zeile_frei(src, same_side) else 'S'
+ tgt_side = same_side if zeile_frei(tgt, same_side) else 'N'
if not is_back:
# PICTURE-Gateways haben nur 4 feste Anschlusspunkte (N/O/S/W) - eigene
# Zuteilung statt der obigen Layout-Konvention, siehe assign_gateway_sides().
@@ -957,12 +1007,30 @@ def build_bpmn(meta_md, steps, order, externals):
points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
src_is_gateway, tgt_is_gateway)
elif has_lanes:
- channel_y = pool_bottom + 50
- points = [(ox, oy), (ox, channel_y), (tx, channel_y), (tx, ty)]
+ band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1)
+ channel_y = pool_bottom - band + RUECKSPRUNG_ABSTAND * (back_flow_ids.index(flow_id) + 1)
+ points = ruecksprung_points(ox, oy, tx, ty, x_pos[src], x_pos[tgt], channel_y)
else:
- offset = -60 if src_side == 'W' else 60
- channel_x = (min(ox, tx) if src_side == 'W' else max(ox, tx)) + offset
- points = [(ox, oy), (channel_x, oy), (channel_x, ty), (tx, ty)]
+ # Ohne Lanes stehen die Elemente nicht in festen Spalten (parallele
+ # Zweige sind zur Mitte versetzt): Kanal seitlich an allen Elementen
+ # vorbei, die zwischen den Zeilen von Ziel und Quelle stehen (#12).
+ start, y_start = [(ox, oy)], oy
+ if src_side == 'S':
+ y_start = max(y_pos[o] + bounds_of(o)[3] for o in zeile(src) + [src]) + RUECKSPRUNG_STUB
+ start.append((ox, y_start))
+ ende, y_ende = [(tx, ty)], ty
+ if tgt_side == 'N':
+ y_ende = min(y_pos[o] for o in zeile(tgt) + [tgt]) - RUECKSPRUNG_STUB
+ ende.insert(0, (tx, y_ende))
+ oben, unten = min(y_start, y_ende), max(y_start, y_ende)
+ zwischen = [sid for sid in order if steps[sid].type not in UNCONNECTED_TYPES
+ and y_pos[sid] <= unten and y_pos[sid] + bounds_of(sid)[3] >= oben]
+ versatz = RUECKSPRUNG_SEITE + RUECKSPRUNG_ABSTAND * back_flow_ids.index(flow_id)
+ if x_pos[src] <= x_pos[tgt]:
+ channel_x = min(x_pos[sid] for sid in zwischen) - versatz
+ else:
+ channel_x = max(x_pos[sid] + bounds_of(sid)[2] for sid in zwischen) + versatz
+ points = _dedupe_consecutive(start + [(channel_x, y_start), (channel_x, y_ende)] + ende)
for px, py in points:
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
diff --git a/.agents/skills/sn-bpmn/test_md_to_bpmn.py b/.agents/skills/sn-bpmn/test_md_to_bpmn.py
index e951afd..f42f01e 100644
--- a/.agents/skills/sn-bpmn/test_md_to_bpmn.py
+++ b/.agents/skills/sn-bpmn/test_md_to_bpmn.py
@@ -437,6 +437,134 @@ def test_nur_xor_gateways_zeigen_ihr_x():
assert shape.get('isMarkerVisible') == erwartet, shape.get('bpmnElement')
+RUECKSPRUNG_MODELL = """
+Extern: Antragsteller
+
+S1 Start: Posteingang: Antrag geht ein
+ -> S2
+S2 Task: Sachbearbeitung: Antrag erfassen
+S3 Gateway XOR: Sachbearbeitung: Antrag vollständig?
+ Ja -> S5
+ Nein -> S4
+S4 Sendeaufgabe: Sachbearbeitung: Unterlagen nachfordern
+ ~> Antragsteller
+ -> S2
+S5 Task: Sachbearbeitung: Entscheidung vorbereiten
+S6 Task: Teamleitung: Entscheidung zeichnen
+ Zurück -> S3
+ Weiter -> S7
+S7 Ende: Posteingang: Antrag entschieden
+"""
+
+
+def _baue_und_finde(text):
+ """Baut das Modell und liefert (defs, Bounds je Element-Id, Wegpunkte je (Quelle, Ziel)-Paar der Sequenzflüsse)."""
+ meta, steps, order, externals = m.parse_markdown(text)
+ defs = m.build_bpmn(meta, steps, order, externals)
+ bounds = {}
+ for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
+ b = shape.find(f"{{{m.NS['dc']}}}Bounds")
+ bounds[shape.get('bpmnElement')] = tuple(float(b.get(a)) for a in ('x', 'y', 'width', 'height'))
+ paare = {f.get('id'): (f.get('sourceRef'), f.get('targetRef')) for f in defs.iter(f"{{{m.NS['bpmn']}}}sequenceFlow")}
+ wege = {}
+ for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"):
+ if edge.get('bpmnElement') in paare:
+ wege[paare[edge.get('bpmnElement')]] = [(float(w.get('x')), float(w.get('y')))
+ for w in edge.findall(f"{{{m.NS['di']}}}waypoint")]
+ return defs, bounds, wege
+
+
+def test_ruecksprungkante_bleibt_im_eigenen_pool():
+ """Prüft, dass eine Rücksprungkante im Lanes-Layout vollständig innerhalb des Prozess-Pools verläuft und nicht in
+ der Lücke zum externen Pool oder darin (Issue #12)."""
+ _, bounds, wege = _baue_und_finde(RUECKSPRUNG_MODELL)
+ px, py, pw, ph = bounds['Participant_1']
+ ex, ey, ew, eh = bounds['Participant_Ext_1']
+ for x, y in wege[('SendTask_S4', 'Task_S2')] + wege[('Task_S6', 'Gateway_S3')]:
+ assert px <= x <= px + pw and py <= y <= py + ph, f"Wegpunkt ({x:g}, {y:g}) außerhalb des Pools"
+ assert not (ey <= y <= ey + eh), f"Wegpunkt ({x:g}, {y:g}) im externen Pool"
+
+
+def test_mehrere_ruecksprungkanten_haben_eigene_kanalhoehe():
+ """Prüft, dass zwei Rücksprungkanten ihren waagrechten Rückweg auf verschiedenen Höhen nehmen, statt sich auf
+ einer Linie zu überdecken (Issue #12)."""
+ _, _, wege = _baue_und_finde(RUECKSPRUNG_MODELL)
+ def tiefster_punkt(weg):
+ return max(y for _, y in weg)
+ assert tiefster_punkt(wege[('SendTask_S4', 'Task_S2')]) != tiefster_punkt(wege[('Task_S6', 'Gateway_S3')])
+
+
+def test_ruecksprungkante_ohne_lanes_laeuft_an_versetzten_zweigen_vorbei():
+ """Prüft, dass eine Rücksprungkante ohne Lanes seitlich an allen Elementen zwischen Quelle und Ziel vorbeiführt,
+ auch an einem parallelen Zweig, der gegenüber der Mitte versetzt steht (Issue #12)."""
+ text = """
+ S1 Start: Beginn
+ -> S2
+ S2 Task: Erfassen
+ -> S3
+ S3 Gateway AND: Teilen
+ -> S4
+ -> S5
+ S4 Task: Prüfen links
+ -> S6
+ S5 Task: Prüfen rechts
+ -> S6
+ S6 Gateway AND: Zusammen
+ -> S7
+ S7 Gateway XOR: In Ordnung?
+ Nein -> S2
+ Ja -> S8
+ S8 Ende: Fertig
+ """
+ _, bounds, wege = _baue_und_finde(text)
+ weg = wege[('Gateway_S7', 'Task_S2')]
+ for eid, (x, y, w, h) in bounds.items():
+ if eid.startswith(('Task_', 'Gateway_', 'StartEvent_', 'EndEvent_')):
+ for (x1, y1), (x2, y2) in zip(weg, weg[1:]):
+ for s in range(1, 40):
+ px, py = x1 + (x2 - x1) * s / 40, y1 + (y2 - y1) * s / 40
+ assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"Rücksprung durch {eid}"
+
+
+DREI_ZWEIGE = """
+S1 Start: Beginn
+ -> S2
+S2 Gateway XOR: Welcher Weg?
+ a -> S3
+ b -> S4
+ c -> S5
+S3 Task: Links
+ -> S6
+S4 Task: Mitte
+ {rueck4}-> S6
+S5 Task: Rechts
+ -> S6
+S6 Gateway XOR: Zusammen
+ {rueck6}-> S7
+S7 Ende: Fertig
+"""
+
+
+@pytest.mark.parametrize("fall, quelle_ziel", [
+ ("rueck4", ('Task_S4', 'Gateway_S2')), # Quelle hat links einen Nachbarn in ihrer Zeile
+ ("rueck6", ('Gateway_S6', 'Task_S3')), # Kanal rechts, Ziel hat rechts einen Nachbarn in seiner Zeile
+])
+def test_ruecksprungkante_ohne_lanes_quert_keinen_nachbarn_in_der_zeile(fall, quelle_ziel):
+ """Prüft, dass eine Rücksprungkante ohne Lanes nicht waagrecht durch ein Nachbarelement in der Zeile von Quelle oder
+ Ziel läuft, sondern dann über die freie Lücke unter bzw. über der Zeile zum Kanal geht (Issue #12)."""
+ rueck = {"rueck4": "nochmal -> S2\n ", "rueck6": "nochmal -> S3\n "}
+ text = DREI_ZWEIGE.format(rueck4=rueck[fall] if fall == "rueck4" else "",
+ rueck6=rueck[fall] if fall == "rueck6" else "")
+ _, bounds, wege = _baue_und_finde(text)
+ weg = wege[quelle_ziel]
+ for eid, (x, y, w, h) in bounds.items():
+ if eid.startswith(('Task_', 'Gateway_', 'StartEvent_', 'EndEvent_')):
+ for (x1, y1), (x2, y2) in zip(weg, weg[1:]):
+ for s in range(1, 40):
+ px, py = x1 + (x2 - x1) * s / 40, y1 + (y2 - y1) * s / 40
+ assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"Rücksprung durch {eid}"
+
+
def test_ausgabe_ist_wohlgeformtes_xml(tmp_path):
"""Prüft, dass die erzeugte .bpmn-Datei sich als valides XML parsen laesst."""
src = tmp_path / 'prozess.md'
diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md
index 32e21d3..e8a5be7 100644
--- a/docs/status/STATUS.md
+++ b/docs/status/STATUS.md
@@ -31,6 +31,10 @@ Skills sind übernommen, alles QGIS- und Plugin-Spezifische ist entfernt. Kern i
## Erledigt
+- 25.09.2026 **Rücksprungkanten laufen an allen Elementen vorbei** (#12): mit Lanes über Gänge zwischen den
+ Spalten und einen Kanal im eigenen Pool, ohne Lanes seitlich an allen Elementen vorbei. Vorbestehend und offen:
+ Vorwärtskanten bei Querverbindungen zwischen Zweigen (eigenes Issue).
+
- 25.09.2026 **Strukturtests für alle Kanten und Elemente** (#16): alle Sequenz- und Nachrichtenflüsse aller
Beispiele gegen alle Elemente, alle Funde auf einmal; bekannte Fälle je Kreuzung als `xfail` (#12, #13).
`AUSWEICHABSTAND` per Test an die Layout-Maße gebunden. xfail-Regel in `AGENTS.md` auf alle Beispiele erweitert.
diff --git a/docs/wissen/fallstricke.md b/docs/wissen/fallstricke.md
index 4d8d2fb..589501d 100644
--- a/docs/wissen/fallstricke.md
+++ b/docs/wissen/fallstricke.md
@@ -5,6 +5,13 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py)
+- **Rücksprungkanten brauchen je Layout eine eigene Führung.** Mit Lanes stehen die Elemente in festen Spalten
+ (linksbündig, 190 auseinander). Der Rückweg läuft senkrecht nur in den Gängen zwischen den Spalten und waagrecht
+ in einem Kanalband, das am Boden der letzten Lane **nach** der Platzierung angehängt wird. So bleiben die
+ Elemente, wo sie sind, und die Kante bleibt im eigenen Pool. Ohne Lanes gibt es keine festen Spalten, parallele
+ Zweige stehen versetzt zur Mitte. Der Kanal liegt deshalb seitlich an **allen** Elementen zwischen Quelle und
+ Ziel vorbei. Steht ein Nachbar in derselben Zeile im Weg, geht die Kante unten hinaus bzw. oben hinein.
+ Geprüft mit allen 126 Rücksprung-Varianten zweier Testmodelle, mit und ohne Lanes. (#12, 25.09.2026)
- **Vorschau und PICTURE können sich unterscheiden, auch ohne Fehler im Modell.** Beispiel XOR-Gateway: PICTURE
zeichnet das „X“ nach eigenen Regeln, auch ohne `isMarkerVisible`. Die Vorschau (bpmn-js) hält sich an das
Attribut und zeigte ohne es leere Rauten. Seit #15 setzt der Konverter das Attribut, beide zeigen das X. Wirkt
diff --git a/tests/test_referenzmodelle.py b/tests/test_referenzmodelle.py
index 2185106..d8e5369 100644
--- a/tests/test_referenzmodelle.py
+++ b/tests/test_referenzmodelle.py
@@ -33,7 +33,6 @@ KANTEN = ("sequenceFlow", "messageFlow")
# Jedes Paar bekommt einen eigenen xfail(strict=True)-Test; sobald der Fehler behoben ist, schlägt dieser als XPASS
# fehl – dann den Eintrag hier entfernen. Nie verwenden, um neue Fehler zu verstecken (AGENTS.md).
BEKANNTE_KREUZUNGEN = {
- ("beispiel_lanes", "Flow_5", "Task_S5"): "Issue #12: Rücksprungkante S4→S2 läuft senkrecht durch S5",
("beispiel_lanes", "MsgFlow_2", "Task_S5"): "Issue #13: Nachrichtenfluss zum externen Pool läuft durch S5",
("referenz_gewannenfestlegung", "MsgFlow_2", "Task_S24"): "Issue #13: Nachrichtenfluss von S16 läuft durch S24",
}