From b53828374e7c12734ccc89d0bb3e58ec90c3a77e Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 25 Sep 2026 18:39:22 +0200 Subject: [PATCH] Kanten an Aufgaben rechtwinklig ansetzen (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orthogonal_points(): Ist nur ein Ende eine Raute, bekommt auch das Aufgaben-Ende einen geraden Stub senkrecht zu seiner Seite (RECHTECK_STUB = 20); danach wie zwischen zwei Rauten von Stub zu Stub. Die beiden Sonderzweige für "nur Quelle/nur Ziel ist Gateway" entfallen. - Beispiele neu erzeugt: 17 Kantenenden (2 + 2 + 13), die parallel zur Aufgabenkante ansetzten, sind korrigiert. - Strukturtest test_kanten_setzen_an_aufgaben_rechtwinklig_an über alle Beispiele und Einzeltest für vier Achsen-Kombinationen. - Belastungstest (126 Fälle): schräge Anschlüsse 378 -> 0; Kreuzungen 22 -> 24, alle in Fällen mit Querverbindung zwischen Zweigen (#22). - Tests: python -m pytest -q -> 124 bestanden, 1 übersprungen. Co-Authored-By: Claude Opus 5.5 --- .../sn-bpmn/beispiele/beispiel_lanes.bpmn | 7 ++- .../sn-bpmn/beispiele/beispiel_prozess.bpmn | 16 ++++-- .../referenz_gewannenfestlegung.bpmn | 52 +++++++++++++------ .agents/skills/sn-bpmn/md_to_bpmn.py | 36 ++++++------- .agents/skills/sn-bpmn/test_md_to_bpmn.py | 19 +++++++ docs/status/STATUS.md | 3 ++ docs/wissen/fallstricke.md | 7 +++ tests/test_referenzmodelle.py | 39 ++++++++++++++ 8 files changed, 138 insertions(+), 41 deletions(-) diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn index 3ad5bbe..2062b59 100644 --- a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn +++ b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn @@ -118,14 +118,16 @@ - + + - + + @@ -135,6 +137,7 @@ + diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn index 15d12a1..264cd65 100644 --- a/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn +++ b/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn @@ -67,7 +67,7 @@ - + @@ -102,7 +102,8 @@ - + + @@ -110,6 +111,7 @@ + @@ -118,7 +120,8 @@ - + + @@ -132,7 +135,8 @@ - + + @@ -140,6 +144,7 @@ + @@ -148,7 +153,8 @@ - + + diff --git a/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn b/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn index 396037a..9c87419 100644 --- a/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn +++ b/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn @@ -473,14 +473,16 @@ - + + - + + @@ -510,18 +512,21 @@ + - + + - + + @@ -540,6 +545,7 @@ + @@ -548,7 +554,8 @@ - + + @@ -566,18 +573,21 @@ + - + + - + + @@ -605,7 +615,8 @@ - + + @@ -615,6 +626,7 @@ + @@ -628,13 +640,15 @@ - + + - + + @@ -649,7 +663,8 @@ - + + @@ -659,6 +674,7 @@ + @@ -666,13 +682,15 @@ - + + - + + @@ -697,7 +715,8 @@ - + + @@ -715,6 +734,7 @@ + @@ -722,6 +742,7 @@ + @@ -735,7 +756,8 @@ - + + diff --git a/.agents/skills/sn-bpmn/md_to_bpmn.py b/.agents/skills/sn-bpmn/md_to_bpmn.py index 77c5658..494532a 100644 --- a/.agents/skills/sn-bpmn/md_to_bpmn.py +++ b/.agents/skills/sn-bpmn/md_to_bpmn.py @@ -615,6 +615,9 @@ GATEWAY_OUTWARD = {'N': (0, -1), 'S': (0, 1), 'E': (1, 0), 'W': (-1, 0)} # Halbe Kantenlaenge der Raute (Standardgroesse 50x50) reicht, um deren Kontur # in jedem Fall sicher zu verlassen, bevor die Kante abbiegt. GATEWAY_STUB = 25 +# Gerades Stück an einer Aufgabe, wenn das andere Ende eine Raute ist (#23); passt +# in den kleinsten Gang (70) zwischen zwei Spalten bzw. Zeilen, auch von beiden Seiten. +RECHTECK_STUB = 20 def _dedupe_consecutive(points): @@ -688,35 +691,30 @@ def orthogonal_points(ox, oy, src_side, tx, ty, tgt_side, dieses Schritts (die einzige garantiert freie Koordinate) beim nächsten Abbiegen zunächst unverändert bleibt. Sonst würde die Kante beim Umbiegen zum Ziel auf demselben Weg zurück durch die Raute laufen, den - sie gerade erst verlassen hat.""" + sie gerade erst verlassen hat. + + Ist nur ein Ende eine Raute, bekommt auch das andere Ende (Aufgabe) einen + kurzen geraden Schritt senkrecht zu seiner Seite (RECHTECK_STUB); danach + wird wie zwischen zwei Rauten von Stub zu Stub geführt. Sonst liefe die + Kante am Aufgaben-Ende parallel zu dessen Kante, wenn die Anschlusspunkte + auf verschiedenen Achsen liegen (#23).""" + gemischt = src_is_gateway != tgt_is_gateway sx, sy = ox, oy - if src_is_gateway: + if src_is_gateway or gemischt: dx, dy = GATEWAY_OUTWARD[src_side] - sx, sy = ox + dx * GATEWAY_STUB, oy + dy * GATEWAY_STUB + stub = GATEWAY_STUB if src_is_gateway else RECHTECK_STUB + sx, sy = ox + dx * stub, oy + dy * stub ex, ey = tx, ty - if tgt_is_gateway: + if tgt_is_gateway or gemischt: dx, dy = GATEWAY_OUTWARD[tgt_side] - ex, ey = tx + dx * GATEWAY_STUB, ty + dy * GATEWAY_STUB + stub = GATEWAY_STUB if tgt_is_gateway else RECHTECK_STUB + ex, ey = tx + dx * stub, ty + dy * stub src_axis = 'v' if src_side in ('N', 'S') else 'h' tgt_axis = 'v' if tgt_side in ('N', 'S') else 'h' if not src_is_gateway and not tgt_is_gateway: points = _orthogonal_middle(ox, oy, src_side, tx, ty, tgt_side) - elif src_is_gateway and not tgt_is_gateway: - # sy/sx (die Stub-Achse) fixieren, solange die andere Koordinate zum - # Ziel hin schwenkt - erst danach auf der Stub-Achse zum Ziel. - if src_axis == 'v': - points = [(ox, oy), (sx, sy), (tx, sy), (tx, ty)] - else: - points = [(ox, oy), (sx, sy), (sx, ty), (tx, ty)] - elif tgt_is_gateway and not src_is_gateway: - # Spiegelbildlich: erst auf der Stub-Achse des Ziels ankommen, erst - # danach dorthin schwenken. - if tgt_axis == 'v': - points = [(ox, oy), (ox, ey), (ex, ey), (tx, ty)] - else: - points = [(ox, oy), (ex, oy), (ex, ey), (tx, ty)] else: # Beide Enden sind Rauten: von der Stub-Achse des Starts direkt auf # die Stub-Achse des Ziels schwenken, jede nur einmal verlassen. diff --git a/.agents/skills/sn-bpmn/test_md_to_bpmn.py b/.agents/skills/sn-bpmn/test_md_to_bpmn.py index a742e23..1e615cd 100644 --- a/.agents/skills/sn-bpmn/test_md_to_bpmn.py +++ b/.agents/skills/sn-bpmn/test_md_to_bpmn.py @@ -655,6 +655,25 @@ def test_anmerkung_waechst_mit_ihrem_text(): assert kurz_w == lang_w and lang_h > kurz_h +@pytest.mark.parametrize("src_side, tgt_side, src_gw, tgt_gw", [ + ('E', 'N', False, True), # Aufgabe (Ostseite) -> Gateway (Nordpunkt), Lanes-Layout + ('S', 'W', True, False), # Gateway (Südpunkt) -> Aufgabe (Westseite), Lanes-Layout + ('S', 'E', False, True), # Aufgabe (Südseite) -> Gateway (Ostpunkt), Layout ohne Lanes + ('W', 'N', True, False), # Gateway (Westpunkt) -> Aufgabe (Nordseite), Layout ohne Lanes +]) +def test_kante_zwischen_aufgabe_und_gateway_setzt_an_beiden_enden_rechtwinklig_an(src_side, tgt_side, src_gw, tgt_gw): + """Prüft, dass eine Kante zwischen Aufgabe und Gateway an beiden Enden rechtwinklig zur jeweiligen Seite ansetzt, + auch wenn die Seiten auf verschiedenen Achsen liegen (Issue #23).""" + punkte = m.orthogonal_points(100, 100, src_side, 400, 300, tgt_side, src_is_gateway=src_gw, tgt_is_gateway=tgt_gw) + def achse(seite): + return 'waagrecht' if seite in ('E', 'W') else 'senkrecht' + def verlauf(a, b): + return 'waagrecht' if abs(a[1] - b[1]) < 1e-6 else 'senkrecht' + assert verlauf(punkte[0], punkte[1]) == achse(src_side) + assert verlauf(punkte[-2], punkte[-1]) == achse(tgt_side) + assert all(verlauf(a, b) and (a[0] == b[0] or a[1] == b[1]) for a, b in zip(punkte, punkte[1:])) + + 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 ff6f835..7587a2e 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -31,6 +31,9 @@ Skills sind übernommen, alles QGIS- und Plugin-Spezifische ist entfernt. Kern i ## Erledigt +- 25.09.2026 **Kanten setzen an Aufgaben rechtwinklig an** (#23): Stub am Aufgaben-Ende auch bei Kanten zu + bzw. von Gateways; 17 Kantenenden in den Beispielen korrigiert. + - 25.09.2026 **Beschriftungen überlappen nicht mehr** (#14): Gateway-Beschriftungen mit fester Position (45°, sonst nächste freie Stelle), Anmerkungen rechts neben dem Pool in passender Größe, Kantenbeschriftungen waagrecht aus Gateways unter der Linie. diff --git a/docs/wissen/fallstricke.md b/docs/wissen/fallstricke.md index 5481d53..f09aff9 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) +- **Auch das Aufgaben-Ende einer Gateway-Kante braucht ein gerades Stück.** Das Gateway-Ende bekommt einen festen + Anschlusspunkt (N/O/S/W), das Aufgaben-Ende die Layout-Seite (mit Lanes O/W, ohne Lanes N/S). Liegen beide auf + verschiedenen Achsen, lief die Kante früher direkt an der Aufgabenkante entlang. Jetzt bekommt die Aufgabe einen + Stub (`RECHTECK_STUB = 20`) senkrecht zu ihrer Seite, danach geht es wie zwischen zwei Rauten von Stub zu Stub. + Geprüft mit `test_kanten_setzen_an_aufgaben_rechtwinklig_an` über alle Beispiele. Im Belastungstest aus #12 sind + 378 schräge Anschlüsse verschwunden. Dafür laufen in 4 künstlichen Fällen mit Querverbindung zwischen Zweigen + Kanten durch eine Aufgabe, in 2 anderen nicht mehr (Art von #22). (#23, 25.09.2026) - **PICTURE übernimmt Beschriftungspositionen und Kästchengrößen aus der Datei.** Ohne `BPMNLabel` setzt es die Gateway-Beschriftung auf 180° (unter die Raute), wo der südliche Pfad und dessen Beschriftung sie schneiden. Im PICTURE-Editor steht sie auf 45°. Der Konverter setzt die Position deshalb selbst: 45°, sonst 45° rechts vom diff --git a/tests/test_referenzmodelle.py b/tests/test_referenzmodelle.py index 9091f6b..ffa3727 100644 --- a/tests/test_referenzmodelle.py +++ b/tests/test_referenzmodelle.py @@ -236,6 +236,45 @@ def test_anmerkung_ist_gross_genug_fuer_ihren_text(md): assert h >= zeilen * 16, f"{anm.get('id')}: {len(text)} Zeichen brauchen etwa {zeilen} Zeilen, Höhe nur {h:g}" +RECHTECKE = HINDERNISSE[len(GATEWAYS + EREIGNISSE):] # Aufgaben, Teilprozesse, Datenelemente + + +def schraege_anschluesse(defs): + """Alle Kantenenden an Rechteck-Elementen, deren erstes bzw. letztes Stück nicht rechtwinklig zu der Elementkante + verläuft, auf der der Anschlusspunkt liegt (Ost/West verlangt waagrecht, Nord/Süd senkrecht).""" + art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")} + enden = {el.get("id"): (el.get("sourceRef"), el.get("targetRef")) + for el in defs.iter() if art.get(el.get("id")) in KANTEN} + formen = {s.get("bpmnElement"): _box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")} + funde = [] + for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"): + kante = edge.get("bpmnElement") + punkte = [(float(wp.get("x")), float(wp.get("y"))) for wp in edge.findall(f"{{{m.NS['di']}}}waypoint")] + if kante not in enden or len(punkte) < 2: + continue + for element, (a, b) in ((enden[kante][0], punkte[:2]), (enden[kante][1], punkte[:-3:-1])): + if art.get(element) not in RECHTECKE: + continue + x, y, w, h = formen[element] + if abs(a[0] - x) < 0.5 or abs(a[0] - x - w) < 0.5: + rechtwinklig = abs(a[1] - b[1]) < 1e-6 # Ost/West: waagrecht + elif abs(a[1] - y) < 0.5 or abs(a[1] - y - h) < 0.5: + rechtwinklig = abs(a[0] - b[0]) < 1e-6 # Nord/Süd: senkrecht + else: + rechtwinklig = False # Anschluss nicht auf dem Rand + if not rechtwinklig: + funde.append((kante, element)) + return sorted(funde) + + +@pytest.mark.parametrize("md", MODELLE, ids=lambda p: p.stem) +def test_kanten_setzen_an_aufgaben_rechtwinklig_an(md): + """Prüft, dass jede Kante an einer Aufgabe (bzw. einem anderen Rechteck-Element) rechtwinklig zu der Kante ansetzt, + auf der ihr Anschlusspunkt liegt, statt an ihr entlangzulaufen (Issue #23).""" + funde = schraege_anschluesse(baue(md)) + assert funde == [], "nicht rechtwinklig (Kante, Element): " + ", ".join(f"{k} → {e}" for k, e in funde) + + def test_kreuzungspruefung_findet_eine_kante_durch_eine_aufgabe(): """Prüft die Prüfung selbst: eine absichtlich durch eine Aufgabe gelegte Kante wird gefunden, eine daneben nicht.""" defs = baue(SKILL / "beispiele" / "beispiel_prozess.md")