openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1275 @@
1
+ """Qt graphics items that draw the net on the canvas.
2
+
3
+ Coordinate systems
4
+ ------------------
5
+ CPN Tools files place the origin at the centre of the page with **y increasing
6
+ upwards**. Qt's scene has y increasing *downwards*. The whole GUI therefore
7
+ uses one rule, applied in exactly two functions here (:func:`to_scene` /
8
+ :func:`to_model`): flip the sign of y at the boundary. Everywhere else in the
9
+ GUI, coordinates are scene coordinates.
10
+
11
+ Item structure
12
+ --------------
13
+ Each net element is one top-level item with its labels as *child* items, so
14
+ moving a place moves its annotations with it for free. Labels are plain text
15
+ items rather than editable ones: editing happens in the properties panel, which
16
+ keeps this module free of input handling.
17
+
18
+ Where the annotations go, and why
19
+ ---------------------------------
20
+ A place carries up to four pieces of text, and none of them may collide --
21
+ neither with each other nor with the annotations of the *next* place along::
22
+
23
+ ③ 1`ph(1)++1`ph(3) <- current marking, centred ABOVE
24
+ ( Think ) PH <- colour set, to the RIGHT
25
+ 1`ph(1)++1`ph(2)… <- initial marking, centred BELOW
26
+
27
+ The three long annotations are stacked vertically and each is **centred on the
28
+ place**, which is what keeps neighbouring nodes apart: a label capped at ~130 px
29
+ and centred extends only ~65 px either side, so two places 250 units apart can
30
+ never overprint one another. Putting the live marking and the declared initial
31
+ marking on the same horizontal line -- the obvious first arrangement -- fails
32
+ badly here, because those two strings are usually similar in length and content
33
+ and end up sitting side by side across the whole diagram.
34
+
35
+ Long annotations are shortened with an ellipsis and carry the full text in a
36
+ tooltip; nothing is lost, because the complete marking is always listed in the
37
+ simulation panel.
38
+
39
+ Arc inscriptions sit at **35 % along the arc**, not at the midpoint. Two arcs
40
+ running between the same neighbourhood in opposite directions have their
41
+ midpoints in nearly the same place, so midpoint labels overlap; measuring from
42
+ each arc's own source separates them.
43
+
44
+ Colours come from :mod:`openprocess.gui.theme`, so everything here follows the system
45
+ light/dark appearance.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import math
51
+ from typing import TYPE_CHECKING
52
+
53
+ from PySide6.QtCore import QPointF, QRectF, Qt
54
+ from PySide6.QtGui import (
55
+ QBrush, QColor, QFont, QFontMetricsF, QPainter, QPainterPath,
56
+ QPainterPathStroker, QPen, QPolygonF,
57
+ )
58
+ from PySide6.QtWidgets import (
59
+ QGraphicsItem, QGraphicsPathItem, QGraphicsSimpleTextItem, QStyle,
60
+ )
61
+
62
+ from . import theme
63
+
64
+ if TYPE_CHECKING: # pragma: no cover - typing only
65
+ from ..model.net import Arc, Place, Transition
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Coordinate conversion
70
+ # ---------------------------------------------------------------------------
71
+ def to_scene(x: float, y: float) -> QPointF:
72
+ """Model coordinates -> scene coordinates (flip y)."""
73
+ return QPointF(x, -y)
74
+
75
+
76
+ def to_model(point: QPointF) -> tuple[float, float]:
77
+ """Scene coordinates -> model coordinates (flip y back)."""
78
+ return point.x(), -point.y()
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Shared helpers
83
+ # ---------------------------------------------------------------------------
84
+ #: Longest annotation we will draw before shortening it, in scene units.
85
+ #: A marking such as ``1`ph(1)++1`ph(2)++1`ph(3)`` is wider than the gap
86
+ #: between two nodes, so drawn in full it runs straight over its neighbour.
87
+ #: The full text is always available in the tooltip and in the simulation
88
+ #: panel, so shortening on the canvas loses nothing.
89
+ MAX_ANNOTATION_WIDTH = 132.0
90
+ #: Inscriptions placed by the modeller (positions from a ``.cpn`` file) were
91
+ #: laid out for their full text, so they may be much longer before shortening.
92
+ MAX_AUTHORED_WIDTH = 340.0
93
+
94
+
95
+ class _Annotation(QGraphicsSimpleTextItem):
96
+ """Text on the canvas, optionally on a soft backdrop of the canvas colour.
97
+
98
+ Inscriptions often sit on top of arcs or grid dots; a translucent
99
+ backdrop keeps them legible without boxing them in.
100
+ """
101
+
102
+ PAD = 2.0
103
+
104
+ def __init__(self, parent: QGraphicsItem, backdrop: bool) -> None:
105
+ super().__init__("", parent)
106
+ self.backdrop = backdrop
107
+ #: Called with the label's new centre (parent coordinates) after the
108
+ #: user dragged it; ``None`` = the label cannot be dragged.
109
+ self.on_dragged = None
110
+ self._drag_from: QPointF | None = None
111
+
112
+ # -- dragging (edit mode only) ------------------------------------------------
113
+ def _draggable(self) -> bool:
114
+ scene = self.scene()
115
+ return (self.on_dragged is not None and bool(self.text())
116
+ and getattr(scene, "editable", False) and getattr(scene, "tool", "") == "select")
117
+
118
+ def mousePressEvent(self, event) -> None: # noqa: N802
119
+ if event.button() != Qt.LeftButton or not self._draggable():
120
+ event.ignore() # let the node underneath handle it
121
+ return
122
+ event.accept()
123
+ owner = self.parentItem()
124
+ if owner is not None and owner.flags() & QGraphicsItem.ItemIsSelectable \
125
+ and not owner.isSelected():
126
+ if not event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier):
127
+ self.scene().clearSelection()
128
+ owner.setSelected(True)
129
+ self._drag_from = event.scenePos() - self.pos()
130
+
131
+ def mouseMoveEvent(self, event) -> None: # noqa: N802
132
+ if self._drag_from is None:
133
+ return
134
+ self.setPos(event.scenePos() - self._drag_from)
135
+
136
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
137
+ if self._drag_from is None:
138
+ return
139
+ self._drag_from = None
140
+ centre = self.pos() + super().boundingRect().center()
141
+ self.on_dragged(centre)
142
+
143
+ def hoverEnterEvent(self, event) -> None: # noqa: N802
144
+ self.setCursor(Qt.OpenHandCursor if self._draggable() else Qt.ArrowCursor)
145
+
146
+ def boundingRect(self) -> QRectF: # noqa: N802
147
+ rect = super().boundingRect()
148
+ return rect.adjusted(-self.PAD, -1, self.PAD, 1) if self.backdrop else rect
149
+
150
+ def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
151
+ if self.backdrop and self.text():
152
+ colour = QColor(theme.palette().canvas)
153
+ colour.setAlphaF(0.86)
154
+ painter.setPen(Qt.NoPen)
155
+ painter.setBrush(colour)
156
+ painter.drawRoundedRect(self.boundingRect(), 3, 3)
157
+ super().paint(painter, option, widget)
158
+
159
+
160
+ class _MarkingBox(QGraphicsSimpleTextItem):
161
+ """The current marking of a place, in a pale green box with a shadow --
162
+ how CPN Tools and CPN IDE show it next to the token count."""
163
+
164
+ PAD_X, PAD_Y = 5.0, 2.0
165
+
166
+ def boundingRect(self) -> QRectF: # noqa: N802
167
+ return super().boundingRect().adjusted(-self.PAD_X, -self.PAD_Y,
168
+ self.PAD_X + 1.5, self.PAD_Y + 1.5)
169
+
170
+ def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
171
+ box = super().boundingRect().adjusted(-self.PAD_X, -self.PAD_Y,
172
+ self.PAD_X, self.PAD_Y)
173
+ dark = theme.is_dark()
174
+ painter.setPen(Qt.NoPen)
175
+ painter.setBrush(QColor(0, 0, 0, 60))
176
+ painter.drawRoundedRect(box.translated(1.5, 1.5), 3, 3)
177
+ painter.setBrush(QColor("#1F4D24" if dark else "#C9F5C4"))
178
+ painter.setPen(QPen(QColor("#3C8C44" if dark else "#7CCB76"), 0.8))
179
+ painter.drawRoundedRect(box, 3, 3)
180
+ super().paint(painter, option, widget)
181
+
182
+
183
+ class _CountBubble(QGraphicsPathItem):
184
+ """The green token count. Clicking it shows or hides the marking box,
185
+ as in CPN Tools and CPN IDE."""
186
+
187
+ def __init__(self, parent: "PlaceItem") -> None:
188
+ super().__init__(parent)
189
+ self.setCursor(Qt.PointingHandCursor)
190
+ self.setAcceptedMouseButtons(Qt.LeftButton)
191
+
192
+ def mousePressEvent(self, event) -> None: # noqa: N802
193
+ event.accept() # not a drag of the place
194
+ self.parentItem().toggle_marking()
195
+
196
+
197
+ def _label(parent: QGraphicsItem, colour: QColor, font: QFont,
198
+ backdrop: bool = True) -> QGraphicsSimpleTextItem:
199
+ item = _Annotation(parent, backdrop)
200
+ item.setBrush(QBrush(colour))
201
+ item.setFont(font)
202
+ return item
203
+
204
+
205
+ def _draggable_label(item: _Annotation, node: "_NodeItem", key: str) -> None:
206
+ """Let the user drag a node's inscription; its spot is kept in the model
207
+ (as an offset from the node's centre, in model units -- the ``.cpn`` way)."""
208
+ def dragged(centre: QPointF) -> None:
209
+ offsets = node.graphics().label_offsets
210
+ offsets[key] = (centre.x(), -centre.y())
211
+ node.refresh()
212
+
213
+ item.on_dragged = dragged
214
+ item.setAcceptHoverEvents(True)
215
+
216
+
217
+ #: Sizes of plain-net nodes (scene units): places are circles, transitions
218
+ #: squares that grow to fit their name, silent transitions black bars.
219
+ PLAIN_PLACE = 40.0
220
+ PLAIN_TRANSITION = 40.0
221
+ PLAIN_SILENT = (14.0, 40.0)
222
+
223
+
224
+ def _paint_tokens(painter: QPainter, count: int, rect: QRectF) -> None:
225
+ """Black tokens inside a place, arranged like dice up to five; a number
226
+ beyond that -- how the course's figures draw markings."""
227
+ painter.save()
228
+ painter.setRenderHint(QPainter.Antialiasing)
229
+ colour = theme.palette().text
230
+ if count > 5:
231
+ painter.setPen(colour)
232
+ font = theme.ui_font(12, QFont.Bold)
233
+ painter.setFont(font)
234
+ painter.drawText(rect, Qt.AlignCenter, str(count))
235
+ painter.restore()
236
+ return
237
+ r = min(rect.width(), rect.height()) / 2
238
+ d = r * 0.36 # distance from the centre
239
+ spots = {1: [(0, 0)], 2: [(-d, 0), (d, 0)], 3: [(-d, d * 0.6), (d, d * 0.6), (0, -d * 0.8)],
240
+ 4: [(-d, -d), (d, -d), (-d, d), (d, d)],
241
+ 5: [(-d, -d), (d, -d), (0, 0), (-d, d), (d, d)]}[count]
242
+ dot = r * (0.2 if count > 1 else 0.24)
243
+ painter.setPen(Qt.NoPen)
244
+ painter.setBrush(colour)
245
+ centre = rect.center()
246
+ for x, y in spots:
247
+ painter.drawEllipse(QPointF(centre.x() + x, centre.y() + y), dot, dot)
248
+ painter.restore()
249
+
250
+
251
+ def _paint_token_row(painter: QPainter, count: int, centre: QPointF) -> None:
252
+ """Tokens as a small row of dots under a name (a number beyond five)."""
253
+ painter.save()
254
+ painter.setRenderHint(QPainter.Antialiasing)
255
+ colour = theme.palette().text
256
+ if count > 5:
257
+ painter.setPen(colour)
258
+ painter.setFont(theme.ui_font(9, QFont.Bold))
259
+ painter.drawText(QRectF(centre.x() - 20, centre.y() - 7, 40, 14), Qt.AlignCenter,
260
+ f"{count} ●")
261
+ else:
262
+ painter.setPen(Qt.NoPen)
263
+ painter.setBrush(colour)
264
+ gap = 6.5
265
+ start = centre.x() - gap * (count - 1) / 2
266
+ for index in range(count):
267
+ painter.drawEllipse(QPointF(start + gap * index, centre.y()), 2.6, 2.6)
268
+ painter.restore()
269
+
270
+
271
+ def _rect_of(item: "_NodeItem") -> tuple[float, float, float, float]:
272
+ """A node's outline rectangle in scene coordinates (left, top, width, height)."""
273
+ rect = item.rect().translated(item.pos())
274
+ return rect.left(), rect.top(), rect.width(), rect.height()
275
+
276
+
277
+ def _place_at_offset(item: QGraphicsSimpleTextItem, offsets: dict, key: str) -> bool:
278
+ """Centre ``item`` where the model file put it (an offset in model units).
279
+
280
+ Returns False when the file gave no position, so the caller can fall back
281
+ to the automatic placement.
282
+ """
283
+ offset = offsets.get(key)
284
+ if offset is None:
285
+ return False
286
+ bounds = item.boundingRect()
287
+ # Model y points up, scene y points down.
288
+ item.setPos(offset[0] - bounds.width() / 2, -offset[1] - bounds.height() / 2)
289
+ return True
290
+
291
+
292
+ def _set_elided(item: QGraphicsSimpleTextItem, text: str,
293
+ max_width: float = MAX_ANNOTATION_WIDTH) -> None:
294
+ """Set an annotation, shortening it with an ellipsis if it is too wide.
295
+
296
+ The untruncated string goes into the tooltip, so hovering always shows the
297
+ real inscription.
298
+ """
299
+ metrics = QFontMetricsF(item.font())
300
+ if metrics.horizontalAdvance(text) > max_width:
301
+ item.setText(metrics.elidedText(text, Qt.ElideRight, max_width))
302
+ item.setToolTip(text)
303
+ else:
304
+ item.setText(text)
305
+ item.setToolTip("")
306
+
307
+
308
+ class _NodeItem(QGraphicsPathItem):
309
+ """Shared behaviour for places and transitions.
310
+
311
+ Both are rounded shapes with a name inside, both are movable, and both
312
+ write their position back to the model as they move. Factoring that out
313
+ keeps the two concrete classes to just their own drawing and labels.
314
+
315
+ Selection is drawn by us, not by Qt: the default is a dashed marching-ants
316
+ rectangle, which looks dated and obscures the annotations. We strip the
317
+ selected state before delegating to the base paint and draw an accent ring
318
+ instead.
319
+ """
320
+
321
+ def __init__(self, rect: QRectF) -> None:
322
+ super().__init__()
323
+ self._rect = rect
324
+ #: Draw a soft accent halo (the transition that fired last).
325
+ self.halo = False
326
+ #: Step numbers at which this transition fired (the trace badge).
327
+ self.trace_steps: list[int] = []
328
+ #: How recent its last firing is, 1 (just now) .. 0 (not in the trace).
329
+ self.trace = 0.0
330
+ self.setFlags(
331
+ QGraphicsItem.ItemIsMovable
332
+ | QGraphicsItem.ItemIsSelectable
333
+ | QGraphicsItem.ItemSendsGeometryChanges
334
+ )
335
+ self.setZValue(10)
336
+
337
+ def rect(self) -> QRectF:
338
+ """The node's bounding shape, used by :class:`ArcItem` for clipping."""
339
+ return self._rect
340
+
341
+ def _build_path(self) -> QPainterPath:
342
+ """The outline for the current :attr:`_rect`. Supplied by subclasses."""
343
+ raise NotImplementedError
344
+
345
+ def _grow_to_fit(self, content_width: float, padding: float) -> bool:
346
+ """Widen the node so its name fits, and report whether it changed.
347
+
348
+ A node narrower than its own label looks broken, and CPN Tools sizes
349
+ nodes to their names too. The new width is written back to the model,
350
+ so it survives a save and the diagram opens the same way next time.
351
+ """
352
+ needed = content_width + padding
353
+ if needed <= self._rect.width() + 0.5:
354
+ return False
355
+ height = self._rect.height()
356
+ self._rect = QRectF(-needed / 2, -height / 2, needed, height)
357
+ self.setPath(self._build_path())
358
+ return True
359
+
360
+ def boundingRect(self) -> QRectF: # noqa: N802
361
+ # Room for the halo and the selection ring outside the outline, and
362
+ # for the trace badge above the top-right corner.
363
+ extra = (24, 90) if self.trace_steps else (6, 6)
364
+ return super().boundingRect().adjusted(-6, -extra[0], extra[1], 6)
365
+
366
+ def set_halo(self, on: bool) -> None:
367
+ if on != self.halo:
368
+ self.halo = on
369
+ self.update()
370
+
371
+ def set_trace(self, steps: list[int], strength: float) -> None:
372
+ """Mark the node as part of the step-through trace (see NetScene.set_trace)."""
373
+ if steps == self.trace_steps and abs(strength - self.trace) < 1e-9:
374
+ return
375
+ self.prepareGeometryChange()
376
+ self.trace_steps, self.trace = list(steps), strength
377
+ self.update()
378
+
379
+ def _paint_trace_badge(self, painter: QPainter) -> None:
380
+ """The step numbers, in a small accent pill above the top-right corner."""
381
+ steps = self.trace_steps
382
+ text = ("… " if len(steps) > 3 else "") + ", ".join(str(s) for s in steps[-3:])
383
+ font = theme.ui_font(9, QFont.DemiBold)
384
+ painter.setFont(font)
385
+ metrics = QFontMetricsF(font)
386
+ width, height = metrics.horizontalAdvance(text) + 10, metrics.height() + 2
387
+ corner = self._rect.topRight()
388
+ pill = QRectF(corner.x() - width * 0.35, corner.y() - height - 3, width, height)
389
+ colour = QColor(theme.palette().accent)
390
+ colour.setAlphaF(0.45 + 0.55 * self.trace)
391
+ painter.setPen(Qt.NoPen)
392
+ painter.setBrush(colour)
393
+ painter.drawRoundedRect(pill, height / 2, height / 2)
394
+ painter.setPen(QColor("#ffffff"))
395
+ painter.drawText(pill, Qt.AlignCenter, text)
396
+
397
+ def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
398
+ selected = bool(option.state & QStyle.State_Selected)
399
+ option.state &= ~QStyle.State_Selected
400
+ if self.halo:
401
+ colour = QColor(theme.palette().accent)
402
+ colour.setAlphaF(0.30)
403
+ painter.setPen(QPen(colour, 8.0))
404
+ painter.setBrush(Qt.NoBrush)
405
+ painter.drawPath(self.path())
406
+ super().paint(painter, option, widget)
407
+ if selected:
408
+ palette = theme.palette()
409
+ pen = QPen(palette.accent, 2.0)
410
+ pen.setCosmetic(True)
411
+ painter.setPen(pen)
412
+ painter.setBrush(Qt.NoBrush)
413
+ painter.drawPath(self.path())
414
+ if self.trace_steps:
415
+ self._paint_trace_badge(painter)
416
+
417
+ def graphics(self):
418
+ """The model's graphics record for this node."""
419
+ raise NotImplementedError
420
+
421
+ def name_anchor(self) -> tuple[QPointF, float]:
422
+ """Where the name is shown (scene point) and how wide an editor for
423
+ it should be at least: over the node when the name is inside it,
424
+ over the label when it is outside."""
425
+ label = getattr(self, "name_label", None)
426
+ if getattr(self, "names_outside", False) and label is not None and label.isVisible():
427
+ return label.sceneBoundingRect().center(), 60.0
428
+ return self.scenePos(), max(60.0, self._rect.width())
429
+
430
+ def refresh(self) -> None:
431
+ raise NotImplementedError
432
+
433
+ def _notify_arcs(self) -> None:
434
+ """Ask every arc touching this node to recompute its route."""
435
+ scene = self.scene()
436
+ if scene is None:
437
+ return
438
+ identifier = getattr(self, "model_id", None)
439
+ for item in scene.items():
440
+ if isinstance(item, ArcItem) and item.touches(identifier):
441
+ item.reroute()
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Places
446
+ # ---------------------------------------------------------------------------
447
+ class PlaceItem(_NodeItem):
448
+ """An ellipse with its name inside and four annotations around it."""
449
+
450
+ def __init__(self, place: "Place", plain: bool = False,
451
+ names_outside: bool = False) -> None:
452
+ #: Drawn as in the course's plain Petri nets: a circle with black
453
+ #: tokens inside.
454
+ self.plain = plain
455
+ #: The name under the node (draggable) rather than inside it.
456
+ self.names_outside = names_outside
457
+ if plain:
458
+ width = height = PLAIN_PLACE
459
+ else:
460
+ width = place.graphics.width or 76.0
461
+ height = place.graphics.height or 48.0
462
+ super().__init__(QRectF(-width / 2, -height / 2, width, height))
463
+ self.place = place
464
+ self.model_id = place.id
465
+ self.tokens = 0
466
+
467
+ self.setPath(self._build_path())
468
+
469
+ palette = theme.palette()
470
+ self.setBrush(QBrush(palette.surface))
471
+ self.setPen(QPen(palette.border_strong, 1.6))
472
+
473
+ self.name_label = _label(self, palette.text, theme.ui_font(12, QFont.DemiBold), False)
474
+ type_font = theme.ui_font(10)
475
+ type_font.setItalic(True)
476
+ self.type_label = _label(self, palette.text_muted, type_font)
477
+ self.initial_label = _label(self, palette.inscription, theme.mono_font(10))
478
+ _draggable_label(self.type_label, self, "type")
479
+ _draggable_label(self.initial_label, self, "initmark")
480
+ if names_outside:
481
+ self.name_label.backdrop = True
482
+ _draggable_label(self.name_label, self, "name")
483
+
484
+ # The live marking, as in CPN Tools: a green token count at the
485
+ # place's right-hand edge, and next to it the tokens themselves in a
486
+ # pale green box that clicking the count shows or hides.
487
+ self.count_pill = _CountBubble(self)
488
+ self.count_pill.setPen(QPen(Qt.NoPen))
489
+ self.count_pill.setBrush(QBrush(palette.success))
490
+ self.count_label = _label(self.count_pill, QColor("#FFFFFF"),
491
+ theme.ui_font(10, QFont.Bold), False)
492
+ self.marking_label = _MarkingBox(self)
493
+ self.marking_label.setFont(theme.mono_font(10))
494
+ self.marking_label.setBrush(QBrush(QColor("#D7F5D7" if theme.is_dark() else "#113311")))
495
+ self.marking_label.setZValue(2)
496
+ self._marking_state = (0, "", None)
497
+ self.set_marking(0, "")
498
+
499
+ self.setPos(to_scene(place.graphics.x, place.graphics.y))
500
+ self.refresh()
501
+
502
+ # -- presentation --------------------------------------------------------
503
+ def graphics(self):
504
+ return self.place.graphics
505
+
506
+ def _build_path(self) -> QPainterPath:
507
+ path = QPainterPath()
508
+ path.addEllipse(self._rect)
509
+ return path
510
+
511
+ def refresh(self) -> None:
512
+ """Re-read the model object and lay the annotations out."""
513
+ self.name_label.setText(self.place.name)
514
+ bounds = self.name_label.boundingRect()
515
+ if self.names_outside:
516
+ # The name under the node (or where the user dragged it).
517
+ if not _place_at_offset(self.name_label, self.place.graphics.label_offsets, "name"):
518
+ self.name_label.setPos(-bounds.width() / 2, self._rect.bottom() + 1)
519
+ elif self.plain:
520
+ # A circle just big enough for the name.
521
+ diameter = max(PLAIN_PLACE, bounds.width() + 16)
522
+ if abs(diameter - self._rect.width()) > 0.5:
523
+ self.prepareGeometryChange()
524
+ self._rect = QRectF(-diameter / 2, -diameter / 2, diameter, diameter)
525
+ self.setPath(self._build_path())
526
+ self._layout_inside_name()
527
+ else:
528
+ # An ellipse only offers about 70 % of its width at the text's
529
+ # height, so the name needs proportionally more room here than in
530
+ # a rectangle.
531
+ if self._grow_to_fit(bounds.width() / 0.70, 12.0):
532
+ self.place.graphics.width = self._rect.width()
533
+ self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2)
534
+ if self.plain:
535
+ self.type_label.hide()
536
+ self.initial_label.hide()
537
+ return
538
+ rect = self._rect
539
+
540
+ # Inscriptions go where the model file put them; otherwise at CPN
541
+ # Tools' default spots: colour set below right, initial marking above
542
+ # right.
543
+ offsets = self.place.graphics.label_offsets
544
+ self.type_label.setText(self.place.colour_set_name)
545
+ if not _place_at_offset(self.type_label, offsets, "type"):
546
+ self.type_label.setPos(rect.right() - 4, rect.bottom() - 4)
547
+
548
+ _set_elided(self.initial_label, self.place.initial_marking_text,
549
+ MAX_AUTHORED_WIDTH if "initmark" in offsets else MAX_ANNOTATION_WIDTH)
550
+ if not _place_at_offset(self.initial_label, offsets, "initmark"):
551
+ initial_bounds = self.initial_label.boundingRect()
552
+ self.initial_label.setPos(rect.right() - 4, rect.top() - initial_bounds.height() + 4)
553
+
554
+ def set_marking(self, count: int, text: str, show_values: bool | None = None) -> None:
555
+ """Show the simulator's current contents at the place.
556
+
557
+ ``count`` goes in the green bubble at the place's right-hand edge;
558
+ ``text`` (the multiset) in the box beside it, which is shown or
559
+ hidden per place -- the model file's own setting at first, then by
560
+ clicking the bubble -- unless ``show_values`` forces all on or off.
561
+ The tooltip always has the full marking. An empty place shows
562
+ nothing.
563
+ """
564
+ self._marking_state = (count, text, show_values)
565
+ if self.plain:
566
+ self.tokens = count
567
+ self.count_pill.hide()
568
+ self.marking_label.hide()
569
+ self.setToolTip(f"{self.place.name}: {count} token(s)")
570
+ if not self.names_outside:
571
+ self._layout_inside_name()
572
+ self.update()
573
+ return
574
+ visible = count > 0
575
+ self.count_pill.setVisible(visible)
576
+ if show_values is None:
577
+ show_values = not self.place.graphics.marking_hidden
578
+ self.marking_label.setVisible(visible and show_values)
579
+ tip = "" if not visible else f"{self.place.name}: {count} token(s)\n" + (
580
+ text if len(text) < 1500 else text[:1500] + " …")
581
+ self.setToolTip(tip)
582
+ self.count_pill.setToolTip(tip + ("\n\nClick to show or hide the tokens" if tip else ""))
583
+ if not visible:
584
+ return
585
+
586
+ self.count_label.setText(str(count))
587
+ label_bounds = self.count_label.boundingRect()
588
+ pill_height = 16.0
589
+ pill_width = max(pill_height, label_bounds.width() + 9)
590
+ pill = QPainterPath()
591
+ pill.addRoundedRect(QRectF(0, 0, pill_width, pill_height),
592
+ pill_height / 2, pill_height / 2)
593
+ self.count_pill.setPath(pill)
594
+ self.count_label.setPos((pill_width - label_bounds.width()) / 2,
595
+ (pill_height - label_bounds.height()) / 2)
596
+
597
+ # CPN Tools' convention (as CPN IDE reads it): the count's corner is
598
+ # at the place's right edge, level with its centre, moved by the
599
+ # file's "token" offset; the marking box is to the right of the count,
600
+ # moved by the "marking" offset. Offsets from a file that would put
601
+ # them far away are ignored.
602
+ tx, ty = self.place.graphics.label_offsets.get("token", (0.0, 0.0))
603
+ if abs(tx) > 60 or abs(ty) > 60:
604
+ tx = ty = 0.0
605
+ left, top = self._rect.right() + tx - 4, -ty - pill_height / 2
606
+ self.count_pill.setPos(left, top)
607
+
608
+ _set_elided(self.marking_label, text, 260.0)
609
+ mx, my = self.place.graphics.label_offsets.get("marking", (0.0, 0.0))
610
+ if abs(mx) > 120 or abs(my) > 120:
611
+ mx = my = 0.0
612
+ box_left = max(left + pill_width + 6, left + pill_width + 6 + mx)
613
+ text_bounds = self.marking_label.boundingRect()
614
+ self.marking_label.setPos(box_left + _MarkingBox.PAD_X,
615
+ top - my + (pill_height - text_bounds.height()) / 2
616
+ + _MarkingBox.PAD_Y)
617
+
618
+ def _name_inside_with_tokens(self) -> bool:
619
+ return self.plain and not self.names_outside and bool(self.place.name) and \
620
+ self.tokens > 0
621
+
622
+ def _layout_inside_name(self) -> None:
623
+ """Centre the name in the circle; with tokens, the name moves up and
624
+ the tokens go in a row underneath it."""
625
+ bounds = self.name_label.boundingRect()
626
+ lift = 6.0 if self._name_inside_with_tokens() else 0.0
627
+ self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2 - lift)
628
+
629
+ def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
630
+ super().paint(painter, option, widget)
631
+ if not (self.plain and self.tokens):
632
+ return
633
+ if self._name_inside_with_tokens():
634
+ _paint_token_row(painter, self.tokens, QPointF(0, 9.0))
635
+ else:
636
+ _paint_tokens(painter, self.tokens, self._rect)
637
+
638
+ def toggle_marking(self) -> None:
639
+ """Clicking the count: show or hide this place's tokens."""
640
+ self.place.graphics.marking_hidden = self.marking_label.isVisible()
641
+ count, text, _forced = self._marking_state
642
+ self.set_marking(count, text, None)
643
+
644
+ def set_error(self, has_error: bool) -> None:
645
+ palette = theme.palette()
646
+ self.setPen(QPen(palette.danger if has_error else palette.border_strong, 1.6))
647
+
648
+ def itemChange(self, change, value): # noqa: N802 - Qt naming
649
+ if change == QGraphicsItem.ItemPositionHasChanged:
650
+ self.place.graphics.x, self.place.graphics.y = to_model(self.pos())
651
+ self._notify_arcs()
652
+ return super().itemChange(change, value)
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # Transitions
657
+ # ---------------------------------------------------------------------------
658
+ class TransitionItem(_NodeItem):
659
+ """A rounded rectangle with name, guard and time labels, plus the enabling cue."""
660
+
661
+ def __init__(self, transition: "Transition", plain: bool = False,
662
+ names_outside: bool = False) -> None:
663
+ #: Plain nets: a square with the name inside; a silent (τ) transition
664
+ #: is a black bar, as in the course's figures.
665
+ self.plain = plain
666
+ #: The name under the box (draggable) rather than inside it.
667
+ self.names_outside = names_outside
668
+ self.silent = plain and getattr(transition, "silent", False)
669
+ if self.silent:
670
+ width, height = PLAIN_SILENT
671
+ elif plain:
672
+ width = height = PLAIN_TRANSITION
673
+ else:
674
+ width = transition.graphics.width or 82.0
675
+ height = transition.graphics.height or 48.0
676
+ super().__init__(QRectF(-width / 2, -height / 2, width, height))
677
+ self.transition = transition
678
+ self.model_id = transition.id
679
+ self.enabled_count = 0
680
+ self._has_error = False
681
+
682
+ self.setPath(self._build_path())
683
+
684
+ palette = theme.palette()
685
+ self.name_label = _label(self, palette.text, theme.ui_font(12, QFont.DemiBold), False)
686
+ self.guard_label = _label(self, palette.inscription, theme.mono_font(10))
687
+ self.time_label = _label(self, palette.inscription, theme.mono_font(10))
688
+ _draggable_label(self.guard_label, self, "cond")
689
+ _draggable_label(self.time_label, self, "time")
690
+ if names_outside:
691
+ self.name_label.backdrop = True
692
+ _draggable_label(self.name_label, self, "name")
693
+
694
+ # The binding count sits in its own pill, mirroring the place's marking
695
+ # pill so the two read as the same kind of information.
696
+ self.count_pill = QGraphicsPathItem(self)
697
+ self.count_pill.setPen(QPen(Qt.NoPen))
698
+ self.count_pill.setBrush(QBrush(palette.success))
699
+ self.count_label = _label(self.count_pill, QColor("#FFFFFF"),
700
+ theme.ui_font(10, QFont.Bold), False)
701
+ self.count_pill.setVisible(False)
702
+
703
+ self.setPos(to_scene(transition.graphics.x, transition.graphics.y))
704
+ self.refresh()
705
+ self.set_enabled_count(0)
706
+
707
+ def graphics(self):
708
+ return self.transition.graphics
709
+
710
+ def _build_path(self) -> QPainterPath:
711
+ path = QPainterPath()
712
+ radius = 2.0 if getattr(self, "plain", False) else theme.RADIUS
713
+ path.addRoundedRect(self._rect, radius, radius)
714
+ return path
715
+
716
+ def refresh(self) -> None:
717
+ palette = theme.palette()
718
+
719
+ self.name_label.setText(self.transition.name)
720
+ bounds = self.name_label.boundingRect()
721
+ if self.names_outside:
722
+ if not _place_at_offset(self.name_label, self.transition.graphics.label_offsets,
723
+ "name"):
724
+ self.name_label.setPos(-bounds.width() / 2, self._rect.bottom() + 1)
725
+ if self.plain:
726
+ self.guard_label.hide()
727
+ self.time_label.hide()
728
+ self.name_label.setVisible(not self.silent)
729
+ self.setToolTip(f"τ (silent){': ' + self.transition.name if self.transition.name else ''}"
730
+ if self.silent else "")
731
+ if not self.silent and not self.names_outside:
732
+ self._grow_to_fit(bounds.width(), 14.0)
733
+ self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2)
734
+ return
735
+ if not self.names_outside:
736
+ if self._grow_to_fit(bounds.width(), 26.0):
737
+ self.transition.graphics.width = self._rect.width()
738
+ self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2)
739
+ rect = self._rect
740
+
741
+ # Guards are conventionally shown in square brackets, to the left.
742
+ offsets = self.transition.graphics.label_offsets
743
+ guard = self.transition.guard_text.strip()
744
+ if guard and not guard.startswith("["):
745
+ guard = f"[{guard}]"
746
+ _set_elided(self.guard_label, guard, MAX_AUTHORED_WIDTH if "cond" in offsets else 200)
747
+ if not _place_at_offset(self.guard_label, offsets, "cond"):
748
+ # CPN Tools' default spot: above, to the left.
749
+ bounds = self.guard_label.boundingRect()
750
+ self.guard_label.setPos(rect.left() - 5 - bounds.width() + 8,
751
+ rect.top() - bounds.height() + 2)
752
+
753
+ # The file may store "@+5" or just "5"; show it once as "@+5".
754
+ delay = self.transition.time_text.strip()
755
+ if delay.startswith("@+"):
756
+ delay = delay[2:].strip()
757
+ _set_elided(self.time_label, f"@+{delay}" if delay else "",
758
+ MAX_AUTHORED_WIDTH if "time" in offsets else 200)
759
+ if not _place_at_offset(self.time_label, offsets, "time"):
760
+ # Above, to the right.
761
+ bounds = self.time_label.boundingRect()
762
+ self.time_label.setPos(rect.right() - 3, rect.top() - bounds.height() + 2)
763
+
764
+ def set_enabled_count(self, count: int) -> None:
765
+ """Colour the transition according to how many bindings are enabled."""
766
+ self.enabled_count = count
767
+ palette = theme.palette()
768
+
769
+ if self.silent:
770
+ self.setBrush(QBrush(palette.text))
771
+ self.setPen(QPen(palette.success if count else palette.text, 2.4 if count else 1.2))
772
+ elif self._has_error:
773
+ self.setBrush(QBrush(palette.surface))
774
+ self.setPen(QPen(palette.danger, 1.8))
775
+ elif count:
776
+ self.setBrush(QBrush(palette.success_soft))
777
+ self.setPen(QPen(palette.success, 2.2))
778
+ else:
779
+ self.setBrush(QBrush(palette.surface))
780
+ # A substitution transition gets a heavier border, as in CPN Tools,
781
+ # because it stands for a whole subpage rather than an event.
782
+ self.setPen(QPen(palette.border_strong,
783
+ 3.0 if self.transition.is_substitution else 1.6))
784
+
785
+ # Only show the pill when there is a genuine choice to make.
786
+ self.count_pill.setVisible(count > 1 and not self.plain)
787
+ if count > 1 and not self.plain:
788
+ self.count_label.setText(str(count))
789
+ bounds = self.count_label.boundingRect()
790
+ width = max(18.0, bounds.width() + 10)
791
+ height = 16.0
792
+ pill = QPainterPath()
793
+ pill.addRoundedRect(QRectF(0, 0, width, height), height / 2, height / 2)
794
+ self.count_pill.setPath(pill)
795
+ self.count_label.setPos((width - bounds.width()) / 2,
796
+ (height - bounds.height()) / 2)
797
+ self.count_pill.setPos(self._rect.right() - width / 2,
798
+ self._rect.bottom() - height / 2 - 1)
799
+
800
+ def set_error(self, has_error: bool) -> None:
801
+ self._has_error = has_error
802
+ self.set_enabled_count(self.enabled_count)
803
+
804
+ def itemChange(self, change, value): # noqa: N802 - Qt naming
805
+ if change == QGraphicsItem.ItemPositionHasChanged:
806
+ self.transition.graphics.x, self.transition.graphics.y = to_model(self.pos())
807
+ self._notify_arcs()
808
+ return super().itemChange(change, value)
809
+
810
+
811
+ # ---------------------------------------------------------------------------
812
+ # Arcs
813
+ # ---------------------------------------------------------------------------
814
+ class ArcItem(QGraphicsPathItem):
815
+ """A directed arc with an arrowhead and an inscription label.
816
+
817
+ Three routing features, each fixing a way that a naive straight line
818
+ between two centres goes wrong:
819
+
820
+ **Endpoint clipping.** The line stops at the boundary of each shape, so
821
+ the arrowhead lands on the edge of the transition rather than at its centre.
822
+
823
+ **Bendpoints.** ``.cpn`` files store polyline bendpoints, and real models
824
+ use them heavily to route around nodes. Ignoring them would make an
825
+ imported model look nothing like it does in CPN Tools, so the path follows
826
+ them when present.
827
+
828
+ **Parallel arcs.** Two arcs between the *same* place and transition -- a
829
+ consume-and-return pair, say -- would otherwise be drawn exactly on top of
830
+ each other. When the scene reports that an arc has siblings, it is
831
+ shifted sideways (``bow``) so both are visible, as in CPN IDE.
832
+
833
+ Editing (CPN IDE's rules, see :mod:`openprocess.gui.arc_editing`) is driven by
834
+ the scene; this item draws the handles: a small circle on every waypoint
835
+ while the arc is hovered or selected, one more following the mouse along
836
+ the line, and a bar in the middle of each horizontal or vertical segment.
837
+ """
838
+
839
+ #: Trace highlight strength, 0 (off) .. 1 (the latest step).
840
+ trace = 0.0
841
+
842
+ ARROW_LENGTH = 10.0
843
+ ARROW_HALF_WIDTH = 4.2
844
+ #: Where along the arc the inscription sits. Not 0.5: see the module
845
+ #: docstring -- opposing arcs would collide at the midpoint.
846
+ LABEL_POSITION = 0.35
847
+ #: Handle radius on screen, in pixels (diagram-js: 4).
848
+ HANDLE_RADIUS = 4.0
849
+
850
+ def __init__(self, arc: "Arc", place_item: PlaceItem,
851
+ transition_item: TransitionItem, bow: float = 0.0,
852
+ plain: bool = False) -> None:
853
+ super().__init__()
854
+ self.plain = plain
855
+ self.arc = arc
856
+ self.place_item = place_item
857
+ self.transition_item = transition_item
858
+ self.bow = bow
859
+
860
+ palette = theme.palette()
861
+ self.setPen(QPen(palette.border_strong, 1.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
862
+ self.setBrush(QBrush(palette.border_strong))
863
+ self.setZValue(5)
864
+ self.setFlag(QGraphicsItem.ItemIsSelectable, True)
865
+ self.setAcceptHoverEvents(True)
866
+
867
+ self.inscription_label = _label(self, palette.inscription, theme.mono_font(10))
868
+ self.inscription_label.on_dragged = self._inscription_dragged
869
+ self.inscription_label.setAcceptHoverEvents(True)
870
+ #: Where the modeller put the inscription, relative to the midpoint
871
+ #: of the two nodes (so it follows them when they move).
872
+ self._label_offset: QPointF | None = None
873
+ absolute = arc.graphics.label_offsets.get("annot")
874
+ if absolute is not None and arc.graphics.label_offsets:
875
+ self._label_offset = to_scene(*absolute) - self._node_midpoint()
876
+ #: While nodes are dragged: the arc as it was when the drag began
877
+ #: (transition position, place position, bends, both end points).
878
+ self.drag_origin: tuple | None = None
879
+ #: While one end is dragged to another node: which end and where.
880
+ self.loose_end: tuple[str, QPointF] | None = None
881
+ #: Hover state for the handles.
882
+ self.hovered = False
883
+ self.floating: QPointF | None = None
884
+ #: True while the scene drags part of this arc (handles hidden).
885
+ self.editing = False
886
+ self._waypoints: list[QPointF] = []
887
+ self.reroute()
888
+
889
+ def _node_midpoint(self) -> QPointF:
890
+ return (self.place_item.pos() + self.transition_item.pos()) / 2
891
+
892
+ def touches(self, element_id: str | None) -> bool:
893
+ return element_id is not None and element_id in (
894
+ self.arc.place_id, self.arc.transition_id
895
+ )
896
+
897
+ def waypoints(self) -> list[QPointF]:
898
+ """The drawn route, from the transition end to the place end: where
899
+ it leaves one node, the bends, where it meets the other."""
900
+ return list(self._waypoints)
901
+
902
+ # -- geometry ------------------------------------------------------------
903
+ #: A bendpoint within this distance (horizontally or vertically) of a
904
+ #: node's centre makes the arc leave the node straight across or straight
905
+ #: down -- CPN Tools' tidy right-angled arcs.
906
+ SNAP = 20.0
907
+
908
+ def reroute(self) -> None:
909
+ """Recompute the path, the arrowhead(s) and the label position.
910
+
911
+ The rules are those of CPN Tools and CPN IDE:
912
+
913
+ * an input arc runs place -> transition, every other one (output
914
+ and double-headed) transition -> place;
915
+ * bendpoints are stored from the transition end to the place end,
916
+ and are joined by straight segments;
917
+ * a first or last bendpoint roughly level with (or above) its node
918
+ pulls the end of the arc level with it, so it leaves the node at a
919
+ right angle instead of heading for the exact centre;
920
+ * arcs between the same place and transition without bendpoints are
921
+ drawn side by side as parallel lines.
922
+ """
923
+ self._repair_after_node_move()
924
+ stored = [to_scene(x, y) for x, y in self.arc.bendpoints] # transition -> place
925
+ loose = self.loose_end
926
+ if loose is not None:
927
+ # One end follows the mouse (reconnecting): no cropping there.
928
+ which, point = loose
929
+ if which == "place":
930
+ towards = stored[0] if stored else point
931
+ t_end = self._crop(self.transition_item,
932
+ self._anchor(self.transition_item, towards), towards)
933
+ p_end = point
934
+ else:
935
+ towards = stored[-1] if stored else point
936
+ p_end = self._crop(self.place_item,
937
+ self._anchor(self.place_item, towards), towards)
938
+ t_end = point
939
+ elif stored:
940
+ t_end = self._crop(self.transition_item,
941
+ self._anchor(self.transition_item, stored[0]), stored[0])
942
+ p_end = self._crop(self.place_item,
943
+ self._anchor(self.place_item, stored[-1]), stored[-1])
944
+ else:
945
+ # Parallel arcs: shift both ends sideways by the same amount.
946
+ # The side is measured in one fixed direction (place ->
947
+ # transition), so an input and an output arc between the same
948
+ # nodes go to opposite sides instead of on top of each other.
949
+ chord = self.transition_item.pos() - self.place_item.pos()
950
+ length = math.hypot(chord.x(), chord.y()) or 1.0
951
+ shift = QPointF(-chord.y() / length, chord.x() / length) * self.bow
952
+ a = self.transition_item.pos() + shift
953
+ b = self.place_item.pos() + shift
954
+ t_end = self._crop(self.transition_item, a, b)
955
+ p_end = self._crop(self.place_item, b, a)
956
+ self._waypoints = [t_end, *stored, p_end]
957
+
958
+ route = self._waypoints if not (self.arc.is_input and not self.arc.is_output) \
959
+ else self._waypoints[::-1]
960
+ path = QPainterPath(route[0])
961
+ for point in route[1:]:
962
+ path.lineTo(point)
963
+
964
+ # The line is stroked and the arrowheads are filled, so they live in
965
+ # separate paths. (One combined, filled path would also fill the area
966
+ # enclosed by a bent line -- the "black triangles" bug.)
967
+ self.prepareGeometryChange()
968
+ self.line_path = QPainterPath(path)
969
+ arrows = QPainterPath()
970
+ self._append_arrowhead(arrows, self._tangent_source(path, 0.97), route[-1])
971
+ if self.arc.orientation == "BOTHDIR":
972
+ self._append_arrowhead(arrows, self._tangent_source(path, 0.03), route[0])
973
+ self.arrow_path = arrows
974
+ combined = QPainterPath(path)
975
+ combined.addPath(arrows)
976
+ self.setPath(combined)
977
+
978
+ self._place_label(path)
979
+ self.update()
980
+
981
+ def _repair_after_node_move(self) -> None:
982
+ """While nodes are dragged: CPN IDE's rule for the bends.
983
+
984
+ Moving both ends moves the whole arc. Moving one end leaves the
985
+ bends where they are, except that the bend next to the moved node
986
+ stays level with (or in line with) the arc's end if it was, and
987
+ bends the node now covers are dropped
988
+ (:func:`~openprocess.gui.arc_editing.repair_after_move`).
989
+ """
990
+ if self.drag_origin is None:
991
+ return
992
+ t0, p0, bends, t_end, p_end = self.drag_origin
993
+ dt = self.transition_item.pos() - t0
994
+ dp = self.place_item.pos() - p0
995
+ t_moved, p_moved = dt.manhattanLength() > 1e-6, dp.manhattanLength() > 1e-6
996
+ if not bends or not (t_moved or p_moved):
997
+ return
998
+ if t_moved and p_moved:
999
+ self.arc.bendpoints = [to_model(QPointF(x, y) + dt) for x, y in bends]
1000
+ return
1001
+ from .arc_editing import repair_after_move
1002
+ t_rect, p_rect = _rect_of(self.transition_item), _rect_of(self.place_item)
1003
+ if t_moved:
1004
+ points = [t_end, *bends, p_end]
1005
+ moved_end = (t_end[0] + dt.x(), t_end[1] + dt.y())
1006
+ result = repair_after_move(points, moved_end, t_rect, p_rect)
1007
+ else:
1008
+ points = [p_end, *bends[::-1], t_end]
1009
+ moved_end = (p_end[0] + dp.x(), p_end[1] + dp.y())
1010
+ result = repair_after_move(points, moved_end, p_rect, t_rect)[::-1]
1011
+ self.arc.bendpoints = [to_model(QPointF(*p)) for p in result[1:-1]]
1012
+
1013
+ def start_node_drag(self) -> None:
1014
+ """Remember the arc before its nodes are dragged (see above)."""
1015
+ ends = self._waypoints
1016
+ self.drag_origin = (self.transition_item.pos(), self.place_item.pos(),
1017
+ [(p.x(), p.y()) for p in (to_scene(x, y)
1018
+ for x, y in self.arc.bendpoints)],
1019
+ (ends[0].x(), ends[0].y()), (ends[-1].x(), ends[-1].y()))
1020
+
1021
+ def _inscription_dragged(self, centre: QPointF) -> None:
1022
+ self._label_offset = centre - self._node_midpoint()
1023
+ self.reroute()
1024
+
1025
+ # -- handles ---------------------------------------------------------------
1026
+ def handles_visible(self) -> bool:
1027
+ scene = self.scene()
1028
+ return (not self.editing and (self.isSelected() or self.hovered)
1029
+ and getattr(scene, "editable", False)
1030
+ and getattr(scene, "tool", "") == "select")
1031
+
1032
+ def hoverEnterEvent(self, event) -> None: # noqa: N802
1033
+ self.hovered = True
1034
+ self.hoverMoveEvent(event)
1035
+
1036
+ def hoverMoveEvent(self, event) -> None: # noqa: N802
1037
+ from .arc_editing import closest_on_segment, distance
1038
+ p = (event.scenePos().x(), event.scenePos().y())
1039
+ points = [(w.x(), w.y()) for w in self._waypoints]
1040
+ feet = [closest_on_segment(p, a, b) for a, b in zip(points, points[1:])]
1041
+ foot = min(feet, key=lambda f: distance(f, p)) if feet else None
1042
+ self.floating = QPointF(*foot) if foot else None
1043
+ self.update()
1044
+
1045
+ def hoverLeaveEvent(self, event) -> None: # noqa: N802
1046
+ self.hovered = False
1047
+ self.floating = None
1048
+ self.update()
1049
+
1050
+ def itemChange(self, change, value): # noqa: N802 - Qt naming
1051
+ if change == QGraphicsItem.ItemSelectedHasChanged:
1052
+ self.update()
1053
+ return super().itemChange(change, value)
1054
+
1055
+ @staticmethod
1056
+ def _tangent_source(path: QPainterPath, percent: float) -> QPointF:
1057
+ """A point just short of an end, giving the direction to point the head."""
1058
+ return path.pointAtPercent(max(0.0, min(1.0, percent)))
1059
+
1060
+ def _place_label(self, path: QPainterPath) -> None:
1061
+ """Put the inscription a third of the way along, clear of the line."""
1062
+ if self.plain:
1063
+ # Plain nets: only a weight above 1 is written, as a number.
1064
+ from ..model.plain import weight_of
1065
+ weight = weight_of(self.arc)
1066
+ self.inscription_label.setText(str(weight) if weight > 1 else "")
1067
+ if weight <= 1:
1068
+ return
1069
+ if self._label_offset is None:
1070
+ point = path.pointAtPercent(0.5)
1071
+ angle = math.radians(path.angleAtPercent(0.5))
1072
+ normal = QPointF(math.sin(angle), math.cos(angle)) * 9.0
1073
+ bounds = self.inscription_label.boundingRect()
1074
+ if normal.y() > 0:
1075
+ normal = -normal
1076
+ self.inscription_label.setPos(point.x() + normal.x() - bounds.width() / 2,
1077
+ point.y() + normal.y() - bounds.height() / 2)
1078
+ return
1079
+ centre = self._node_midpoint() + self._label_offset
1080
+ bounds = self.inscription_label.boundingRect()
1081
+ self.inscription_label.setPos(centre.x() - bounds.width() / 2,
1082
+ centre.y() - bounds.height() / 2)
1083
+ self.arc.graphics.label_offsets["annot"] = to_model(centre)
1084
+ return
1085
+ _set_elided(self.inscription_label, self.arc.expression_text,
1086
+ MAX_AUTHORED_WIDTH if self._label_offset is not None
1087
+ else MAX_ANNOTATION_WIDTH)
1088
+ if not self.arc.expression_text:
1089
+ return
1090
+ if self._label_offset is not None:
1091
+ centre = self._node_midpoint() + self._label_offset
1092
+ bounds = self.inscription_label.boundingRect()
1093
+ self.inscription_label.setPos(centre.x() - bounds.width() / 2,
1094
+ centre.y() - bounds.height() / 2)
1095
+ # Keep the model in step, so saving writes where it is drawn.
1096
+ self.arc.graphics.label_offsets["annot"] = to_model(centre)
1097
+ return
1098
+
1099
+ anchor = path.pointAtPercent(self.LABEL_POSITION)
1100
+ angle = math.radians(path.angleAtPercent(self.LABEL_POSITION))
1101
+ # Qt reports the angle counter-clockwise from the x axis in a
1102
+ # y-down scene, hence the negated sine.
1103
+ tangent = QPointF(math.cos(angle), -math.sin(angle))
1104
+ normal = QPointF(-tangent.y(), tangent.x())
1105
+
1106
+ bounds = self.inscription_label.boundingRect()
1107
+ if abs(self.bow) > 0.01 and not self.arc.bendpoints:
1108
+ # One of a pair of parallel arcs: write on its own side, so the
1109
+ # two inscriptions of a pair end up on opposite sides.
1110
+ chord = self.transition_item.pos() - self.place_item.pos()
1111
+ length = math.hypot(chord.x(), chord.y()) or 1.0
1112
+ side = QPointF(-chord.y() / length, chord.x() / length) * math.copysign(1, self.bow)
1113
+ # Far enough that the text box clears the line, whatever the angle.
1114
+ reach = 6.0 + abs(side.x()) * bounds.width() / 2 + abs(side.y()) * bounds.height() / 2
1115
+ offset = side * reach
1116
+ else:
1117
+ # Offset to whichever side of the line points "up" on screen, so
1118
+ # the text never sits on top of the arc.
1119
+ offset = normal * (12.0 if normal.y() < 0 else -12.0)
1120
+ self.inscription_label.setPos(
1121
+ anchor.x() + offset.x() - bounds.width() / 2,
1122
+ anchor.y() + offset.y() - bounds.height() / 2,
1123
+ )
1124
+
1125
+ def _anchor(self, item: _NodeItem, towards: QPointF) -> QPointF:
1126
+ """Where inside ``item`` the arc starts: its centre, moved level with
1127
+ (or in line with) the neighbouring bendpoint when that is close, as
1128
+ long as the point stays inside the node."""
1129
+ centre = item.pos()
1130
+ rect = item.rect()
1131
+ x, y = centre.x(), centre.y()
1132
+ if abs(towards.y() - y) < self.SNAP and abs(towards.y() - y) < rect.height() / 2 - 3:
1133
+ y = towards.y()
1134
+ if abs(towards.x() - x) < self.SNAP and abs(towards.x() - x) < rect.width() / 2 - 3:
1135
+ x = towards.x()
1136
+ return QPointF(x, y)
1137
+
1138
+ @staticmethod
1139
+ def _inside(item: _NodeItem, point: QPointF) -> bool:
1140
+ local = point - item.pos()
1141
+ rect = item.rect()
1142
+ a, b = rect.width() / 2, rect.height() / 2
1143
+ if isinstance(item, PlaceItem):
1144
+ return (local.x() / a) ** 2 + (local.y() / b) ** 2 <= 1.0
1145
+ return abs(local.x()) <= a and abs(local.y()) <= b
1146
+
1147
+ def _crop(self, item: _NodeItem, inside: QPointF, outside: QPointF) -> QPointF:
1148
+ """The point where the segment ``inside`` -> ``outside`` leaves the node,
1149
+ plus two pixels of air so the arrowhead does not touch the outline."""
1150
+ if not self._inside(item, inside):
1151
+ inside = item.pos()
1152
+ if self._inside(item, outside):
1153
+ return outside # overlapping nodes: nothing to crop
1154
+ low, high = 0.0, 1.0
1155
+ for _ in range(24): # bisection: exact to well under a pixel
1156
+ middle = (low + high) / 2
1157
+ if self._inside(item, inside + (outside - inside) * middle):
1158
+ low = middle
1159
+ else:
1160
+ high = middle
1161
+ direction = outside - inside
1162
+ length = math.hypot(direction.x(), direction.y()) or 1.0
1163
+ return inside + direction * high + direction * (2.0 / length)
1164
+
1165
+ def _append_arrowhead(self, path: QPainterPath, tail: QPointF, tip: QPointF) -> None:
1166
+ """Add a filled triangle at ``tip``, pointing away from ``tail``."""
1167
+ direction = tip - tail
1168
+ length = math.hypot(direction.x(), direction.y())
1169
+ if length < 1e-6:
1170
+ return
1171
+ unit = QPointF(direction.x() / length, direction.y() / length)
1172
+ normal = QPointF(-unit.y(), unit.x())
1173
+
1174
+ base = tip - unit * self.ARROW_LENGTH
1175
+ path.addPolygon(
1176
+ QPolygonF([
1177
+ tip,
1178
+ base + normal * self.ARROW_HALF_WIDTH,
1179
+ base - normal * self.ARROW_HALF_WIDTH,
1180
+ tip,
1181
+ ])
1182
+ )
1183
+
1184
+ def shape(self) -> QPainterPath:
1185
+ """Widen the clickable region (a 1.5 px line is very hard to hit), but
1186
+ only around the line and the arrowheads, not the area a bent arc
1187
+ encloses. With its handles showing, the circles around the
1188
+ waypoints count too, so the ends can be grabbed."""
1189
+ stroker = QPainterPathStroker()
1190
+ stroker.setWidth(14.0) # diagram-js: a 15 px hit line
1191
+ shape = stroker.createStroke(self.line_path).united(self.arrow_path)
1192
+ if self.handles_visible():
1193
+ for point in self._waypoints:
1194
+ shape.addEllipse(point, 10.0, 10.0)
1195
+ return shape
1196
+
1197
+ def boundingRect(self) -> QRectF: # noqa: N802
1198
+ # Room for the handles, which are drawn a fixed size on screen.
1199
+ return super().boundingRect().adjusted(-24, -24, 24, 24)
1200
+
1201
+ def _paint_handles(self, painter: QPainter) -> None:
1202
+ """CPN IDE's handles: a circle on every waypoint, one following the
1203
+ mouse along the line, a bar across each straight segment's middle."""
1204
+ from .arc_editing import aligned
1205
+ scale = painter.worldTransform().m11() or 1.0
1206
+ radius = self.HANDLE_RADIUS / scale
1207
+ accent = theme.palette().accent
1208
+ fill = QColor(accent)
1209
+ fill.setAlphaF(0.25)
1210
+ outline = QPen(accent, 1.2 / scale)
1211
+ points = self._waypoints
1212
+ painter.setPen(outline)
1213
+ painter.setBrush(fill)
1214
+ for a, b in zip(points, points[1:]):
1215
+ direction = aligned((a.x(), a.y()), (b.x(), b.y()))
1216
+ if direction is None:
1217
+ continue
1218
+ middle = (a + b) / 2
1219
+ long_side, short_side = 22.0 / scale, 6.0 / scale
1220
+ # A bar across the segment, pointing the way it can be slid.
1221
+ if direction == "vertical":
1222
+ width, height = long_side, short_side
1223
+ else:
1224
+ width, height = short_side, long_side
1225
+ painter.drawRoundedRect(QRectF(middle.x() - width / 2, middle.y() - height / 2,
1226
+ width, height), 2 / scale, 2 / scale)
1227
+ painter.setBrush(QColor(theme.palette().canvas))
1228
+ for point in points:
1229
+ painter.drawEllipse(point, radius, radius)
1230
+ if self.hovered and self.floating is not None:
1231
+ painter.setBrush(fill)
1232
+ painter.drawEllipse(self.floating, radius, radius)
1233
+
1234
+ def set_trace(self, strength: float) -> None:
1235
+ """Underlay the arc in the accent colour: tokens travelled along it
1236
+ in the trace, ``strength`` 1 for the latest step fading to 0."""
1237
+ if abs(strength - self.trace) > 1e-9:
1238
+ self.trace = strength
1239
+ self.update()
1240
+
1241
+ def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
1242
+ painter.setRenderHint(QPainter.Antialiasing)
1243
+ trace = self.trace
1244
+ if trace > 0:
1245
+ glow = QColor(theme.palette().accent)
1246
+ glow.setAlphaF(0.15 + 0.45 * trace)
1247
+ painter.setPen(QPen(glow, 3.0 + 4.0 * trace, Qt.SolidLine, Qt.RoundCap,
1248
+ Qt.RoundJoin))
1249
+ painter.setBrush(Qt.NoBrush)
1250
+ painter.drawPath(self.line_path)
1251
+ selected = bool(option.state & QStyle.State_Selected)
1252
+ if selected:
1253
+ colour = theme.palette().accent
1254
+ pen = QPen(colour, 2.4, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
1255
+ else:
1256
+ pen = QPen(self.pen())
1257
+ colour = pen.color()
1258
+ painter.setPen(pen)
1259
+ painter.setBrush(Qt.NoBrush) # never fill the line itself
1260
+ painter.drawPath(self.line_path)
1261
+ painter.setPen(Qt.NoPen)
1262
+ painter.setBrush(QBrush(colour))
1263
+ painter.drawPath(self.arrow_path)
1264
+ if self.handles_visible():
1265
+ self._paint_handles(painter)
1266
+
1267
+ def set_error(self, has_error: bool) -> None:
1268
+ """Draw the arc in red when its inscription failed to compile."""
1269
+ palette = theme.palette()
1270
+ colour = palette.danger if has_error else palette.border_strong
1271
+ self.setPen(QPen(colour, 1.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
1272
+ self.setBrush(QBrush(colour))
1273
+ self.inscription_label.setBrush(
1274
+ QBrush(palette.danger if has_error else palette.inscription)
1275
+ )