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")