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:
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user