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)) 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): def anmerkung_groesse(text):
"""Breite und Höhe eines Anmerkungs-Kästchens, passend zur Textlänge.""" """Breite und Höhe eines Anmerkungs-Kästchens, passend zur Textlänge."""
return ANMERKUNG_BREITE, max(DEFAULT_SIZE['TextAnnotation'][1], 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', 'id': f'{eid}_di', 'bpmnElement': eid, 'isHorizontal': 'true',
}).append(_bounds_el(0, ex_y, pool_width, 100)) }).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 gateway_shapes = {} # Gateways mit Beschriftung, Position wird zuletzt gewählt
hindernisse = [] # Rechtecke von Elementen und Kantenbeschriftungen hindernisse = [] # Rechtecke von Elementen und Kantenbeschriftungen
strecken = [] # alle Kantenstücke als ((x1, y1), (x2, y2)) 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' tgt_is_gateway = SHAPE_KIND.get(steps[tgt].type) == 'diamond'
points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side, points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
src_is_gateway, tgt_is_gateway) 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: elif has_lanes:
band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1) band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1)
channel_y = pool_bottom - band + RUECKSPRUNG_ABSTAND * (back_flow_ids.index(flow_id) + 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:])) 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): def test_ausgabe_ist_wohlgeformtes_xml(tmp_path):
"""Prüft, dass die erzeugte .bpmn-Datei sich als valides XML parsen laesst.""" """Prüft, dass die erzeugte .bpmn-Datei sich als valides XML parsen laesst."""
src = tmp_path / 'prozess.md' 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 ## 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 - 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. 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) ## 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 - **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 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 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) 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(): 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.""" """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") defs = baue(SKILL / "beispiele" / "beispiel_prozess.md")