Rücksprungkanten an allen Elementen vorbeiführen (#12)

- Mit Lanes: ruecksprung_points() fährt senkrecht nur in den Gängen
  zwischen den Spalten (rechts der Quelle, links des Ziels) und waagrecht
  knapp unter Quelle bzw. Ziel und in einem Kanal am Poolboden. Das
  Kanalband wird nach der Platzierung an die letzte Lane angehängt: Die
  Elemente bleiben, wo sie sind, die Kante bleibt im eigenen Pool, jede
  Rücksprungkante hat eine eigene Kanalhöhe.
- Ohne Lanes: Kanal seitlich an allen Elementen zwischen Quelle und Ziel
  vorbei (parallele Zweige stehen versetzt). Steht ein Nachbar in der
  Zeile im Weg, unten hinaus bzw. oben hinein über die Lücke zwischen den
  Zeilen (zeile(), zeile_frei()).
- beispiel_lanes: Kante S4->S2 läuft nicht mehr durch S5, Pool um das
  Kanalband höher, externer Pool entsprechend tiefer. beispiel_prozess:
  Kanal bei x 50 statt 20. Referenzmodell unverändert.
- xfail für #12 entfernt; fünf neue Tests in test_md_to_bpmn.py.
- Belastungstest (126 Rücksprung-Varianten, mit/ohne Lanes): Kreuzungen
  durch Rücksprungkanten vorher in 31 Fällen, jetzt in keinem.
- Tests: python -m pytest -q -> 102 bestanden, 2 xfail (#13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 13:13:37 +02:00
co-authored by Claude Opus 5.5
parent b403036203
commit 96225b5e02
8 changed files with 229 additions and 18 deletions
+2 -1
View File
@@ -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
@@ -69,7 +69,7 @@
<bpmndi:BPMNDiagram id="Diagram_1">
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Collaboration_1">
<bpmndi:BPMNShape id="Participant_1_di" bpmnElement="Participant_1" isHorizontal="true">
<dc:Bounds x="0" y="0" width="1366" height="720"/>
<dc:Bounds x="0" y="0" width="1366" height="750"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_1_di" bpmnElement="Lane_1" isHorizontal="true">
<dc:Bounds x="0" y="0" width="1366" height="190"/>
@@ -78,10 +78,10 @@
<dc:Bounds x="0" y="190" width="1366" height="340"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_3_di" bpmnElement="Lane_3" isHorizontal="true">
<dc:Bounds x="0" y="530" width="1366" height="190"/>
<dc:Bounds x="0" y="530" width="1366" height="220"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Participant_Ext_1_di" bpmnElement="Participant_Ext_1" isHorizontal="true">
<dc:Bounds x="0" y="780" width="1366" height="100"/>
<dc:Bounds x="0" y="810" width="1366" height="100"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="StartEvent_S1_di" bpmnElement="StartEvent_S1">
<dc:Bounds x="110" y="77" width="36" height="36"/>
@@ -139,8 +139,12 @@
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
<di:waypoint x="713.333" y="315"/>
<di:waypoint x="713.333" y="770"/>
<di:waypoint x="350" y="770"/>
<di:waypoint x="713.333" y="335"/>
<di:waypoint x="825" y="335"/>
<di:waypoint x="825" y="735"/>
<di:waypoint x="255" y="735"/>
<di:waypoint x="255" y="420"/>
<di:waypoint x="350" y="420"/>
<di:waypoint x="350" y="400"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_6_di" bpmnElement="Flow_6">
@@ -160,16 +164,16 @@
<di:waypoint x="1250" y="95"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="MsgFlow_1_di" bpmnElement="MsgFlow_1">
<di:waypoint x="341.5" y="780"/>
<di:waypoint x="341.5" y="810"/>
<di:waypoint x="128" y="113"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="MsgFlow_2_di" bpmnElement="MsgFlow_2">
<di:waypoint x="746.667" y="315"/>
<di:waypoint x="683" y="780"/>
<di:waypoint x="683" y="810"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="MsgFlow_3_di" bpmnElement="MsgFlow_3">
<di:waypoint x="1110" y="135"/>
<di:waypoint x="1024.5" y="780"/>
<di:waypoint x="1024.5" y="810"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
@@ -120,8 +120,8 @@
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
<di:waypoint x="80" y="630"/>
<di:waypoint x="20" y="630"/>
<di:waypoint x="20" y="290"/>
<di:waypoint x="50" y="630"/>
<di:waypoint x="50" y="290"/>
<di:waypoint x="165" y="290"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_6_di" bpmnElement="Flow_6">
+74 -6
View File
@@ -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}'})
+128
View File
@@ -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'