Vorwärtskanten weichen Elementen über freie Linien aus (#22)

- build_bpmn() prüft jeden Standardweg einer Vorwärtskante gegen alle
  fremden Elemente. Nur bei einer Kreuzung sucht umweg_points() einen
  rechtwinkligen Weg von Stub zu Stub über freie Linien (Lückenmitten
  und 15 neben jedem Element), erst mit einem, dann mit zwei
  Zwischenknicken; der kürzeste gewinnt. Saubere Kanten bleiben
  byte-gleich, die Beispiel-.bpmn ändern sich nicht.
- Belastungstest aus #12 als dauerhafter Test (126 Varianten: keine
  Kreuzung, rechtwinklige Anschlüsse), Test für das Beispiel aus dem
  Issue und zwei Einzeltests für umweg_points().
- Im Belastungstest werden 23 Kanten umgeleitet (3 bis 4
  Zwischenpunkte); Kreuzungen vorher 24, jetzt 0.
- Tests: python -m pytest -q -> 253 bestanden, 1 übersprungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 19:36:52 +02:00
co-authored by Claude Opus 5.5
parent 12152eb3b0
commit f3b291c6fc
5 changed files with 166 additions and 0 deletions
+84
View File
@@ -457,6 +457,78 @@ def _strecke_schneidet(p, q, box):
for s in range(1, 40))
# Umwegsuche (#22): Abstand der Umweglinien zu den Elementen
UMWEG_ABSTAND = 15
def _kehrt_um(punkte):
"""True, wenn zwei aufeinanderfolgende Stücke auf derselben Linie gegenläufig sind."""
for a, b, c in zip(punkte, punkte[1:], punkte[2:]):
if a[0] == b[0] == c[0] and (b[1] - a[1]) * (c[1] - b[1]) < 0:
return True
if a[1] == b[1] == c[1] and (b[0] - a[0]) * (c[0] - b[0]) < 0:
return True
return False
def _freie_linien(intervalle, zusatz):
"""Kandidaten für Umweglinien auf einer Achse: Mitte jeder Lücke zwischen den
(zusammengefassten) Element-Intervallen, knapp vor und hinter jedem Element,
dazu die Stub-Koordinaten der Kante."""
werte = set(zusatz)
zusammen = []
for lo, hi in sorted(intervalle):
werte.update((lo - UMWEG_ABSTAND, hi + UMWEG_ABSTAND))
if zusammen and lo <= zusammen[-1][1]:
zusammen[-1][1] = max(zusammen[-1][1], hi)
else:
zusammen.append([lo, hi])
werte.update((a[1] + b[0]) / 2 for a, b in zip(zusammen, zusammen[1:]))
return sorted(werte)
def umweg_points(ox, oy, src_side, tx, ty, tgt_side, src_stub, tgt_stub, boxen):
"""Rechtwinkliger Weg zwischen zwei Anschlusspunkten, der keines der Rechtecke
in boxen (x, y, w, h) schneidet - für Kanten, deren Standardweg durch ein
Element liefe (#22). Von den Stubs aus (gerade Stücke senkrecht zur Seite)
werden Wege mit einem bzw. zwei Zwischenknicken über freie Linien versucht;
genommen wird der mit den wenigsten Knicken, bei Gleichstand der kürzeste.
Gibt None zurück, wenn es keinen freien Weg gibt."""
dx, dy = GATEWAY_OUTWARD[src_side]
sx, sy = ox + dx * src_stub, oy + dy * src_stub
dx, dy = GATEWAY_OUTWARD[tgt_side]
ex, ey = tx + dx * tgt_stub, ty + dy * tgt_stub
erweitert = [(x - 5, y - 5, w + 10, h + 10) for x, y, w, h in boxen]
xs = _freie_linien([(x, x + w) for x, _, w, _ in boxen], (sx, ex))
ys = _freie_linien([(y, y + h) for _, y, _, h in boxen], (sy, ey))
def gueltig(mitte):
weg = _dedupe_consecutive([(ox, oy)] + mitte + [(tx, ty)])
if _kehrt_um(weg):
return None
innen = _dedupe_consecutive(mitte)
if any(_strecke_schneidet(a, b, box) for a, b in zip(innen, innen[1:]) for box in erweitert):
return None
return weg
def laenge(weg):
return sum(abs(a[0] - b[0]) + abs(a[1] - b[1]) for a, b in zip(weg, weg[1:]))
def ein_knick():
yield from ([(sx, sy), (x, sy), (x, ey), (ex, ey)] for x in xs)
yield from ([(sx, sy), (sx, y), (ex, y), (ex, ey)] for y in ys)
def zwei_knicke():
yield from ([(sx, sy), (x1, sy), (x1, y), (x2, y), (x2, ey), (ex, ey)] for x1 in xs for y in ys for x2 in xs)
yield from ([(sx, sy), (sx, y1), (x, y1), (x, y2), (ex, y2), (ex, ey)] for y1 in ys for x in xs for y2 in ys)
for stufe in (ein_knick, zwei_knicke): # zweite Stufe nur, wenn die erste nichts findet
wege = [w for w in map(gueltig, stufe()) if w]
if wege:
return min(wege, key=lambda w: (len(w), laenge(w), w))
return None
def anmerkung_groesse(text):
"""Breite und Höhe eines Anmerkungs-Kästchens, passend zur Textlänge."""
return ANMERKUNG_BREITE, max(DEFAULT_SIZE['TextAnnotation'][1],
@@ -991,6 +1063,8 @@ def build_bpmn(meta_md, steps, order, externals):
'id': f'{eid}_di', 'bpmnElement': eid, 'isHorizontal': 'true',
}).append(_bounds_el(0, ex_y, pool_width, 100))
# Hindernisse für Kanten: alle Elemente außer Gruppen und Anmerkungen (#22)
element_boxen = {sid: bounds_of(sid) for sid in order if steps[sid].type not in ('Group', 'TextAnnotation')}
gateway_shapes = {} # Gateways mit Beschriftung, Position wird zuletzt gewählt
hindernisse = [] # Rechtecke von Elementen und Kantenbeschriftungen
strecken = [] # alle Kantenstücke als ((x1, y1), (x2, y2))
@@ -1086,6 +1160,16 @@ def build_bpmn(meta_md, steps, order, externals):
tgt_is_gateway = SHAPE_KIND.get(steps[tgt].type) == 'diamond'
points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
src_is_gateway, tgt_is_gateway)
# Standardweg behalten, solange er kein fremdes Element schneidet;
# sonst Umweg über freie Linien (z. B. wenn eine Querverbindung
# zwischen Zweigen eine Aufgabe in eine Zwischenspalte schiebt, #22).
fremde = [box for sid, box in element_boxen.items() if sid not in (src, tgt)]
if any(_strecke_schneidet(a, b, box) for a, b in zip(points, points[1:]) for box in fremde):
umweg = umweg_points(ox, oy, src_side, tx, ty, tgt_side,
GATEWAY_STUB if src_is_gateway else RECHTECK_STUB,
GATEWAY_STUB if tgt_is_gateway else RECHTECK_STUB,
list(element_boxen.values()))
points = umweg or points
elif has_lanes:
band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1)
channel_y = pool_bottom - band + RUECKSPRUNG_ABSTAND * (back_flow_ids.index(flow_id) + 1)
+19
View File
@@ -674,6 +674,25 @@ def test_kante_zwischen_aufgabe_und_gateway_setzt_an_beiden_enden_rechtwinklig_a
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_umweg_meidet_hindernis_und_setzt_rechtwinklig_an():
"""Prüft, dass umweg_points() um ein Element zwischen Quelle und Ziel herumführt, an beiden Enden gerade aus der
Seite heraus bzw. hinein verläuft und nur waagrechte und senkrechte Stücke hat (Issue #22)."""
quelle, hindernis, ziel = (0, 0, 100, 80), (200, 0, 100, 80), (400, 0, 100, 80)
weg = m.umweg_points(100, 40, 'E', 400, 40, 'W', m.RECHTECK_STUB, m.RECHTECK_STUB, [quelle, hindernis, ziel])
assert weg[0] == (100, 40) and weg[-1] == (400, 40)
assert weg[1][1] == 40 and weg[-2][1] == 40 # Ost- bzw. Westseite: waagrecht
assert all(a[0] == b[0] or a[1] == b[1] for a, b in zip(weg, weg[1:]))
assert not any(m._strecke_schneidet(a, b, hindernis) for a, b in zip(weg, weg[1:]))
def test_umweg_ohne_freien_weg_liefert_none():
"""Prüft, dass umweg_points() None liefert, wenn das Ziel ringsum eingeschlossen ist – dann bleibt der Standardweg
(Issue #22)."""
ziel = (400, 0, 100, 80)
mauer = [(300, -200, 20, 500), (580, -200, 20, 500), (300, -220, 300, 20), (300, 300, 300, 20)]
assert m.umweg_points(100, 40, 'E', 400, 40, 'W', 20, 20, [ziel] + mauer) is None
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'
+3
View File
@@ -31,6 +31,9 @@ Skills sind übernommen, alles QGIS- und Plugin-Spezifische ist entfernt. Kern i
## Erledigt
- 25.09.2026 **Vorwärtskanten weichen Elementen aus** (#22): Umweg über freie Linien, nur wenn der
Standardweg ein Element schneidet; Belastungstest (126 Fälle) als dauerhafter Test. Beispiele unverändert.
- 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.
+8
View File
@@ -5,6 +5,14 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py)
- **Vorwärtskanten kennen die übrigen Elemente nur im Notfall.** `orthogonal_points()` rechnet rein geometrisch
zwischen zwei Anschlusspunkten. Das reicht, solange das Layout regelmäßig ist. Eine Querverbindung zwischen
Geschwisterzweigen schiebt aber eine Aufgabe eine Ebene tiefer in eine Zwischenspalte. Deshalb prüft
`build_bpmn()` jeden Standardweg gegen alle fremden Elemente und sucht nur bei einer Kreuzung mit
`umweg_points()` einen Weg über freie Linien (Lückenmitten, 15 neben jedem Element; erst ein, dann zwei
Zwischenknicke; kürzester gewinnt). So bleiben saubere Kanten byte-gleich. Der Belastungstest aus #12 ist jetzt
ein dauerhafter Test (`test_belastung_keine_kante_durch_ein_element_und_rechtwinklig`, 126 Fälle). (#22,
25.09.2026)
- **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
+52
View File
@@ -275,6 +275,58 @@ def test_kanten_setzen_an_aufgaben_rechtwinklig_an(md):
assert funde == [], "nicht rechtwinklig (Kante, Element): " + ", ".join(f"{k} → {e}" for k, e in funde)
# Belastungstest (#12, #22): kleine Modelle, in die je eine zusätzliche Kante "zurück -> <früherer Schritt>" eingefügt
# wird – für alle Paare, ohne Lanes, mit einer Lane und mit zwei abwechselnden Lanes. Querverbindungen zwischen
# Geschwisterzweigen verschieben dabei das Layout (eine Aufgabe rutscht eine Ebene tiefer).
BELASTUNGSMODELLE = {
"drei": ["S1 Start: A", " -> S2", "S2 Gateway XOR: W?", " a -> S3", " b -> S4", " c -> S5",
"S3 Task: Links", " -> S6", "S4 Task: Mitte", " -> S6", "S5 Task: Rechts", " -> S6",
"S6 Gateway XOR: Z", " -> S7", "S7 Task: Danach", " -> S8", "S8 Ende: E"],
"parallel": ["S1 Start: A", " -> S2", "S2 Task: Erfassen", " -> S3", "S3 Gateway AND: T", " -> S4", " -> S5",
"S4 Task: L", " -> S6", "S5 Task: R", " -> S6", "S6 Gateway AND: Z", " -> S7",
"S7 Gateway XOR: Ok?", " Ja -> S8", "S8 Ende: E"],
}
def _mit_lanes(zeilen, lane_von):
return [z.replace(": ", f": {lane_von(z.split()[0])}: ", 1) if z[:1] == "S" else z for z in zeilen]
def _belastungsfaelle():
faelle = []
for name, zeilen in BELASTUNGSMODELLE.items():
ids = [z.split()[0] for z in zeilen if z[:1] == "S"]
for quelle in ids[:-1]:
for ziel in ids[1:ids.index(quelle) + 1]:
mit = []
for z in zeilen:
mit.append(z)
if z.startswith(quelle + " "):
mit.append(f" zurück -> {ziel}")
for variante, zz in (("ohne", mit), ("1lane", _mit_lanes(mit, lambda s: "A")),
("2lanes", _mit_lanes(mit, lambda s: "A" if int(s[1:]) % 2 else "B"))):
faelle.append(pytest.param("\n".join(zz), id=f"{name}-{quelle}-{ziel}-{variante}"))
return faelle
@pytest.mark.parametrize("text", _belastungsfaelle())
def test_belastung_keine_kante_durch_ein_element_und_rechtwinklig(text):
"""Prüft an allen Varianten des Belastungstests, dass keine Kante durch ein Element läuft und jede Kante an Aufgaben
rechtwinklig ansetzt – auch wenn eine Querverbindung zwischen Zweigen das Layout verschiebt (Issue #22)."""
defs = m.build_bpmn(*m.parse_markdown(text))
assert kreuzungen(defs) == []
assert schraege_anschluesse(defs) == []
def test_beispiel_aus_issue_22_ohne_kreuzung():
"""Prüft das Beispiel aus Issue #22: Sprung von hinten in den rechten Zweig; die Kante vom Gateway zu diesem Zweig
läuft nicht durch die linke Aufgabe."""
zeilen = list(BELASTUNGSMODELLE["drei"])
zeilen.insert(zeilen.index("S7 Task: Danach") + 1, " zurück -> S5")
text = "\n".join(zeilen)
assert kreuzungen(m.build_bpmn(*m.parse_markdown(text))) == []
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")