Beschriftungen und Anmerkungen ohne Überlappung setzen (#14)

- Gateway-Beschriftungen bekommen eine feste Position (BPMNLabel), weil
  PICTURE sie aus der Datei übernimmt: 45° wie im PICTURE-Editor, sonst
  45° rechts vom Ost-Stub, 315°, 135°, 225° – die erste Stelle ohne
  Kante, Kantenbeschriftung oder Element. Gesetzt nach allen Kanten.
- Kantenbeschriftungen waagrecht aus einem Gateway stehen unter der Linie
  (darüber steht die Gateway-Beschriftung).
- Anmerkungen rechts neben dem Pool untereinander, Höhe passend zum Text
  (text_zeilen(), anmerkung_groesse()); Pool ohne Anmerkungen bemessen.
- Beispiele neu erzeugt. Alle 17 Gateway-Beschriftungen auf 45° bzw. 45°
  rechts vom Stub, keine im Lane-Kopf oder in einer Nachbar-Lane.
- Strukturtests für alle Beispiele (Beschriftung ungeschnitten, in der
  eigenen Lane; Anmerkungen ohne Überlappung, neben dem Pool, groß genug)
  und drei Einzeltests.
- Tests: python -m pytest -q -> 117 bestanden, 1 übersprungen (leere
  Parameterliste der bekannten Kreuzungen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 16:10:19 +02:00
co-authored by Claude Opus 5.5
parent d40d49b002
commit 993b6de50c
9 changed files with 339 additions and 24 deletions
+5
View File
@@ -108,6 +108,11 @@ Jeder Schritt ist eine Zeile der Form:
Vollständige Liste inkl. Bedeutung: `Elementkatalog.md`.
Hinweise stehen im Diagramm rechts neben dem Pool untereinander, in einem
Kästchen passend zur Textlänge. Gateway-Beschriftungen stehen oben rechts an
der Raute (wie im PICTURE-Editor), bei belegtem Platz an der nächsten freien
Stelle.
### Typisierte Ereignisse
Start-, Zwischen- und Endereignisse können ein zweites Wort für die Art des
@@ -91,6 +91,9 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S3_di" bpmnElement="Gateway_S3" isMarkerVisible="true">
<dc:Bounds x="490" y="335" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="570" y="305.5" width="90" height="42"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="SendTask_S4_di" bpmnElement="SendTask_S4">
<dc:Bounds x="680" y="235" width="100" height="80"/>
@@ -134,7 +137,7 @@
<di:waypoint x="565" y="275"/>
<di:waypoint x="680" y="275"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="537.5" y="340" width="30" height="14"/>
<dc:Bounds x="537.5" y="366" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
@@ -66,6 +66,9 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S3_di" bpmnElement="Gateway_S3" isMarkerVisible="true">
<dc:Bounds x="165" y="420" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="245" y="390.5" width="90" height="42"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="SendTask_S4_di" bpmnElement="SendTask_S4">
<dc:Bounds x="80" y="590" width="100" height="80"/>
@@ -75,6 +78,9 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S6_di" bpmnElement="Gateway_S6" isMarkerVisible="true">
<dc:Bounds x="165" y="760" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="202.5" y="744.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S7_di" bpmnElement="Task_S7">
<dc:Bounds x="80" y="930" width="100" height="80"/>
@@ -115,7 +121,7 @@
<di:waypoint x="240" y="590"/>
<di:waypoint x="130" y="590"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="212.5" y="425" width="30" height="14"/>
<dc:Bounds x="212.5" y="451" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
@@ -145,7 +151,7 @@
<di:waypoint x="240" y="930"/>
<di:waypoint x="300" y="930"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="212.5" y="765" width="30" height="14"/>
<dc:Bounds x="212.5" y="791" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_9_di" bpmnElement="Flow_9">
@@ -273,22 +273,22 @@
<bpmndi:BPMNDiagram id="Diagram_1">
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Collaboration_1">
<bpmndi:BPMNShape id="Participant_1_di" bpmnElement="Participant_1" isHorizontal="true">
<dc:Bounds x="0" y="0" width="5480" height="1060"/>
<dc:Bounds x="0" y="0" width="5166" height="1060"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_1_di" bpmnElement="Lane_1" isHorizontal="true">
<dc:Bounds x="0" y="0" width="5480" height="340"/>
<dc:Bounds x="0" y="0" width="5166" height="340"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_2_di" bpmnElement="Lane_2" isHorizontal="true">
<dc:Bounds x="0" y="340" width="5480" height="190"/>
<dc:Bounds x="0" y="340" width="5166" height="190"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_3_di" bpmnElement="Lane_3" isHorizontal="true">
<dc:Bounds x="0" y="530" width="5480" height="190"/>
<dc:Bounds x="0" y="530" width="5166" height="190"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_4_di" bpmnElement="Lane_4" isHorizontal="true">
<dc:Bounds x="0" y="720" width="5480" height="340"/>
<dc:Bounds x="0" y="720" width="5166" height="340"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Participant_Ext_1_di" bpmnElement="Participant_Ext_1" isHorizontal="true">
<dc:Bounds x="0" y="1120" width="5480" height="100"/>
<dc:Bounds x="0" y="1120" width="5166" height="100"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="StartEvent_S1_di" bpmnElement="StartEvent_S1">
<dc:Bounds x="110" y="152" width="36" height="36"/>
@@ -307,6 +307,9 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S6_di" bpmnElement="Gateway_S6" isMarkerVisible="true">
<dc:Bounds x="1060" y="145" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="1140" y="129.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S7_di" bpmnElement="Task_S7">
<dc:Bounds x="1250" y="130" width="100" height="80"/>
@@ -319,30 +322,48 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S10_di" bpmnElement="Gateway_S10" isMarkerVisible="true">
<dc:Bounds x="1820" y="145" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="1857.5" y="115.5" width="90" height="42"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S11_di" bpmnElement="Task_S11">
<dc:Bounds x="2010" y="130" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S12_di" bpmnElement="Gateway_S12" isMarkerVisible="true">
<dc:Bounds x="2200" y="145" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2237.5" y="129.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S13_di" bpmnElement="Gateway_S13" isMarkerVisible="true">
<dc:Bounds x="2390" y="60" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2470" y="44.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S14_di" bpmnElement="Task_S14">
<dc:Bounds x="2580" y="45" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S15_di" bpmnElement="Gateway_S15" isMarkerVisible="true">
<dc:Bounds x="2770" y="60" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2807.5" y="44.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S16_di" bpmnElement="Task_S16">
<dc:Bounds x="2960" y="45" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S17_di" bpmnElement="Gateway_S17" isMarkerVisible="true">
<dc:Bounds x="3150" y="60" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3187.5" y="44.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S18_di" bpmnElement="Gateway_S18" isMarkerVisible="true">
<dc:Bounds x="3340" y="60" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3377.5" y="44.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S19_di" bpmnElement="Task_S19">
<dc:Bounds x="3530" y="585" width="100" height="80"/>
@@ -355,9 +376,15 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S22_di" bpmnElement="Gateway_S22" isMarkerVisible="true">
<dc:Bounds x="3910" y="145" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3947.5" y="129.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S23_di" bpmnElement="Gateway_S23" isMarkerVisible="true">
<dc:Bounds x="3340" y="230" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3420" y="214.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S24_di" bpmnElement="Task_S24">
<dc:Bounds x="3530" y="935" width="100" height="80"/>
@@ -367,9 +394,15 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S26_di" bpmnElement="Gateway_S26" isMarkerVisible="true">
<dc:Bounds x="3720" y="230" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3757.5" y="214.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S27_di" bpmnElement="Gateway_S27" isMarkerVisible="true">
<dc:Bounds x="4100" y="145" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="4137.5" y="129.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S40_di" bpmnElement="Task_S40">
<dc:Bounds x="2390" y="215" width="100" height="80"/>
@@ -379,15 +412,24 @@
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S42_di" bpmnElement="Gateway_S42" isMarkerVisible="true">
<dc:Bounds x="2770" y="230" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2807.5" y="200.5" width="90" height="42"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S43_di" bpmnElement="Task_S43">
<dc:Bounds x="2960" y="215" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S44_di" bpmnElement="Gateway_S44" isMarkerVisible="true">
<dc:Bounds x="3150" y="230" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3187.5" y="214.5" width="90" height="28"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_S50_di" bpmnElement="Gateway_S50" isMarkerVisible="true">
<dc:Bounds x="4290" y="145" width="50" height="50"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="4327.5" y="101.5" width="90" height="56"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_S51_di" bpmnElement="Task_S51">
<dc:Bounds x="4480" y="130" width="100" height="80"/>
@@ -402,16 +444,16 @@
<dc:Bounds x="5050" y="152" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="TextAnnotation_H0_di" bpmnElement="TextAnnotation_H0">
<dc:Bounds x="5240" y="40" width="160" height="60"/>
<dc:Bounds x="5206" y="0" width="250" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="TextAnnotation_H3_di" bpmnElement="TextAnnotation_H3">
<dc:Bounds x="5240" y="130" width="160" height="60"/>
<dc:Bounds x="5206" y="80" width="250" height="266"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="TextAnnotation_H1_di" bpmnElement="TextAnnotation_H1">
<dc:Bounds x="5240" y="220" width="160" height="60"/>
<dc:Bounds x="5206" y="366" width="250" height="90"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="TextAnnotation_H2_di" bpmnElement="TextAnnotation_H2">
<dc:Bounds x="5240" y="310" width="160" height="60"/>
<dc:Bounds x="5206" y="476" width="250" height="106"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1_di" bpmnElement="Flow_1">
<di:waypoint x="146" y="170"/>
@@ -451,7 +493,7 @@
<di:waypoint x="1845" y="120"/>
<di:waypoint x="1845" y="145"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="1107.5" y="150" width="30" height="14"/>
<dc:Bounds x="1107.5" y="176" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_8_di" bpmnElement="Flow_8">
@@ -500,7 +542,7 @@
<di:waypoint x="2275" y="255"/>
<di:waypoint x="2390" y="255"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2247.5" y="150" width="30" height="14"/>
<dc:Bounds x="2247.5" y="176" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_15_di" bpmnElement="Flow_15">
@@ -519,7 +561,7 @@
<di:waypoint x="2795" y="35"/>
<di:waypoint x="2795" y="60"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2437.5" y="65" width="30" height="14"/>
<dc:Bounds x="2437.5" y="91" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_17_di" bpmnElement="Flow_17">
@@ -557,7 +599,7 @@
<di:waypoint x="3365" y="205"/>
<di:waypoint x="3365" y="230"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3197.5" y="65" width="30" height="14"/>
<dc:Bounds x="3197.5" y="91" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_22_di" bpmnElement="Flow_22">
@@ -575,7 +617,7 @@
<di:waypoint x="3415" y="805"/>
<di:waypoint x="3530" y="805"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3387.5" y="65" width="30" height="14"/>
<dc:Bounds x="3387.5" y="91" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_24_di" bpmnElement="Flow_24">
@@ -619,7 +661,7 @@
<di:waypoint x="3415" y="170"/>
<di:waypoint x="3530" y="170"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="3387.5" y="235" width="132" height="14"/>
<dc:Bounds x="3387.5" y="261" width="132" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_30_di" bpmnElement="Flow_30">
@@ -675,7 +717,7 @@
<di:waypoint x="2845" y="255"/>
<di:waypoint x="2960" y="255"/>
<bpmndi:BPMNLabel>
<dc:Bounds x="2817.5" y="235" width="30" height="14"/>
<dc:Bounds x="2817.5" y="261" width="30" height="14"/>
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_38_di" bpmnElement="Flow_38">
+100 -4
View File
@@ -412,6 +412,55 @@ RUECKSPRUNG_SEITE = 30
# im Gang gegenüber den Rücksprungkanten.
NACHRICHT_STUB = 30
NACHRICHT_VERSATZ = 20
# Beschriftungen (#14): grobe Schätzung der Textgröße, damit Kästchen und
# Beschriftungen groß genug sind - PICTURE vergrößert sonst beim Import und
# überlappt Nachbarn. Anmerkungen stehen rechts neben dem Pool.
ZEICHENBREITE = 7
ZEILENHOEHE = 16
ANMERKUNG_BREITE = 250
ANMERKUNG_ABSTAND = 40
GATEWAY_LABEL_BREITE = 90
GATEWAY_LABEL_ZEILE = 14
def text_zeilen(text, breite):
"""Geschätzte Zeilenzahl von text bei Umbruch an Wortgrenzen in einem Feld der
angegebenen Breite (abzüglich 10 Innenrand, ZEICHENBREITE je Zeichen)."""
je_zeile = max(1, int((breite - 10) // ZEICHENBREITE))
zeilen, laenge = 1, 0
for wort in text.split():
if laenge == 0:
laenge = len(wort)
elif laenge + 1 + len(wort) <= je_zeile:
laenge += 1 + len(wort)
else:
zeilen, laenge = zeilen + 1, len(wort)
while laenge > je_zeile: # überlange Wörter brechen hart um
zeilen, laenge = zeilen + 1, laenge - je_zeile
return zeilen
def _rechtecke_ueberlappen(a, b):
"""True, wenn sich die Rechtecke a und b (x, y, w, h) echt überschneiden."""
return a[0] < b[0] + b[2] and b[0] < a[0] + a[2] and a[1] < b[1] + b[3] and b[1] < a[1] + a[3]
def _strecke_schneidet(p, q, box):
"""True, wenn die Strecke p-q durchs Innere des Rechtecks box (x, y, w, h) läuft."""
x, y, w, h = box
(x1, y1), (x2, y2) = p, q
if abs(y1 - y2) < 1e-6: # waagrecht
return y < y1 < y + h and min(x1, x2) < x + w and max(x1, x2) > x
if abs(x1 - x2) < 1e-6: # senkrecht
return x < x1 < x + w and min(y1, y2) < y + h and max(y1, y2) > y
return any(x < x1 + (x2 - x1) * s / 40 < x + w and y < y1 + (y2 - y1) * s / 40 < y + h
for s in range(1, 40))
def anmerkung_groesse(text):
"""Breite und Höhe eines Anmerkungs-Kästchens, passend zur Textlänge."""
return ANMERKUNG_BREITE, max(DEFAULT_SIZE['TextAnnotation'][1],
text_zeilen(text, ANMERKUNG_BREITE) * ZEILENHOEHE + 10)
GROESSTE_BREITE = max(w for typ, (w, _) in DEFAULT_SIZE.items() if typ not in UNCONNECTED_TYPES)
@@ -884,10 +933,14 @@ def build_bpmn(meta_md, steps, order, externals):
def bounds_of(sid):
st = steps[sid]
w, h = DEFAULT_SIZE.get(st.type, (100, 80))
if st.type == 'TextAnnotation':
w, h = anmerkung_groesse(st.label)
else:
w, h = DEFAULT_SIZE.get(st.type, (100, 80))
return x_pos[sid], y_pos[sid], w, h
pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in order), default=400) + MARGIN
im_diagramm = [sid for sid in order if steps[sid].type != 'TextAnnotation']
pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in im_diagramm), default=400) + MARGIN
back_flow_ids = [flow_id for flow_id, _, _, _, is_back in flows if is_back]
msg_kanal_x = {}
if not has_lanes and msg_flow_records:
@@ -910,7 +963,16 @@ def build_bpmn(meta_md, steps, order, externals):
pool_height += band
lane_geo = (lane_top, lane_height, pool_height)
else:
pool_height = max((y_pos[sid] + bounds_of(sid)[3] for sid in order), default=300) + MARGIN
pool_height = max((y_pos[sid] + bounds_of(sid)[3] for sid in im_diagramm), default=300) + MARGIN
# Anmerkungen rechts neben dem Pool untereinander, in der Größe ihres Texts:
# so kreuzen sie keine Lane-Grenzen und überlappen sich nicht, auch wenn
# PICTURE die Position streng aus der Datei übernimmt (#14).
anm_y = 0
for sid in order:
if steps[sid].type == 'TextAnnotation':
x_pos[sid], y_pos[sid] = pool_width + ANMERKUNG_ABSTAND, anm_y
anm_y += bounds_of(sid)[3] + ANMERKUNG_ABSTAND / 2
ext_pos = {}
if has_collaboration:
@@ -931,6 +993,9 @@ 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))
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))
for sid in order:
attrs = {'id': f'{node_ids[sid]}_di', 'bpmnElement': node_ids[sid]}
if steps[sid].type == 'XorGateway':
@@ -940,6 +1005,9 @@ def build_bpmn(meta_md, steps, order, externals):
shape = ET.SubElement(plane, q('bpmndi', 'BPMNShape'), attrs)
x, y, w, h = bounds_of(sid)
shape.append(_bounds_el(x, y, w, h))
if SHAPE_KIND.get(steps[sid].type) == 'diamond' and steps[sid].label:
gateway_shapes[sid] = shape # Beschriftung erst nach den Kanten, siehe unten
hindernisse.append((x, y, w, h))
# Anschlusspunkte je (Element, Seite) zählen, damit mehrere Kanten an derselben
# Seite nicht alle im selben Punkt zusammenlaufen (Modellierregel 4).
@@ -1048,18 +1116,24 @@ def build_bpmn(meta_md, steps, order, externals):
for px, py in points:
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
strecken.extend(zip(points, points[1:]))
if label:
# explizite Label-Position setzen, um Überlappungen mit Pfeilen/Nachbarlabels
# zu reduzieren: am ersten Segment, leicht seitlich versetzt
(p0x, p0y), (p1x, p1y) = points[0], points[1] if len(points) > 1 else points[0]
mx, my = (p0x + p1x) / 2, (p0y + p1y) / 2
if abs(p0y - p1y) < 1e-6: # horizontales Segment -> Label darüber
if abs(p0y - p1y) < 1e-6 and SHAPE_KIND.get(steps[src].type) == 'diamond':
# waagrecht aus einem Gateway -> Label darunter; darüber steht
# die Beschriftung des Gateways (#14)
lx, ly = mx - 15, my + 6
elif abs(p0y - p1y) < 1e-6: # horizontales Segment -> Label darüber
lx, ly = mx - 15, my - 20
else: # vertikales Segment -> Label seitlich
lx, ly = mx + 8, my - 8
bpmn_label = ET.SubElement(edge, q('bpmndi', 'BPMNLabel'))
bpmn_label.append(_bounds_el(lx, ly, max(30, len(label) * 6), 14))
hindernisse.append((lx, ly, max(30, len(label) * 6), 14))
for mfid, sid, direction, target in msg_flow_records:
edge = ET.SubElement(plane, q('bpmndi', 'BPMNEdge'), {'id': f'{mfid}_di', 'bpmnElement': mfid})
@@ -1084,6 +1158,28 @@ def build_bpmn(meta_md, steps, order, externals):
points = points[::-1]
for px, py in points:
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
strecken.extend(zip(points, points[1:]))
# Gateway-Beschriftungen zuletzt, wenn alle Kanten und Kantenbeschriftungen
# feststehen: oben rechts (45°) wie im PICTURE-Editor, sonst die nächste freie
# Stelle. Ohne Angabe stünde sie unter der Raute (180°), wo der südliche Pfad
# und dessen Beschriftung sie schneiden; PICTURE übernimmt die Position (#14).
for sid, shape in gateway_shapes.items():
x, y, w, h = bounds_of(sid)
lw = GATEWAY_LABEL_BREITE
lh = text_zeilen(steps[sid].label, lw) * GATEWAY_LABEL_ZEILE
# 45°; 45° rechts vom Ost-Stub (dort steigt oft die eigene Kante auf);
# 315°; 135°; 225°
ecken = [(x + w * 3 / 4, y + h / 4 - lh), (x + w + GATEWAY_STUB + 5, y + h / 4 - lh),
(x + w / 4 - lw, y + h / 4 - lh), (x + w * 3 / 4, y + h * 3 / 4),
(x + w / 4 - lw, y + h * 3 / 4)]
eigene = (x, y, w, h)
frei = [(lx, ly) for lx, ly in ecken
if not any(_strecke_schneidet(a, b, (lx, ly, lw, lh)) for a, b in strecken)
and not any(_rechtecke_ueberlappen((lx, ly, lw, lh), r) for r in hindernisse if r != eigene)]
lx, ly = frei[0] if frei else ecken[0]
ET.SubElement(shape, q('bpmndi', 'BPMNLabel')).append(_bounds_el(lx, ly, lw, lh))
hindernisse.append((lx, ly, lw, lh))
return definitions
+34
View File
@@ -621,6 +621,40 @@ def test_nachrichtenfluss_quert_kein_element(text):
assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"{mfid} durch {eid}"
def test_textschaetzung_bricht_an_wortgrenzen_und_bei_ueberlangen_woertern_um():
"""Prüft die Zeilenschätzung für Beschriftungen: kurzer Text eine Zeile, Umbruch an Wortgrenzen, ein Wort länger
als die Zeile wird hart umbrochen (Issue #14)."""
je_zeile = int((100 - 10) // m.ZEICHENBREITE)
assert m.text_zeilen("Ja", 100) == 1
assert m.text_zeilen("a" * je_zeile + " b", 100) == 2
assert m.text_zeilen("x" * (2 * je_zeile + 1), 100) == 3
def test_gateway_beschriftung_steht_ohne_hindernis_oben_rechts():
"""Prüft, dass ein Gateway ohne Hindernis seine Beschriftung auf 45° (oben rechts) bekommt, wie im PICTURE-Editor,
und nicht darunter, wo der südliche Pfad sie schneiden würde (Issue #14)."""
text = """
S1 Start: Beginn
-> S2
S2 Gateway XOR: Antrag vollständig?
Ja -> S3
S3 Ende: Fertig
"""
defs, bounds, _ = _baue_und_finde(text)
shape = next(s for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape") if s.get('bpmnElement') == 'Gateway_S2')
b = shape.find(f"{{{m.NS['bpmndi']}}}BPMNLabel").find(f"{{{m.NS['dc']}}}Bounds")
lx, ly, lh = float(b.get('x')), float(b.get('y')), float(b.get('height'))
x, y, w, h = bounds['Gateway_S2']
assert lx == x + w * 3 / 4 and ly + lh == y + h / 4
def test_anmerkung_waechst_mit_ihrem_text():
"""Prüft, dass eine lange Anmerkung ein höheres Kästchen bekommt als eine kurze (Issue #14)."""
kurz_w, kurz_h = m.anmerkung_groesse("Kurz")
lang_w, lang_h = m.anmerkung_groesse("Ein langer Hinweis " * 20)
assert kurz_w == lang_w and lang_h > kurz_h
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'
+4
View File
@@ -31,6 +31,10 @@ Skills sind übernommen, alles QGIS- und Plugin-Spezifische ist entfernt. Kern i
## Erledigt
- 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.
- 25.09.2026 **Nachrichtenflüsse senkrecht und an allen Elementen vorbei** (#13): Ende senkrecht unter dem
Start, bei Hindernis über einen freien Gang. Damit gibt es in den Beispielen keine bekannte Kreuzung mehr
(`BEKANNTE_KREUZUNGEN` leer).
+10
View File
@@ -5,6 +5,16 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py)
- **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
Ost-Stub, 315°, 135°, 225° – die erste Stelle, die keine Kante, Kantenbeschriftung oder kein Element schneidet.
Dafür werden die Gateway-Beschriftungen **nach** allen Kanten gesetzt. Zu kleine Anmerkungs-Kästchen vergrößert
PICTURE beim Import, dann überlappen sie Nachbarn und Lane-Grenzen. Deshalb richtet sich die Höhe nach dem Text
(`text_zeilen()`, grobe Schätzung 7 je Zeichen), und die Anmerkungen stehen rechts neben dem Pool.
`GATEWAY_LABEL_BREITE = 90` hängt an `AUSWEICHABSTAND` (#8): Bei 100 ragte die Beschriftung über die
Ausweichachse bei x + 130. Pool-Name über dem Namen der ersten Lane: nur in der Vorschau beobachtet, in PICTURE
kein Problem. (PICTURE 2026.4.1, #14, 25.09.2026)
- **Nachrichtenflüsse enden senkrecht unter ihrem Start, nicht an einem verteilten Punkt.** Der externe Pool ist
ein breites Rechteck, jeder Punkt an seiner Oberkante ist ein gültiger Anschluss. Die frühere Verteilung der
Anschlusspunkte über die Pool-Oberkante erzeugte schräge Linien quer durch das Diagramm. Steht unter dem Schritt
+115
View File
@@ -121,6 +121,121 @@ def test_bekannte_kreuzung_ist_behoben(modell, kante, element):
assert (kante, element) not in kreuzungen(baue(SKILL / "beispiele" / f"{modell}.md"))
def _box(el):
b = el.find(f"{{{m.NS['dc']}}}Bounds")
return tuple(float(b.get(a)) for a in ("x", "y", "width", "height"))
def _ueberlappen(a, b, rand=0.5):
"""True, wenn sich die Rechtecke a und b (x, y, w, h) echt überschneiden (Berührung zählt nicht)."""
return (a[0] + rand < b[0] + b[2] and b[0] + rand < a[0] + a[2]
and a[1] + rand < b[1] + b[3] and b[1] + rand < a[1] + a[3])
def beschriftungskonflikte(defs):
"""Alle Konflikte einer Gateway-Beschriftung mit Kanten, Kantenbeschriftungen oder anderen Elementen."""
art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
gw_labels, formen = {}, {}
for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
eid = shape.get("bpmnElement")
if art.get(eid) in HINDERNISSE or art.get(eid) == "textAnnotation":
formen[eid] = _box(shape)
label = shape.find(f"{{{m.NS['bpmndi']}}}BPMNLabel")
if art.get(eid) in GATEWAYS and label is not None:
gw_labels[eid] = _box(label)
konflikte = []
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")]
label = edge.find(f"{{{m.NS['bpmndi']}}}BPMNLabel")
for gid, (x, y, w, h) in gw_labels.items():
if label is not None and _ueberlappen(_box(label), (x, y, w, h)):
konflikte.append((gid, f"Beschriftung von {kante}"))
for (x1, y1), (x2, y2) in zip(punkte, punkte[1:]):
schritte = max(8, int(math.hypot(x2 - x1, y2 - y1) / 4))
if any(x < x1 + (x2 - x1) * s / schritte < x + w and y < y1 + (y2 - y1) * s / schritte < y + h
for s in range(1, schritte)):
konflikte.append((gid, kante))
break
for gid, box in gw_labels.items():
konflikte += [(gid, eid) for eid, form in formen.items() if eid != gid and _ueberlappen(box, form)]
return sorted(konflikte)
@pytest.mark.parametrize("md", MODELLE, ids=lambda p: p.stem)
def test_gateway_beschriftung_wird_von_nichts_geschnitten(md):
"""Prüft, dass jede Gateway-Beschriftung eine feste Position hat und weder von einer Kante noch von einer
Kantenbeschriftung oder einem anderen Element geschnitten wird (Issue #14; PICTURE übernimmt die Position)."""
defs = baue(md)
art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
ohne = [s.get("bpmnElement") for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")
if art.get(s.get("bpmnElement")) in GATEWAYS and s.find(f"{{{m.NS['bpmndi']}}}BPMNLabel") is None
and next(el for el in defs.iter() if el.get("id") == s.get("bpmnElement")).get("name")]
assert ohne == [], "Gateway mit Namen, aber ohne Beschriftungsposition: " + ", ".join(ohne)
konflikte = beschriftungskonflikte(defs)
assert konflikte == [], "Gateway-Beschriftung geschnitten: " + ", ".join(f"{g} ← {k}" for g, k in konflikte)
@pytest.mark.parametrize("md", [md for md in MODELLE if md.stem != "beispiel_prozess"], ids=lambda p: p.stem)
def test_gateway_beschriftung_bleibt_in_der_eigenen_lane(md):
"""Prüft, dass jede Gateway-Beschriftung vollständig in der Lane ihres Gateways liegt und nicht in den Lane-Kopf
mit den Lane-Namen oder in eine Nachbar-Lane ragt (Issue #14)."""
defs = baue(md)
lanes = [_box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape") if s.get("bpmnElement").startswith("Lane_")]
assert lanes, "Modell ohne Lanes"
for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
label = shape.find(f"{{{m.NS['bpmndi']}}}BPMNLabel")
if label is None:
continue
x, y, w, h = _box(shape)
lx, ly, lw, lh = _box(label)
lane = next(b for b in lanes if b[1] <= y + h / 2 <= b[1] + b[3])
assert lane[1] <= ly and ly + lh <= lane[1] + lane[3], f"{shape.get('bpmnElement')}: ragt aus der Lane"
assert lx >= lane[0] + m.LANE_HEADER_WIDTH, f"{shape.get('bpmnElement')}: ragt in den Lane-Kopf"
MIT_ANMERKUNGEN = [md for md in MODELLE if " Hinweis:" in md.read_text(encoding="utf-8")]
def test_es_gibt_ein_modell_mit_anmerkungen():
"""Prüft, dass die Anmerkungs-Tests tatsächlich an einem Modell laufen."""
assert MIT_ANMERKUNGEN
@pytest.mark.parametrize("md", MIT_ANMERKUNGEN, ids=lambda p: p.stem)
def test_anmerkungen_ueberlappen_nichts_und_stehen_neben_dem_pool(md):
"""Prüft, dass Anmerkungen (Hinweise) sich nicht gegenseitig überlappen, kein anderes Element überdecken und –
wenn es einen Prozess-Pool gibt – rechts daneben stehen, also keine Lane-Grenze kreuzen (Issue #14)."""
defs = baue(md)
art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
formen = {s.get("bpmnElement"): _box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")}
anmerkungen = {eid: box for eid, box in formen.items() if art.get(eid) == "textAnnotation"}
ids = sorted(anmerkungen)
for i, a in enumerate(ids):
for b in ids[i + 1:]:
assert not _ueberlappen(anmerkungen[a], anmerkungen[b]), f"{a} überlappt {b}"
for eid, form in formen.items():
if art.get(eid) in HINDERNISSE:
assert not _ueberlappen(anmerkungen[a], form), f"{a} überdeckt {eid}"
if "Participant_1" in formen:
px, _, pw, _ = formen["Participant_1"]
for eid, (x, _, _, _) in anmerkungen.items():
assert x >= px + pw, f"{eid} steht im Pool (x {x:g} < {px + pw:g})"
@pytest.mark.parametrize("md", MIT_ANMERKUNGEN, ids=lambda p: p.stem)
def test_anmerkung_ist_gross_genug_fuer_ihren_text(md):
"""Prüft, dass jedes Anmerkungs-Kästchen seinen Text fassen kann (grob: 7 Einheiten je Zeichen, 16 je Zeile).
Sonst vergrößert PICTURE das Kästchen beim Import, und es überlappt Nachbarn und Lane-Grenzen (Issue #14)."""
defs = baue(md)
formen = {s.get("bpmnElement"): _box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")}
for anm in defs.iter(f"{{{m.NS['bpmn']}}}textAnnotation"):
text = anm.find(f"{{{m.NS['bpmn']}}}text").text or ""
_, _, w, h = formen[anm.get("id")]
zeilen = math.ceil(len(text) * 7 / (w - 10))
assert h >= zeilen * 16, f"{anm.get('id')}: {len(text)} Zeichen brauchen etwa {zeilen} Zeilen, Höhe nur {h:g}"
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")