diff --git a/.agents/skills/sn-bpmn/Anleitung.md b/.agents/skills/sn-bpmn/Anleitung.md index cd90a84..9ea21e7 100644 --- a/.agents/skills/sn-bpmn/Anleitung.md +++ b/.agents/skills/sn-bpmn/Anleitung.md @@ -150,6 +150,11 @@ Nachrichtenflüsse sind nur zwischen Pools erlaubt (siehe `Modellierregeln.md`) – das Ziel muss immer ein per `Extern:` deklarierter Name sein, nie eine Schritt-ID im selben Pool. +Der Konverter zeichnet Nachrichtenflüsse senkrecht vom Schritt hinunter zum +externen Pool. Steht darunter ein anderes Element, weicht der Fluss vorher in +einen freien Gang aus (mit Lanes neben der Spalte, ohne Lanes rechts neben dem +Diagramm). + ### Verzweigungen / Sprünge Unter einem Schritt können beliebig viele Pfeil-Zeilen stehen: diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn index 3d10bbc..dd3566a 100644 --- a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn +++ b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn @@ -164,16 +164,18 @@ - + - + + + - + diff --git a/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn b/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn index 05ff1f7..84d6fbf 100644 --- a/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn +++ b/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn @@ -710,11 +710,13 @@ - + - + + + diff --git a/.agents/skills/sn-bpmn/md_to_bpmn.py b/.agents/skills/sn-bpmn/md_to_bpmn.py index 321ecf8..76cf9f7 100644 --- a/.agents/skills/sn-bpmn/md_to_bpmn.py +++ b/.agents/skills/sn-bpmn/md_to_bpmn.py @@ -407,6 +407,11 @@ 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 +# Nachrichtenflüsse (#13): senkrechtes Stück unter dem Schritt (länger als bei +# Rücksprüngen, damit sich die waagrechten Stücke nicht überdecken) und Versatz +# im Gang gegenüber den Rücksprungkanten. +NACHRICHT_STUB = 30 +NACHRICHT_VERSATZ = 20 GROESSTE_BREITE = max(w for typ, (w, _) in DEFAULT_SIZE.items() if typ not in UNCONNECTED_TYPES) @@ -884,6 +889,16 @@ def build_bpmn(meta_md, steps, order, externals): 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] + msg_kanal_x = {} + if not has_lanes and msg_flow_records: + # Ohne Lanes steht unter jedem Schritt der nächste: Nachrichtenflüsse + # laufen rechts neben allen Elementen (und den Rücksprung-Kanälen) + # hinunter; der Pool wird so breit, dass diese Kanäle darin liegen (#13). + rechts_aussen = max(x_pos[s] + bounds_of(s)[2] for s in order if steps[s].type not in UNCONNECTED_TYPES) + basis = rechts_aussen + RUECKSPRUNG_SEITE + RUECKSPRUNG_ABSTAND * len(back_flow_ids) + for k, (mfid, _, _, _) in enumerate(msg_flow_records): + msg_kanal_x[mfid] = basis + RUECKSPRUNG_ABSTAND * k + pool_width = max(pool_width, max(msg_kanal_x.values()) + RUECKSPRUNG_SEITE) if has_lanes: lane_top, lane_height, pool_height = lane_geo if back_flow_ids: @@ -979,7 +994,6 @@ def build_bpmn(meta_md, steps, order, externals): for mfid, sid, direction, target in msg_flow_records: register_anchor(('n', sid), 'S', f'{mfid}_node') - register_anchor(('x', target), 'N', f'{mfid}_ext') def shape_kind_for(key): kind, ident = key @@ -1050,8 +1064,24 @@ def build_bpmn(meta_md, steps, order, externals): for mfid, sid, direction, target in msg_flow_records: edge = ET.SubElement(plane, q('bpmndi', 'BPMNEdge'), {'id': f'{mfid}_di', 'bpmnElement': mfid}) ox, oy = spread_anchor(('n', sid), 'S', f'{mfid}_node') - tx, ty = spread_anchor(('x', target), 'N', f'{mfid}_ext') - points = [(ox, oy), (tx, ty)] if direction == 'out' else [(tx, ty), (ox, oy)] + ext_top = ext_pos[target][1] + # Senkrecht hinunter zum externen Pool; jeder Punkt an dessen Oberkante + # ist ein gültiger Anschluss. Steht darunter ein Element im Weg, erst + # kurz nach unten und seitlich in einen freien Gang (#13). + im_weg = [o for o in order if o != sid and steps[o].type not in UNCONNECTED_TYPES + and x_pos[o] < ox < x_pos[o] + bounds_of(o)[2] + and y_pos[o] + bounds_of(o)[3] > oy and y_pos[o] < ext_top] + if not im_weg: + points = [(ox, oy), (ox, ext_top)] + else: + if has_lanes: + gang_x = x_pos[sid] + (GROESSTE_BREITE + LANE_COLUMN_WIDTH) / 2 + NACHRICHT_VERSATZ + else: + gang_x = msg_kanal_x[mfid] + unter = oy + NACHRICHT_STUB + points = [(ox, oy), (ox, unter), (gang_x, unter), (gang_x, ext_top)] + if direction != 'out': + points = points[::-1] 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 f42f01e..ec4e793 100644 --- a/.agents/skills/sn-bpmn/test_md_to_bpmn.py +++ b/.agents/skills/sn-bpmn/test_md_to_bpmn.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Tests für md_to_bpmn.py - reines Python/pytest, ohne Netzwerk.""" import xml.etree.ElementTree as ET +from pathlib import Path import pytest @@ -565,6 +566,61 @@ def test_ruecksprungkante_ohne_lanes_quert_keinen_nachbarn_in_der_zeile(fall, qu assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"Rücksprung durch {eid}" +NACHRICHTEN_OHNE_LANES = """ +Extern: Bürger +S1 Start Nachricht: Antrag geht ein + <~ Bürger + -> S2 +S2 Task: Prüfen + -> S3 +S3 SendTask: Rückfrage senden + ~> Bürger + -> S4 +S4 Task: Entscheiden + -> S5 +S5 Ende: Fertig +""" + +NACHRICHTEN_MIT_LANES = (Path(__file__).parent / "beispiele" / "beispiel_lanes.md").read_text(encoding="utf-8") + + +def _nachrichtenfluesse(text): + """Baut das Modell und liefert (Bounds je Element-Id, Wegpunkte je Nachrichtenfluss-Id).""" + defs, bounds, _ = _baue_und_finde(text) + wege = {} + for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"): + if edge.get('bpmnElement').startswith('MsgFlow_'): + wege[edge.get('bpmnElement')] = [(float(w.get('x')), float(w.get('y'))) + for w in edge.findall(f"{{{m.NS['di']}}}waypoint")] + return bounds, wege + + +@pytest.mark.parametrize("text", [NACHRICHTEN_OHNE_LANES, NACHRICHTEN_MIT_LANES], ids=["ohne Lanes", "mit Lanes"]) +def test_nachrichtenfluesse_sind_rechtwinklig(text): + """Prüft, dass jeder Nachrichtenfluss nur aus waagrechten und senkrechten Stücken besteht, statt schräg zum + externen Pool zu laufen (Issue #13).""" + _, wege = _nachrichtenfluesse(text) + assert wege + for mfid, weg in wege.items(): + for (x1, y1), (x2, y2) in zip(weg, weg[1:]): + assert abs(x1 - x2) < 1e-6 or abs(y1 - y2) < 1e-6, f"{mfid} schräg von ({x1:g}, {y1:g}) nach ({x2:g}, {y2:g})" + + +@pytest.mark.parametrize("text", [NACHRICHTEN_OHNE_LANES, NACHRICHTEN_MIT_LANES], ids=["ohne Lanes", "mit Lanes"]) +def test_nachrichtenfluss_quert_kein_element(text): + """Prüft, dass kein Nachrichtenfluss durch ein Element läuft, das unter seinem Schritt steht – ohne Lanes steht + jeder Folgeschritt genau darunter (Issue #13).""" + bounds, wege = _nachrichtenfluesse(text) + for mfid, weg in wege.items(): + for eid, (x, y, w, h) in bounds.items(): + if not eid.startswith(('Task_', 'SendTask_', 'Gateway_', 'StartEvent_', 'EndEvent_')): + continue + for (x1, y1), (x2, y2) in zip(weg, weg[1:]): + for s in range(1, 60): + px, py = x1 + (x2 - x1) * s / 60, y1 + (y2 - y1) * s / 60 + assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"{mfid} 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 e8a5be7..67ce486 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 **Nachrichtenflüsse senkrecht und an allen Elementen vorbei** (#13): Ende senkrecht unter dem + Start, bei Hindernis über einen freien Gang. Damit gibt es in den Beispielen keine bekannte Kreuzung mehr + (`BEKANNTE_KREUZUNGEN` leer). + - 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). diff --git a/docs/wissen/fallstricke.md b/docs/wissen/fallstricke.md index 589501d..23def6c 100644 --- a/docs/wissen/fallstricke.md +++ b/docs/wissen/fallstricke.md @@ -5,6 +5,12 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik ## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py) +- **Nachrichtenflüsse enden senkrecht unter ihrem Start, nicht an einem verteilten Punkt.** Der externe Pool ist + ein breites Rechteck, jeder Punkt an seiner Oberkante ist ein gültiger Anschluss. Die frühere Verteilung der + Anschlusspunkte über die Pool-Oberkante erzeugte schräge Linien quer durch das Diagramm. Steht unter dem Schritt + ein Element, geht der Fluss über einen Gang: mit Lanes 20 neben dem Gang der Rücksprungkanten und mit längerem + Stub (30 statt 20), damit sich beide nicht überdecken; ohne Lanes rechts neben allen Elementen, dafür wird der + Pool breiter. Belastungstest mit 342 Varianten: vorher 182 Fälle mit Kreuzung, jetzt keiner. (#13, 25.09.2026) - **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 diff --git a/tests/test_referenzmodelle.py b/tests/test_referenzmodelle.py index d8e5369..4593602 100644 --- a/tests/test_referenzmodelle.py +++ b/tests/test_referenzmodelle.py @@ -32,10 +32,7 @@ KANTEN = ("sequenceFlow", "messageFlow") # Bekannte, per Issue erfasste Konverterfehler: (Modell, Kante, Element) -> Grund mit Issue-Nummer. # 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", "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", -} +BEKANNTE_KREUZUNGEN = {} def baue(md):