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