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
openprocess/gui/app.py ADDED
@@ -0,0 +1,90 @@
1
+ """The ``openprocess-cpn`` entry point: the CPN editor on its own.
2
+
3
+ The editor itself is :class:`openprocess.gui.studio.cpn_page.CpnPage`, the same page
4
+ OpenProcess Studio shows for a ``.cpn`` file. ``openprocess-cpn`` simply opens Studio with
5
+ that model (or a new, empty one), so there is one application with one look,
6
+ whether you start from a CPN model or from an event log.
7
+
8
+ :class:`MainWindow` is kept for scripts and tests that want a bare window
9
+ around a single model, without Studio's sidebar.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from PySide6.QtGui import QKeySequence
18
+ from PySide6.QtWidgets import QApplication, QMainWindow
19
+
20
+ from ..model.net import CPNet
21
+ from . import theme
22
+
23
+ APPLICATION_NAME = "OpenProcess"
24
+
25
+
26
+ def _empty_model(name: str = "Untitled") -> CPNet:
27
+ net = CPNet(name)
28
+ net.add_declaration("colset UNIT = unit;")
29
+ net.add_declaration("colset INT = int;")
30
+ net.add_page("Top")
31
+ net.compile()
32
+ return net
33
+
34
+
35
+ class MainWindow(QMainWindow):
36
+ """A window holding one CPN page (no sidebar)."""
37
+
38
+ def __init__(self, net: CPNet | None = None, path: Path | None = None) -> None:
39
+ super().__init__()
40
+ from .studio import style
41
+ from .studio.cpn_page import CpnPage
42
+ from .studio.documents import CpnDocument
43
+ self.document = CpnDocument(net if net is not None else _empty_model(),
44
+ path=str(path) if path else None)
45
+ self.page = CpnPage(self.document)
46
+ self.page.setObjectName("studioRoot")
47
+ self.setCentralWidget(self.page)
48
+ self.setStyleSheet(style.stylesheet())
49
+ self.page.status.connect(lambda message: self.statusBar().showMessage(message, 8000))
50
+ self.page.dirty_changed.connect(lambda _dirty: self._update_title())
51
+ self.page.saved.connect(self._update_title)
52
+ menu = self.menuBar().addMenu("&File")
53
+ menu.addAction("Save", QKeySequence.Save, self.page.save)
54
+ menu.addAction("Save As…", QKeySequence.SaveAs, self.page.export)
55
+ view = self.menuBar().addMenu("&View")
56
+ view.addAction("Zoom In", QKeySequence.ZoomIn, self.page.view.zoom_in)
57
+ view.addAction("Zoom Out", QKeySequence.ZoomOut, self.page.view.zoom_out)
58
+ view.addAction("Zoom to Fit", "Ctrl+0", self.page.view.zoom_to_fit)
59
+ self.resize(1440, 900)
60
+ self._update_title()
61
+
62
+ # Convenience accessors used by scripts.
63
+ @property
64
+ def net(self) -> CPNet:
65
+ return self.document.net
66
+
67
+ @property
68
+ def view(self):
69
+ return self.page.view
70
+
71
+ @property
72
+ def scene(self):
73
+ return self.page.scene
74
+
75
+ def _update_title(self) -> None:
76
+ name = Path(self.document.path).name if self.document.path else self.net.name
77
+ self.setWindowTitle(f"{name}{' •' if self.document.dirty else ''} — {APPLICATION_NAME}")
78
+
79
+
80
+ def main(argv: list[str] | None = None) -> int:
81
+ """``openprocess-cpn [model.cpn]``: open Studio with a CPN model (or a new one)."""
82
+ arguments = list(sys.argv if argv is None else argv)
83
+ from .studio.app import main as studio_main
84
+ if len(arguments) > 1:
85
+ return studio_main(arguments)
86
+ return studio_main(arguments + ["--new-cpn"])
87
+
88
+
89
+ if __name__ == "__main__": # pragma: no cover
90
+ raise SystemExit(main())
@@ -0,0 +1,295 @@
1
+ """How arcs are edited on the canvas: the rules of CPN IDE.
2
+
3
+ CPN IDE draws its nets with *diagram-js* (the library behind the bpmn.io
4
+ editors), so an arc there behaves like a diagram-js connection. This module
5
+ restates those rules as small functions on plain ``(x, y)`` tuples; the
6
+ canvas (:mod:`openprocess.gui.canvas`) calls them while you drag.
7
+
8
+ An arc is a list of *waypoints*: the point where it leaves its first node,
9
+ its bendpoints, and the point where it meets its second node. Here the list
10
+ always runs from the transition end to the place end, the order ``.cpn``
11
+ files store bendpoints in.
12
+
13
+ Pressing on an arc and dragging does one of four things, decided by where
14
+ you pressed (:func:`hit_test`):
15
+
16
+ * **on a bend** (within 10 units): move that bend;
17
+ * **on one of the two ends**: drag that end to another node (reconnect);
18
+ * **near the middle of a horizontal or vertical segment**: slide the whole
19
+ segment sideways (:func:`move_segment`);
20
+ * **anywhere else on the line**: add a bend there and drag it.
21
+
22
+ While a bend is dragged it snaps level with, or in line with, its
23
+ neighbours (:func:`snap_bend`). When it is dropped, bends that ended up in a
24
+ straight line with their neighbours disappear (:func:`without_redundant`) --
25
+ that is how a bend is removed: drag it back into line.
26
+
27
+ Moving a node leaves the bends where they are, except the one next to the
28
+ node: if it was level with (or in line with) the arc's end, it stays so
29
+ (:func:`repair_after_move`). Moving both ends of an arc together moves the
30
+ whole arc.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import math
36
+
37
+ Point = tuple[float, float]
38
+ Rect = tuple[float, float, float, float] # left, top, width, height
39
+
40
+ #: How close (in canvas units) a press must be to a bend to grab it, and to
41
+ #: a segment's middle to slide the segment.
42
+ HIT_TOLERANCE = 10.0
43
+ #: A dragged bend snaps to its neighbours' x or y within this distance.
44
+ SNAP_TOLERANCE = 10.0
45
+ #: A moved node's centre snaps to other nodes' centres within this distance.
46
+ NODE_SNAP_TOLERANCE = 7.0
47
+ #: Two points this close in x (or y) count as lined up vertically (or
48
+ #: horizontally).
49
+ ALIGNED_THRESHOLD = 2.0
50
+ #: A bend this close to a straight line through its neighbours is dropped.
51
+ REDUNDANT_ACCURACY = 5.0
52
+ #: After a node move, bends within this distance of either node are dropped.
53
+ OVERLAP_PADDING = 20.0
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Small geometry helpers
58
+ # ---------------------------------------------------------------------------
59
+ def distance(a: Point, b: Point) -> float:
60
+ return math.hypot(a[0] - b[0], a[1] - b[1])
61
+
62
+
63
+ def aligned(a: Point, b: Point) -> str | None:
64
+ """``"vertical"`` if ``a`` and ``b`` share x, ``"horizontal"`` if they
65
+ share y (within :data:`ALIGNED_THRESHOLD`), else ``None``."""
66
+ if abs(a[0] - b[0]) <= ALIGNED_THRESHOLD:
67
+ return "vertical"
68
+ if abs(a[1] - b[1]) <= ALIGNED_THRESHOLD:
69
+ return "horizontal"
70
+ return None
71
+
72
+
73
+ def on_line(p: Point, q: Point, r: Point, accuracy: float = REDUNDANT_ACCURACY) -> bool:
74
+ """Is ``r`` within ``accuracy`` of the straight line through ``p`` and ``q``?"""
75
+ length = distance(p, q)
76
+ if length == 0:
77
+ return distance(p, r) <= accuracy
78
+ cross = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
79
+ return abs(cross / length) <= accuracy
80
+
81
+
82
+ def inside(p: Point, rect: Rect, padding: float = 0.0) -> bool:
83
+ left, top, width, height = rect
84
+ return (left - padding < p[0] < left + width + padding
85
+ and top - padding < p[1] < top + height + padding)
86
+
87
+
88
+ def closest_on_segment(p: Point, a: Point, b: Point) -> Point:
89
+ dx, dy = b[0] - a[0], b[1] - a[1]
90
+ length2 = dx * dx + dy * dy
91
+ if length2 == 0:
92
+ return a
93
+ share = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2))
94
+ return a[0] + dx * share, a[1] + dy * share
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Where did the press land?
99
+ # ---------------------------------------------------------------------------
100
+ def hit_test(waypoints: list[Point], p: Point,
101
+ tolerance: float = HIT_TOLERANCE) -> tuple[str, int, Point] | None:
102
+ """Classify a press at ``p`` on an arc with the given waypoints.
103
+
104
+ Returns ``(kind, index, point)`` or ``None`` when ``p`` is not on the arc:
105
+
106
+ * ``("bend", i, point)`` -- on waypoint ``i``. ``i == 0`` and
107
+ ``i == len(waypoints) - 1`` are the two ends (reconnect);
108
+ * ``("segment", i, point)`` -- near the middle of the horizontal or
109
+ vertical segment from waypoint ``i - 1`` to waypoint ``i``;
110
+ * ``("insert", i, point)`` -- elsewhere on that segment: a new bend goes
111
+ in at index ``i``; ``point`` is the spot on the line.
112
+ """
113
+ for index, waypoint in enumerate(waypoints):
114
+ if distance(waypoint, p) <= tolerance:
115
+ return "bend", index, waypoint
116
+ best: tuple[float, int, Point] | None = None
117
+ for index in range(1, len(waypoints)):
118
+ a, b = waypoints[index - 1], waypoints[index]
119
+ foot = closest_on_segment(p, a, b)
120
+ gap = distance(foot, p)
121
+ if gap <= tolerance and (best is None or gap < best[0]):
122
+ best = (gap, index, foot)
123
+ if best is None:
124
+ return None
125
+ _, index, foot = best
126
+ a, b = waypoints[index - 1], waypoints[index]
127
+ middle = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
128
+ if aligned(a, b) and abs(foot[0] - middle[0]) <= tolerance \
129
+ and abs(foot[1] - middle[1]) <= tolerance:
130
+ return "segment", index, middle
131
+ return "insert", index, foot
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Dragging a bend
136
+ # ---------------------------------------------------------------------------
137
+ def snap_value(value: float, candidates: list[float],
138
+ tolerance: float = SNAP_TOLERANCE) -> float:
139
+ for candidate in candidates:
140
+ if abs(candidate - value) <= tolerance:
141
+ return candidate
142
+ return value
143
+
144
+
145
+ def snap_bend(p: Point, neighbours: list[Point]) -> Point:
146
+ """Snap a dragged bend level with / in line with its neighbours.
147
+
148
+ The neighbours are the waypoints on either side; for an end of the arc
149
+ pass the node's centre, as diagram-js does."""
150
+ xs = [n[0] for n in neighbours]
151
+ ys = [n[1] for n in neighbours]
152
+ return snap_value(p[0], xs), snap_value(p[1], ys)
153
+
154
+
155
+ def without_redundant(waypoints: list[Point],
156
+ accuracy: float = REDUNDANT_ACCURACY) -> list[Point]:
157
+ """Drop bends that lie on the straight line through their neighbours, or
158
+ on top of the next point. The two ends always stay."""
159
+ points = list(waypoints)
160
+ index = 1
161
+ while index < len(points) - 1:
162
+ point = points[index]
163
+ if distance(point, points[index + 1]) == 0 or \
164
+ on_line(points[index - 1], points[index + 1], point, accuracy):
165
+ del points[index]
166
+ else:
167
+ index += 1
168
+ return points
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Sliding a segment
173
+ # ---------------------------------------------------------------------------
174
+ def move_segment(waypoints: list[Point], index: int, delta: float,
175
+ first_node: tuple[Point, Rect], last_node: tuple[Point, Rect],
176
+ snap_to: bool = True) -> list[Point]:
177
+ """Slide the segment from waypoint ``index - 1`` to ``index`` sideways.
178
+
179
+ A horizontal segment moves up or down by ``delta``, a vertical one left
180
+ or right. ``first_node`` / ``last_node`` are ``(centre, rect)`` of the
181
+ nodes at the two ends. Where the segment starts or ends at a node:
182
+
183
+ * if its moved end still lies inside the node, the arc simply meets the
184
+ node at the new height;
185
+ * if it has left the node, a new bend is added, so the arc leaves the
186
+ node straight and then turns -- an S-bend.
187
+
188
+ A bend next to a node that is moved *into* that node is dropped.
189
+ Returns the new waypoint list (ends included, not yet tidied).
190
+ """
191
+ start, end = waypoints[index - 1], waypoints[index]
192
+ axis = 1 if aligned(start, end) == "horizontal" else 0 # which coordinate moves
193
+ last = len(waypoints) - 1
194
+ # An end of the arc counts from the node's centre (its "docking" point).
195
+ if index - 1 == 0:
196
+ start = _with(start, 1 - axis, first_node[0][1 - axis])
197
+ if index == last:
198
+ end = _with(end, 1 - axis, last_node[0][1 - axis])
199
+ target = start[axis] + delta
200
+ if snap_to:
201
+ references = [start[axis], end[axis]]
202
+ if index - 2 >= 0:
203
+ references.append(waypoints[index - 2][axis])
204
+ if index + 1 <= last:
205
+ references.append(waypoints[index + 1][axis])
206
+ if index - 1 <= 1:
207
+ references.append(first_node[0][axis])
208
+ if index >= last - 1:
209
+ references.append(last_node[0][axis])
210
+ target = snap_value(target, [r for r in references if r != start[axis]])
211
+ new_start, new_end = _with(start, axis, target), _with(end, axis, target)
212
+
213
+ points = list(waypoints)
214
+ points[index - 1], points[index] = new_start, new_end
215
+ # The node end at the start of the segment ...
216
+ if index - 1 == 0:
217
+ if not inside(new_start, first_node[1]):
218
+ points.insert(0, start) # the arc leaves the node, then turns
219
+ index += 1
220
+ elif index - 1 == 1 and inside(new_start, first_node[1]):
221
+ del points[0] # the first bend went into the node
222
+ index -= 1
223
+ # ... and at its end.
224
+ last = len(points) - 1
225
+ if index == last:
226
+ if not inside(new_end, last_node[1]):
227
+ points.append(end)
228
+ elif index == last - 1 and inside(new_end, last_node[1]):
229
+ del points[-1]
230
+ return points
231
+
232
+
233
+ def _with(p: Point, axis: int, value: float) -> Point:
234
+ return (value, p[1]) if axis == 0 else (p[0], value)
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # After a node moved
239
+ # ---------------------------------------------------------------------------
240
+ def repair_after_move(points: list[Point], new_end: Point,
241
+ moved: Rect, other: Rect) -> list[Point]:
242
+ """The waypoints of an arc after the node at ``points[0]`` moved.
243
+
244
+ ``points`` runs from the moved node's end to the other end; ``new_end``
245
+ is the old end point moved with the node; ``moved`` and ``other`` are
246
+ the two nodes' rectangles at their new places. Only the first bend
247
+ changes: kept level (or in line) with the end if it was. Bends that the
248
+ move put on top of either node are dropped, with everything before them.
249
+ """
250
+ for _ in range(len(points)): # at most one pass per bend
251
+ if len(points) < 3:
252
+ return [new_end, points[-1]]
253
+ old_end = points[0]
254
+ repaired = list(points)
255
+ repaired[0] = new_end
256
+ bend = repaired[1]
257
+ alignment = aligned(old_end, bend)
258
+ if alignment == "vertical":
259
+ repaired[1] = (new_end[0], bend[1])
260
+ elif alignment == "horizontal":
261
+ repaired[1] = (bend[0], new_end[1])
262
+ for i in range(len(repaired) - 2, 0, -1):
263
+ if inside(repaired[i], moved, OVERLAP_PADDING) or \
264
+ inside(repaired[i], other, OVERLAP_PADDING):
265
+ points = repaired[i:]
266
+ break
267
+ else:
268
+ return repaired
269
+ return [new_end, points[-1]]
270
+
271
+
272
+ def snap_node(centre: Point, targets: list[Point],
273
+ tolerance: float = NODE_SNAP_TOLERANCE) -> tuple[float | None, float | None]:
274
+ """The x and y a dragged node's centre snaps to (``None`` = no snap):
275
+ other nodes' centres and the corners of right-angled arcs."""
276
+ best_x = best_y = None
277
+ gap_x = gap_y = tolerance + 1e-9
278
+ for tx, ty in targets:
279
+ if abs(tx - centre[0]) < gap_x:
280
+ best_x, gap_x = tx, abs(tx - centre[0])
281
+ if abs(ty - centre[1]) < gap_y:
282
+ best_y, gap_y = ty, abs(ty - centre[1])
283
+ return best_x, best_y
284
+
285
+
286
+ def corner_bends(waypoints: list[Point]) -> list[Point]:
287
+ """Bends that start or end a horizontal or vertical segment -- the ones
288
+ nodes snap to, as in CPN IDE."""
289
+ corners = []
290
+ for i in range(1, len(waypoints) - 1):
291
+ point = waypoints[i]
292
+ before, after = waypoints[i - 1], waypoints[i + 1]
293
+ if point[0] in (before[0], after[0]) or point[1] in (before[1], after[1]):
294
+ corners.append(point)
295
+ return corners