From 96225b5e0226c860e35b7d9ff0263911415ea667 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 25 Sep 2026 13:13:37 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=BCcksprungkanten=20an=20allen=20Elementen?= =?UTF-8?q?=20vorbeif=C3=BChren=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .agents/skills/sn-bpmn/Anleitung.md | 3 +- .../sn-bpmn/beispiele/beispiel_lanes.bpmn | 20 +-- .../sn-bpmn/beispiele/beispiel_prozess.bpmn | 4 +- .agents/skills/sn-bpmn/md_to_bpmn.py | 80 ++++++++++- .agents/skills/sn-bpmn/test_md_to_bpmn.py | 128 ++++++++++++++++++ docs/status/STATUS.md | 4 + docs/wissen/fallstricke.md | 7 + tests/test_referenzmodelle.py | 1 - 8 files changed, 229 insertions(+), 18 deletions(-) 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", }