pySimBlocks 0.1.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 (262) hide show
  1. examples/advanced/hardware/block_diagram.py +162 -0
  2. examples/advanced/hardware/camera.py +93 -0
  3. examples/advanced/hardware/gui_emio.py +231 -0
  4. examples/advanced/hardware/lmi.py +69 -0
  5. examples/advanced/hardware/main.py +57 -0
  6. examples/advanced/hardware/parameters.py +54 -0
  7. examples/advanced/qp_problem/params.py +10 -0
  8. examples/advanced/qp_problem/run.py +14 -0
  9. examples/advanced/sofa/gui/finger/Finger.py +108 -0
  10. examples/advanced/sofa/gui/finger/FingerController.py +53 -0
  11. examples/advanced/sofa/gui/finger/parameters_auto.py +22 -0
  12. examples/advanced/sofa/gui/sofa_plant/run.py +14 -0
  13. examples/advanced/sofa/python/Finger.py +107 -0
  14. examples/advanced/sofa/python/FingerController.py +48 -0
  15. examples/advanced/sofa/python/closedloop.py +64 -0
  16. examples/advanced/sofa/python/openloop.py +58 -0
  17. examples/basics/algebraic_function/g.py +7 -0
  18. examples/basics/algebraic_function/run.py +14 -0
  19. examples/basics/dc_motor/gui/run.py +14 -0
  20. examples/basics/dc_motor/multiple_order.py +91 -0
  21. examples/basics/dc_motor/openloop.py +62 -0
  22. examples/basics/dc_motor/pi.py +99 -0
  23. examples/basics/dc_motor/pid.py +118 -0
  24. examples/basics/dc_motor/pid_block.py +82 -0
  25. examples/basics/dc_motor/pid_block_vs_manual.py +143 -0
  26. examples/basics/external/parameters.py +4 -0
  27. examples/basics/external/run.py +14 -0
  28. examples/basics/file_sources/temp.py +16 -0
  29. examples/basics/observer.py +76 -0
  30. examples/basics/state_feedback/gui/parameters.py +9 -0
  31. examples/basics/state_feedback/gui/run.py +14 -0
  32. examples/basics/state_feedback/state_feedback.py +102 -0
  33. examples/basics/state_space/linear/gui/run.py +14 -0
  34. examples/basics/state_space/linear/ss_block.py +89 -0
  35. examples/basics/state_space/linear/ss_manual.py +105 -0
  36. examples/basics/state_space/non_linear/nlss.py +47 -0
  37. examples/basics/state_space/non_linear/run.py +14 -0
  38. examples/basics/state_space/polytope/params.py +39 -0
  39. examples/basics/state_space/polytope/weights.py +12 -0
  40. examples/comparison/simulink/convert.py +29 -0
  41. examples/comparison/simulink/openloop.py +127 -0
  42. examples/comparison/simulink/pi.py +148 -0
  43. examples/comparison/simulink/pid.py +154 -0
  44. examples/comparison/simulink/pid_backward.py +155 -0
  45. examples/quick_start/filter.py +35 -0
  46. examples/quick_start/gui/run.py +14 -0
  47. examples/tutorials/tutorial_1_python/main.py +99 -0
  48. examples/tutorials/tutorial_3_sofa/finger/Finger.py +120 -0
  49. examples/tutorials/tutorial_3_sofa/finger/FingerController.py +37 -0
  50. pySimBlocks/__init__.py +35 -0
  51. pySimBlocks/blocks/__init__.py +68 -0
  52. pySimBlocks/blocks/controllers/__init__.py +27 -0
  53. pySimBlocks/blocks/controllers/pid.py +250 -0
  54. pySimBlocks/blocks/controllers/state_feedback.py +141 -0
  55. pySimBlocks/blocks/interfaces/__init__.py +27 -0
  56. pySimBlocks/blocks/interfaces/external_input.py +107 -0
  57. pySimBlocks/blocks/interfaces/external_output.py +113 -0
  58. pySimBlocks/blocks/observers/__init__.py +23 -0
  59. pySimBlocks/blocks/observers/luenberger.py +175 -0
  60. pySimBlocks/blocks/operators/__init__.py +47 -0
  61. pySimBlocks/blocks/operators/algebraic_function.py +258 -0
  62. pySimBlocks/blocks/operators/dead_zone.py +186 -0
  63. pySimBlocks/blocks/operators/delay.py +254 -0
  64. pySimBlocks/blocks/operators/demux.py +123 -0
  65. pySimBlocks/blocks/operators/discrete_derivator.py +237 -0
  66. pySimBlocks/blocks/operators/discrete_integrator.py +265 -0
  67. pySimBlocks/blocks/operators/gain.py +294 -0
  68. pySimBlocks/blocks/operators/mux.py +131 -0
  69. pySimBlocks/blocks/operators/product.py +194 -0
  70. pySimBlocks/blocks/operators/rate_limiter.py +214 -0
  71. pySimBlocks/blocks/operators/saturation.py +178 -0
  72. pySimBlocks/blocks/operators/sum.py +188 -0
  73. pySimBlocks/blocks/operators/zero_order_hold.py +141 -0
  74. pySimBlocks/blocks/optimizers/__init__.py +23 -0
  75. pySimBlocks/blocks/optimizers/quadratic_program.py +326 -0
  76. pySimBlocks/blocks/sources/__init__.py +37 -0
  77. pySimBlocks/blocks/sources/chirp.py +168 -0
  78. pySimBlocks/blocks/sources/constant.py +82 -0
  79. pySimBlocks/blocks/sources/file_source.py +294 -0
  80. pySimBlocks/blocks/sources/function_source.py +197 -0
  81. pySimBlocks/blocks/sources/ramp.py +99 -0
  82. pySimBlocks/blocks/sources/sinusoidal.py +113 -0
  83. pySimBlocks/blocks/sources/step.py +138 -0
  84. pySimBlocks/blocks/sources/white_noise.py +107 -0
  85. pySimBlocks/blocks/systems/__init__.py +27 -0
  86. pySimBlocks/blocks/systems/linear_state_space.py +180 -0
  87. pySimBlocks/blocks/systems/non_linear_state_space.py +309 -0
  88. pySimBlocks/blocks/systems/polytopic_state_space.py +193 -0
  89. pySimBlocks/blocks/systems/sofa/__init__.py +30 -0
  90. pySimBlocks/blocks/systems/sofa/sofa_controller.py +472 -0
  91. pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py +120 -0
  92. pySimBlocks/blocks/systems/sofa/sofa_plant.py +306 -0
  93. pySimBlocks/cli.py +127 -0
  94. pySimBlocks/core/__init__.py +32 -0
  95. pySimBlocks/core/block.py +167 -0
  96. pySimBlocks/core/block_source.py +94 -0
  97. pySimBlocks/core/config.py +90 -0
  98. pySimBlocks/core/fixed_time_manager.py +46 -0
  99. pySimBlocks/core/model.py +250 -0
  100. pySimBlocks/core/scheduler.py +30 -0
  101. pySimBlocks/core/simulator.py +329 -0
  102. pySimBlocks/core/task.py +73 -0
  103. pySimBlocks/docs/blocks/controllers/pid.md +78 -0
  104. pySimBlocks/docs/blocks/controllers/state_feedback.md +60 -0
  105. pySimBlocks/docs/blocks/interfaces/external_input.md +39 -0
  106. pySimBlocks/docs/blocks/interfaces/external_output.md +39 -0
  107. pySimBlocks/docs/blocks/observers/luenberger.md +70 -0
  108. pySimBlocks/docs/blocks/operators/algebraic_function.md +76 -0
  109. pySimBlocks/docs/blocks/operators/dead_zone.md +67 -0
  110. pySimBlocks/docs/blocks/operators/delay.md +76 -0
  111. pySimBlocks/docs/blocks/operators/demux.md +64 -0
  112. pySimBlocks/docs/blocks/operators/discrete_derivator.md +61 -0
  113. pySimBlocks/docs/blocks/operators/discrete_integrator.md +87 -0
  114. pySimBlocks/docs/blocks/operators/gain.md +97 -0
  115. pySimBlocks/docs/blocks/operators/mux.md +64 -0
  116. pySimBlocks/docs/blocks/operators/product.md +51 -0
  117. pySimBlocks/docs/blocks/operators/rate_limiter.md +70 -0
  118. pySimBlocks/docs/blocks/operators/saturation.md +62 -0
  119. pySimBlocks/docs/blocks/operators/sum.md +49 -0
  120. pySimBlocks/docs/blocks/operators/zero_order_hold.md +61 -0
  121. pySimBlocks/docs/blocks/optimizers/quadratic_program.md +98 -0
  122. pySimBlocks/docs/blocks/sources/chirp.md +102 -0
  123. pySimBlocks/docs/blocks/sources/constant.md +53 -0
  124. pySimBlocks/docs/blocks/sources/file_source.md +51 -0
  125. pySimBlocks/docs/blocks/sources/function_source.md +52 -0
  126. pySimBlocks/docs/blocks/sources/ramp.md +58 -0
  127. pySimBlocks/docs/blocks/sources/sinusoidal.md +60 -0
  128. pySimBlocks/docs/blocks/sources/step.md +63 -0
  129. pySimBlocks/docs/blocks/sources/white_noise.md +58 -0
  130. pySimBlocks/docs/blocks/systems/linear_state_space.md +66 -0
  131. pySimBlocks/docs/blocks/systems/non_linear_state_space.md +90 -0
  132. pySimBlocks/docs/blocks/systems/polytopic_state_space.md +72 -0
  133. pySimBlocks/docs/blocks/systems/sofa/sofa_exchange_i_o.md +63 -0
  134. pySimBlocks/docs/blocks/systems/sofa/sofa_plant.md +62 -0
  135. pySimBlocks/gui/__init__.py +20 -0
  136. pySimBlocks/gui/addons/__init__.py +19 -0
  137. pySimBlocks/gui/addons/sofa/__init__.py +19 -0
  138. pySimBlocks/gui/addons/sofa/sofa_dialog.py +190 -0
  139. pySimBlocks/gui/addons/sofa/sofa_service.py +209 -0
  140. pySimBlocks/gui/blocks/block_dialog_session.py +47 -0
  141. pySimBlocks/gui/blocks/block_meta.py +408 -0
  142. pySimBlocks/gui/blocks/controllers/pid.py +116 -0
  143. pySimBlocks/gui/blocks/controllers/state_feedback.py +80 -0
  144. pySimBlocks/gui/blocks/interfaces/external_input.py +64 -0
  145. pySimBlocks/gui/blocks/interfaces/external_output.py +63 -0
  146. pySimBlocks/gui/blocks/observers/luenberger.py +108 -0
  147. pySimBlocks/gui/blocks/operators/algebraic_function.py +232 -0
  148. pySimBlocks/gui/blocks/operators/dead_zone.py +79 -0
  149. pySimBlocks/gui/blocks/operators/delay.py +79 -0
  150. pySimBlocks/gui/blocks/operators/demux.py +98 -0
  151. pySimBlocks/gui/blocks/operators/discrete_derivator.py +67 -0
  152. pySimBlocks/gui/blocks/operators/discrete_integrator.py +80 -0
  153. pySimBlocks/gui/blocks/operators/gain.py +79 -0
  154. pySimBlocks/gui/blocks/operators/mux.py +100 -0
  155. pySimBlocks/gui/blocks/operators/product.py +109 -0
  156. pySimBlocks/gui/blocks/operators/rate_limiter.py +79 -0
  157. pySimBlocks/gui/blocks/operators/saturation.py +72 -0
  158. pySimBlocks/gui/blocks/operators/sum.py +91 -0
  159. pySimBlocks/gui/blocks/operators/zero_order_hold.py +64 -0
  160. pySimBlocks/gui/blocks/optimizers/quadratic_program.py +128 -0
  161. pySimBlocks/gui/blocks/parameter_meta.py +34 -0
  162. pySimBlocks/gui/blocks/port_meta.py +31 -0
  163. pySimBlocks/gui/blocks/sources/chirp.py +146 -0
  164. pySimBlocks/gui/blocks/sources/constant.py +63 -0
  165. pySimBlocks/gui/blocks/sources/file_source.py +196 -0
  166. pySimBlocks/gui/blocks/sources/function_source.py +202 -0
  167. pySimBlocks/gui/blocks/sources/ramp.py +73 -0
  168. pySimBlocks/gui/blocks/sources/sinusoidal.py +81 -0
  169. pySimBlocks/gui/blocks/sources/step.py +83 -0
  170. pySimBlocks/gui/blocks/sources/white_noise.py +70 -0
  171. pySimBlocks/gui/blocks/systems/linear_state_space.py +102 -0
  172. pySimBlocks/gui/blocks/systems/non_linear_state_space.py +233 -0
  173. pySimBlocks/gui/blocks/systems/polytopic_state_space.py +109 -0
  174. pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +221 -0
  175. pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +220 -0
  176. pySimBlocks/gui/dialogs/__init__.py +19 -0
  177. pySimBlocks/gui/dialogs/block_dialog.py +125 -0
  178. pySimBlocks/gui/dialogs/display_yaml_dialog.py +89 -0
  179. pySimBlocks/gui/dialogs/help_dialog.py +39 -0
  180. pySimBlocks/gui/dialogs/plot_dialog.py +230 -0
  181. pySimBlocks/gui/dialogs/settings/plots.py +187 -0
  182. pySimBlocks/gui/dialogs/settings/project.py +146 -0
  183. pySimBlocks/gui/dialogs/settings/simulation.py +137 -0
  184. pySimBlocks/gui/dialogs/settings_dialog.py +88 -0
  185. pySimBlocks/gui/dialogs/unsaved_dialog.py +92 -0
  186. pySimBlocks/gui/editor.py +49 -0
  187. pySimBlocks/gui/graphics/__init__.py +29 -0
  188. pySimBlocks/gui/graphics/block_item.py +289 -0
  189. pySimBlocks/gui/graphics/connection_item.py +266 -0
  190. pySimBlocks/gui/graphics/port_item.py +153 -0
  191. pySimBlocks/gui/graphics/theme.py +119 -0
  192. pySimBlocks/gui/main_window.py +162 -0
  193. pySimBlocks/gui/models/__init__.py +31 -0
  194. pySimBlocks/gui/models/block_instance.py +114 -0
  195. pySimBlocks/gui/models/connection_instance.py +43 -0
  196. pySimBlocks/gui/models/port_instance.py +52 -0
  197. pySimBlocks/gui/models/project_simulation_params.py +50 -0
  198. pySimBlocks/gui/models/project_state.py +114 -0
  199. pySimBlocks/gui/project_controller.py +302 -0
  200. pySimBlocks/gui/services/__init__.py +20 -0
  201. pySimBlocks/gui/services/project_loader.py +381 -0
  202. pySimBlocks/gui/services/project_saver.py +68 -0
  203. pySimBlocks/gui/services/simulation_runner.py +66 -0
  204. pySimBlocks/gui/services/yaml_tools.py +261 -0
  205. pySimBlocks/gui/widgets/__init__.py +19 -0
  206. pySimBlocks/gui/widgets/block_list.py +131 -0
  207. pySimBlocks/gui/widgets/diagram_view.py +312 -0
  208. pySimBlocks/gui/widgets/toolbar_view.py +161 -0
  209. pySimBlocks/project/__init__.py +35 -0
  210. pySimBlocks/project/build_model.py +92 -0
  211. pySimBlocks/project/generate_run_script.py +109 -0
  212. pySimBlocks/project/generate_sofa_controller.py +215 -0
  213. pySimBlocks/project/load_project_config.py +197 -0
  214. pySimBlocks/project/load_simulation_config.py +166 -0
  215. pySimBlocks/project/load_simulator.py +45 -0
  216. pySimBlocks/project/plot_from_config.py +174 -0
  217. pySimBlocks/project/pySimBlocks_blocks_index.yaml +106 -0
  218. pySimBlocks/real_time/__init__.py +23 -0
  219. pySimBlocks/real_time/real_time_runner.py +148 -0
  220. pySimBlocks/tools/__init__.py +19 -0
  221. pySimBlocks/tools/blocks_registry.py +141 -0
  222. pySimBlocks/tools/generate_blocks_index.py +124 -0
  223. pysimblocks-0.1.0.dist-info/METADATA +180 -0
  224. pysimblocks-0.1.0.dist-info/RECORD +262 -0
  225. pysimblocks-0.1.0.dist-info/WHEEL +5 -0
  226. pysimblocks-0.1.0.dist-info/entry_points.txt +2 -0
  227. pysimblocks-0.1.0.dist-info/licenses/LICENSE.md +165 -0
  228. pysimblocks-0.1.0.dist-info/top_level.txt +3 -0
  229. tests/blocks/controllers/test_pid.py +143 -0
  230. tests/blocks/controllers/test_state_feedback.py +193 -0
  231. tests/blocks/observers/test_luenberger.py +225 -0
  232. tests/blocks/operators/test_algebraic_function.py +230 -0
  233. tests/blocks/operators/test_dead_zone.py +106 -0
  234. tests/blocks/operators/test_delay.py +167 -0
  235. tests/blocks/operators/test_demux.py +111 -0
  236. tests/blocks/operators/test_discrete_derivator.py +160 -0
  237. tests/blocks/operators/test_discrete_integrator.py +149 -0
  238. tests/blocks/operators/test_gain.py +182 -0
  239. tests/blocks/operators/test_mux.py +101 -0
  240. tests/blocks/operators/test_product.py +171 -0
  241. tests/blocks/operators/test_rate_limiter.py +166 -0
  242. tests/blocks/operators/test_saturation.py +157 -0
  243. tests/blocks/operators/test_sum.py +183 -0
  244. tests/blocks/operators/test_zero_order_hold.py +106 -0
  245. tests/blocks/sources/test_chirp.py +157 -0
  246. tests/blocks/sources/test_constant.py +55 -0
  247. tests/blocks/sources/test_file_source.py +185 -0
  248. tests/blocks/sources/test_function_source.py +135 -0
  249. tests/blocks/sources/test_ramp.py +110 -0
  250. tests/blocks/sources/test_sinusoidal.py +113 -0
  251. tests/blocks/sources/test_step.py +76 -0
  252. tests/blocks/sources/test_white_noise.py +108 -0
  253. tests/blocks/systems/test_linear_state_space.py +135 -0
  254. tests/blocks/systems/test_polytopic_state_space.py +133 -0
  255. tests/core/test_core_errors_and_determinism.py +82 -0
  256. tests/core/test_core_multirate_tasks.py +88 -0
  257. tests/core/test_core_two_phase_and_propagation.py +132 -0
  258. tests/gui/main_window_open_test.py +162 -0
  259. tests/gui/test_block_meta_param_cache.py +16 -0
  260. tests/gui/test_file_source_meta.py +17 -0
  261. tests/gui/test_function_source_meta.py +28 -0
  262. tests/regression/test_simulink_dc_motor.py +217 -0
@@ -0,0 +1,266 @@
1
+ # ******************************************************************************
2
+ # pySimBlocks
3
+ # Copyright (c) 2026 Université de Lille & INRIA
4
+ # ******************************************************************************
5
+ # This program is free software: you can redistribute it and/or modify it
6
+ # under the terms of the GNU Lesser General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or (at your
8
+ # option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ # ******************************************************************************
18
+ # Authors: see Authors.txt
19
+ # ******************************************************************************
20
+
21
+ from PySide6.QtCore import Qt, QPointF
22
+ from PySide6.QtGui import QPen, QPainterPath, QPainterPathStroker
23
+ from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem
24
+
25
+ from pySimBlocks.gui.graphics.port_item import PortItem
26
+ from pySimBlocks.gui.models.connection_instance import ConnectionInstance
27
+
28
+
29
+ class OrthogonalRoute:
30
+ def __init__(self, points: list[QPointF]):
31
+ self.points = points
32
+ self.dragged_index: int | None = None
33
+
34
+
35
+ class ConnectionItem(QGraphicsPathItem):
36
+ OFFSET = 8
37
+ MARGIN = 12
38
+ DETOUR = 8
39
+ PICK_TOL = 6
40
+ GRID = 5
41
+
42
+ def __init__(self,
43
+ src_port: PortItem | None,
44
+ dst_port: PortItem | None,
45
+ instance: ConnectionInstance,
46
+ points: list[QPointF] | None = None):
47
+ super().__init__()
48
+
49
+ if src_port is None and dst_port is None:
50
+ raise ValueError("At least one of the ports must be provided")
51
+
52
+ self.src_port = src_port
53
+ self.dst_port = dst_port
54
+ self.instance = instance
55
+ self.is_temporary = (src_port is None) or (dst_port is None)
56
+ self._valid_port = src_port if src_port is not None else dst_port
57
+ self.is_manual: bool = False
58
+ self.route: OrthogonalRoute | None = None
59
+
60
+ if points and len(points) >= 2:
61
+ self.apply_manual_route(points)
62
+
63
+ t = self._valid_port.parent_block.view.theme
64
+
65
+
66
+ if self.is_temporary:
67
+ self.setFlag(QGraphicsItem.ItemIsSelectable, False)
68
+ self.setAcceptedMouseButtons(Qt.NoButton)
69
+ pen = QPen(t.wire, 3, Qt.DashLine)
70
+ else:
71
+ self.setFlag(QGraphicsItem.ItemIsSelectable, True)
72
+ self.setAcceptedMouseButtons(Qt.LeftButton)
73
+ pen = QPen(t.wire, 3, Qt.SolidLine)
74
+
75
+ self.setPen(pen)
76
+ self.setZValue(1)
77
+
78
+ self.update_position()
79
+
80
+ # --------------------------------------------------------------------------
81
+ # Position methods
82
+ # --------------------------------------------------------------------------
83
+ def update_position(self):
84
+ if self.is_temporary:
85
+ return
86
+
87
+ p1 = self.src_port.connection_anchor()
88
+ p2 = self.dst_port.connection_anchor()
89
+ if self.is_manual and self.route and len(self.route.points) >= 2:
90
+ self.route.points[0] = p1
91
+ self.route.points[-1] = p2
92
+ self._apply_route(self.route.points)
93
+ return
94
+
95
+ pts = self._compute_auto_route(p1, p2)
96
+ self.route = OrthogonalRoute(pts)
97
+ self._apply_route(self.route.points)
98
+
99
+ # ------------------------------------------------------------------
100
+ def update_temp_position(self, scene_pos: QPointF):
101
+ p1 = self._valid_port.connection_anchor()
102
+ pts = [p1, scene_pos]
103
+ self._apply_route(pts)
104
+
105
+ # --------------------------------------------------------------------------
106
+ # Routing methods
107
+ # --------------------------------------------------------------------------
108
+ def apply_manual_route(self, points: list[QPointF]):
109
+ self.route = OrthogonalRoute(points)
110
+ self.is_manual = True
111
+ self._apply_route(self.route.points)
112
+
113
+ # ------------------------------------------------------------------
114
+ def invalidate_manual_route(self):
115
+ """
116
+ Called when a connected block moves: manual routing is discarded and
117
+ next update_position() will fully recompute the orthogonal route.
118
+ """
119
+ self.is_manual = False
120
+ self.route = None
121
+
122
+ # ------------------------------------------------------------------
123
+ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]:
124
+ src_block = self.src_port.parent_block
125
+ dst_block = self.dst_port.parent_block
126
+
127
+ # Use the visual block rect (not selection handle hit area) for routing.
128
+ src_rect = src_block.mapRectToScene(src_block.rect())
129
+ dst_rect = dst_block.mapRectToScene(dst_block.rect())
130
+
131
+ src_out_sign = 1 if not self.src_port.is_on_left_side else -1
132
+ dst_in_sign = -1 if self.dst_port.is_on_left_side else 1
133
+
134
+ p1_out = QPointF(p1.x() + src_out_sign * self.OFFSET, p1.y())
135
+ p2_in = QPointF(p2.x() + dst_in_sign * self.OFFSET, p2.y())
136
+
137
+ same_block = src_block is dst_block
138
+ u_turn = ((p2_in.x() - p1_out.x()) * src_out_sign) < 0
139
+ is_feedback = same_block or u_turn
140
+
141
+ if not is_feedback:
142
+ mid_x = (p1_out.x() + p2_in.x()) * 0.5
143
+ candidate = [
144
+ p1, p1_out,
145
+ QPointF(mid_x, p1.y()),
146
+ QPointF(mid_x, p2.y()),
147
+ p2_in, p2
148
+ ]
149
+
150
+ path = self._path_from(candidate)
151
+ if not (path.intersects(src_rect) or path.intersects(dst_rect)):
152
+ return candidate
153
+
154
+ # fallback / feedback routing
155
+ candidates_y = [
156
+ min(src_rect.top(), dst_rect.top()) - self.MARGIN,
157
+ max(src_rect.bottom(), dst_rect.bottom()) + self.MARGIN
158
+ ]
159
+
160
+ if src_rect.bottom() < dst_rect.top():
161
+ candidates_y.append((src_rect.bottom() + dst_rect.top()) * 0.5)
162
+ elif dst_rect.bottom() < src_rect.top():
163
+ candidates_y.append((dst_rect.bottom() + src_rect.top()) * 0.5)
164
+
165
+ route_y = min(
166
+ candidates_y,
167
+ key=lambda y: abs(p1.y() - y) + abs(p2.y() - y)
168
+ )
169
+
170
+ return [
171
+ p1, p1_out,
172
+ QPointF(p1_out.x(), route_y),
173
+ QPointF(p2_in.x(), route_y),
174
+ p2_in, p2
175
+ ]
176
+
177
+ # ------------------------------------------------------------------
178
+ def _snap(self, v: float) -> float:
179
+ return round(v / self.GRID) * self.GRID
180
+
181
+ # --------------------------------------------------------------------------
182
+ # Path methods
183
+ # --------------------------------------------------------------------------
184
+ def _apply_route(self, points: list[QPointF]):
185
+ path = QPainterPath(points[0])
186
+ for p in points[1:]:
187
+ path.lineTo(p)
188
+ self.setPath(path)
189
+
190
+ # ------------------------------------------------------------------
191
+ def _path_from(self, pts: list[QPointF]) -> QPainterPath:
192
+ p = QPainterPath(pts[0])
193
+ for pt in pts[1:]:
194
+ p.lineTo(pt)
195
+ return p
196
+
197
+ # --------------------------------------------------------------------------
198
+ # Interaction (segment dragging)
199
+ # --------------------------------------------------------------------------
200
+ def segment_at(self, scene_pos: QPointF) -> int | None:
201
+ if not self.route:
202
+ return None
203
+
204
+ pts = self.route.points
205
+ for i in range(len(pts) - 1):
206
+ a, b = pts[i], pts[i + 1]
207
+
208
+ if a.x() == b.x(): # vertical
209
+ if abs(scene_pos.x() - a.x()) < self.PICK_TOL \
210
+ and min(a.y(), b.y()) <= scene_pos.y() <= max(a.y(), b.y()):
211
+ return i
212
+
213
+ if a.y() == b.y(): # horizontal
214
+ if abs(scene_pos.y() - a.y()) < self.PICK_TOL \
215
+ and min(a.x(), b.x()) <= scene_pos.x() <= max(a.x(), b.x()):
216
+ return i
217
+ return None
218
+
219
+ # --------------------------------------------------------------
220
+ def shape(self):
221
+ """
222
+ Override the default shape to make it easier to click on the connection.
223
+ """
224
+ stroker = QPainterPathStroker()
225
+ stroker.setWidth(6) # zone cliquable (px)
226
+ return stroker.createStroke(self.path())
227
+
228
+ # --------------------------------------------------------------------------
229
+ # Events methods
230
+ # --------------------------------------------------------------------------
231
+ def mousePressEvent(self, event):
232
+ idx = self.segment_at(event.scenePos())
233
+ if idx is not None:
234
+ self.route.dragged_index = idx
235
+ self.is_manual = True
236
+ event.accept()
237
+ else:
238
+ super().mousePressEvent(event)
239
+
240
+ # ------------------------------------------------------------------
241
+ def mouseMoveEvent(self, event):
242
+ if not self.route or self.route.dragged_index is None:
243
+ return
244
+
245
+ i = self.route.dragged_index
246
+ a = self.route.points[i]
247
+ b = self.route.points[i + 1]
248
+ pos = event.scenePos()
249
+
250
+ if a.x() == b.x(): # vertical segment
251
+ x = self._snap(pos.x())
252
+ self.route.points[i] = QPointF(x, a.y())
253
+ self.route.points[i + 1] = QPointF(x, b.y())
254
+
255
+ elif a.y() == b.y(): # horizontal segment
256
+ y = self._snap(pos.y())
257
+ self.route.points[i] = QPointF(a.x(), y)
258
+ self.route.points[i + 1] = QPointF(b.x(), y)
259
+
260
+ self._apply_route(self.route.points)
261
+
262
+ # ------------------------------------------------------------------
263
+ def mouseReleaseEvent(self, event):
264
+ if self.route:
265
+ self.route.dragged_index = None
266
+ super().mouseReleaseEvent(event)
@@ -0,0 +1,153 @@
1
+ # ******************************************************************************
2
+ # pySimBlocks
3
+ # Copyright (c) 2026 Université de Lille & INRIA
4
+ # ******************************************************************************
5
+ # This program is free software: you can redistribute it and/or modify it
6
+ # under the terms of the GNU Lesser General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or (at your
8
+ # option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ # ******************************************************************************
18
+ # Authors: see Authors.txt
19
+ # ******************************************************************************
20
+
21
+ from typing import TYPE_CHECKING
22
+
23
+ from PySide6.QtCore import QPointF, QRectF, Qt
24
+ from PySide6.QtGui import QBrush, QFont, QPainter, QPainterPath, QPen
25
+ from PySide6.QtWidgets import QGraphicsItem, QGraphicsTextItem
26
+
27
+ if TYPE_CHECKING:
28
+ from pySimBlocks.gui.graphics.block_item import BlockItem
29
+ from pySimBlocks.gui.models.port_instance import PortInstance
30
+
31
+
32
+ class PortItem(QGraphicsItem):
33
+ MARGIN = 4
34
+ R = 6 # radius input port
35
+ L = 15 # length output port
36
+ H = 10 # height output port
37
+ RECT = QRectF(-8, -8, 15, 15) # bounding rect for both port types
38
+
39
+ def __init__(self, instance: 'PortInstance', parent_block: 'BlockItem'):
40
+ super().__init__(parent_block)
41
+
42
+ self.instance = instance
43
+ self.parent_block = parent_block
44
+
45
+ self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges)
46
+ self.setAcceptedMouseButtons(Qt.LeftButton)
47
+
48
+ # Label
49
+ self.t = parent_block.view.theme
50
+ self.label = QGraphicsTextItem(self.instance.display_as, parent_block)
51
+ self.label.setDefaultTextColor(self.t.text)
52
+ self.label.setFont(QFont("Sans Serif", 8))
53
+
54
+ # --------------------------------------------------------------------------
55
+ # Properties
56
+ # --------------------------------------------------------------------------
57
+ @property
58
+ def is_input(self):
59
+ return self.instance.direction == "input"
60
+
61
+ # --------------------------------------------------------------
62
+ @property
63
+ def is_on_left_side(self) -> bool:
64
+ return self.pos().x() < (self.parent_block.rect().width() * 0.5)
65
+
66
+ # --------------------------------------------------------------------------
67
+ # Public methods
68
+ # --------------------------------------------------------------------------
69
+ def update_label_position(self):
70
+ label_rect = self.label.boundingRect()
71
+
72
+ if self.is_on_left_side:
73
+ self.label.setPos(
74
+ self.x() + self.R + self.MARGIN,
75
+ self.y() - label_rect.height() / 2,
76
+ )
77
+ else:
78
+ self.label.setPos(
79
+ self.x() - label_rect.width() - self.R - self.MARGIN,
80
+ self.y() - label_rect.height() / 2,
81
+ )
82
+
83
+ # --------------------------------------------------------------
84
+ def update_display_as(self):
85
+ self.label.setPlainText(self.instance.display_as)
86
+
87
+ # --------------------------------------------------------------
88
+ def connection_anchor(self) -> QPointF:
89
+ if self.is_input:
90
+ x = -self.R if self.is_on_left_side else self.R
91
+ local = QPointF(x, 0)
92
+ else:
93
+ x = self.L if not self.is_on_left_side else -self.L
94
+ local = QPointF(x, 0)
95
+ return self.mapToScene(local)
96
+
97
+ # --------------------------------------------------------------
98
+ def is_compatible(self, other: 'PortItem'):
99
+ return self.instance.direction != other.instance.direction
100
+
101
+ # --------------------------------------------------------------------------
102
+ # Visuals
103
+ # --------------------------------------------------------------------------
104
+ def boundingRect(self) -> QRectF:
105
+ return self.RECT
106
+
107
+ # --------------------------------------------------------------
108
+ def paint(self, painter, option, widget=None):
109
+ painter.setRenderHint(QPainter.Antialiasing)
110
+
111
+ fill = self.t.port_in if self.is_input else self.t.port_out
112
+
113
+ painter.setBrush(QBrush(fill))
114
+ painter.setPen(QPen(self.t.block_border, 1))
115
+
116
+ if self.is_input:
117
+ painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R)
118
+ else:
119
+ path = QPainterPath()
120
+ path.moveTo(0, -self.H)
121
+ path.lineTo(0, self.H)
122
+ tip_x = self.L if not self.is_on_left_side else - self.L
123
+ path.lineTo(tip_x, 0)
124
+ path.closeSubpath()
125
+ painter.drawPath(path)
126
+
127
+ # --------------------------------------------------------------
128
+ def shape(self):
129
+ path = QPainterPath()
130
+
131
+ if self.is_input:
132
+ path.addEllipse(-self.R, -self.R, 2*self.R, 2*self.R)
133
+ else:
134
+ tip_x = self.L if not self.is_on_left_side else -self.L
135
+ path.moveTo(0, -self.H)
136
+ path.lineTo(0, self.H)
137
+ path.lineTo(tip_x, 0)
138
+ path.closeSubpath()
139
+
140
+ return path
141
+
142
+ # --------------------------------------------------------------------------
143
+ # Event handlers
144
+ # --------------------------------------------------------------------------
145
+ def mousePressEvent(self, event):
146
+ self.parent_block.view.create_connection_event(self)
147
+ event.accept()
148
+
149
+ # --------------------------------------------------------------
150
+ def itemChange(self, change, value):
151
+ if change == QGraphicsItem.ItemScenePositionHasChanged:
152
+ self.update_label_position()
153
+ return super().itemChange(change, value)
@@ -0,0 +1,119 @@
1
+ # ******************************************************************************
2
+ # pySimBlocks
3
+ # Copyright (c) 2026 Université de Lille & INRIA
4
+ # ******************************************************************************
5
+ # This program is free software: you can redistribute it and/or modify it
6
+ # under the terms of the GNU Lesser General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or (at your
8
+ # option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ # ******************************************************************************
18
+ # Authors: see Authors.txt
19
+ # ******************************************************************************
20
+
21
+ from dataclasses import dataclass
22
+
23
+ from PySide6.QtGui import QColor, QPalette
24
+ from PySide6.QtWidgets import QApplication
25
+
26
+
27
+ def _luma(c: QColor) -> float:
28
+ r, g, b, _ = c.getRgb()
29
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
30
+
31
+
32
+ def _ensure_contrast(fg: QColor, bg: QColor, min_delta: float = 80.0) -> QColor:
33
+ """Ensure |luma(fg) - luma(bg)| >= min_delta by pushing fg to black/white."""
34
+ if abs(_luma(fg) - _luma(bg)) >= min_delta:
35
+ return fg
36
+
37
+ # push toward white on dark bg, toward black on light bg
38
+ if _luma(bg) < 128:
39
+ return QColor(235, 235, 235)
40
+ return QColor(25, 25, 25)
41
+
42
+
43
+ def _separate_bg(block: QColor, scene: QColor, delta: float = 35.0) -> QColor:
44
+ """Make sure block background is separated from scene background."""
45
+ if abs(_luma(block) - _luma(scene)) >= delta:
46
+ return block
47
+
48
+ # If scene is dark -> lighten block a bit; if scene is light -> darken block a bit
49
+ h, s, l, a = block.getHsl()
50
+ if _luma(scene) < 128:
51
+ l = min(255, l + 30)
52
+ else:
53
+ l = max(0, l - 30)
54
+ out = QColor()
55
+ out.setHsl(h, s, l, a)
56
+ return out
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Theme:
61
+ scene_bg: QColor
62
+
63
+ block_bg: QColor
64
+ block_bg_selected: QColor
65
+ block_border: QColor
66
+ block_border_selected: QColor
67
+
68
+ text: QColor
69
+ text_selected: QColor
70
+
71
+ wire: QColor
72
+
73
+ port_in: QColor
74
+ port_out: QColor
75
+
76
+
77
+ def make_theme() -> Theme:
78
+ pal = QApplication.palette()
79
+
80
+ scene_bg = pal.color(QPalette.Window)
81
+ is_dark = _luma(scene_bg) < 128
82
+
83
+ # base colors
84
+ if is_dark:
85
+ block_bg = QColor("#30343A")
86
+ block_bg_selected = QColor("#3A6FB0")
87
+ block_border = QColor("#ACACAC")
88
+ block_border_selected = QColor("#6FAEFF")
89
+ else:
90
+ block_bg = QColor("#ECECEC")
91
+ block_bg_selected = QColor("#C7DBFF")
92
+ block_border = QColor("#6E6E6E")
93
+ block_border_selected = QColor("#4A78FF")
94
+
95
+ text = QColor("#F0F0F0") if is_dark else QColor("#1E1E1E")
96
+ text_selected = QColor("#FFFFFF") if is_dark else QColor("#000000")
97
+ wire = QColor("#E6E6E6") if is_dark else QColor("#1A1A1A")
98
+
99
+ text = _ensure_contrast(text, block_bg, min_delta=90.0)
100
+ text_selected = _ensure_contrast(text_selected, block_bg_selected, min_delta=90.0)
101
+ block_border = _ensure_contrast(block_border, block_bg, min_delta=60.0)
102
+ block_border_selected = _ensure_contrast(block_border_selected, block_bg_selected, min_delta=60.0)
103
+ wire = _ensure_contrast(wire, scene_bg, min_delta=110.0)
104
+
105
+ port_in = QColor("#5FBF73")
106
+ port_out = QColor("#E5534B")
107
+
108
+ return Theme(
109
+ scene_bg=scene_bg,
110
+ block_bg=block_bg,
111
+ block_bg_selected=block_bg_selected,
112
+ block_border=block_border,
113
+ block_border_selected=block_border_selected,
114
+ text=text,
115
+ text_selected=text_selected,
116
+ wire=wire,
117
+ port_in=port_in,
118
+ port_out=port_out,
119
+ )
@@ -0,0 +1,162 @@
1
+ # ******************************************************************************
2
+ # pySimBlocks
3
+ # Copyright (c) 2026 Université de Lille & INRIA
4
+ # ******************************************************************************
5
+ # This program is free software: you can redistribute it and/or modify it
6
+ # under the terms of the GNU Lesser General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or (at your
8
+ # option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ # ******************************************************************************
18
+ # Authors: see Authors.txt
19
+ # ******************************************************************************
20
+
21
+ from pathlib import Path
22
+ from typing import Dict, List
23
+
24
+ from PySide6.QtCore import Qt, QTimer
25
+ from PySide6.QtGui import QAction, QKeySequence
26
+ from PySide6.QtWidgets import QMainWindow, QSplitter
27
+
28
+ from pySimBlocks.gui.blocks.block_meta import BlockMeta
29
+ from pySimBlocks.gui.dialogs.unsaved_dialog import UnsavedChangesDialog
30
+ from pySimBlocks.gui.models.project_state import ProjectState
31
+ from pySimBlocks.gui.project_controller import ProjectController
32
+ from pySimBlocks.gui.services.project_loader import ProjectLoaderYaml
33
+ from pySimBlocks.gui.services.project_saver import ProjectSaverYaml
34
+ from pySimBlocks.gui.services.simulation_runner import SimulationRunner
35
+ from pySimBlocks.gui.services.yaml_tools import cleanup_runtime_project_yaml
36
+ from pySimBlocks.gui.widgets.block_list import BlockList
37
+ from pySimBlocks.gui.widgets.diagram_view import DiagramView
38
+ from pySimBlocks.gui.widgets.toolbar_view import ToolBarView
39
+ from pySimBlocks.tools.blocks_registry import load_block_registry
40
+
41
+
42
+ class MainWindow(QMainWindow):
43
+ def __init__(self, project_path: Path):
44
+ super().__init__()
45
+
46
+ self.loader = ProjectLoaderYaml()
47
+ self.saver = ProjectSaverYaml()
48
+ self.runner = SimulationRunner()
49
+
50
+ self.block_registry = load_block_registry()
51
+
52
+ self.project_state = ProjectState(project_path)
53
+ self.view = DiagramView()
54
+ self.project_controller = ProjectController(self.project_state, self.view, self.resolve_block_meta)
55
+ self.view.project_controller = self.project_controller
56
+ self.blocks = BlockList(self.get_categories, self.get_blocks, self.resolve_block_meta)
57
+ self.toolbar = ToolBarView(self.saver, self.runner, self.project_controller)
58
+
59
+ splitter = QSplitter(Qt.Horizontal)
60
+ splitter.addWidget(self.blocks)
61
+ splitter.addWidget(self.view)
62
+ splitter.setSizes([180, 800])
63
+
64
+ self.setCentralWidget(splitter)
65
+ self.addToolBar(self.toolbar)
66
+
67
+ flag = self.auto_load_detection(project_path)
68
+ if flag:
69
+ self.project_controller.load_project(self.loader)
70
+
71
+ self.project_controller.dirty_changed.connect(self.update_window_title)
72
+ self.update_window_title()
73
+
74
+ self.save_action = QAction("Save", self)
75
+ self.save_action.setShortcut(QKeySequence.Save)
76
+ self.save_action.triggered.connect(self._on_save)
77
+ self.addAction(self.save_action)
78
+
79
+ self.quit_action = QAction("Quit", self)
80
+ self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
81
+ self.quit_action.triggered.connect(self.close)
82
+ self.addAction(self.quit_action)
83
+
84
+ # Ensure keyboard shortcuts (e.g. Space) work immediately on startup.
85
+ QTimer.singleShot(0, self.view.setFocus)
86
+
87
+ # --------------------------------------------------------------------------
88
+ # Registry
89
+ # --------------------------------------------------------------------------
90
+ def get_categories(self) -> List[str]:
91
+ return sorted(self.block_registry.keys())
92
+
93
+ # ------------------------------------------------------------------
94
+ def get_blocks(self, category: str) -> List[str]:
95
+ return sorted(self.block_registry.get(category, {}).keys())
96
+
97
+ # ------------------------------------------------------------------
98
+ def resolve_block_meta(self, category: str, block_type: str) -> BlockMeta:
99
+ return self.block_registry[category][block_type]
100
+
101
+ # --------------------------------------------------------------------------
102
+ # Auto Load
103
+ # --------------------------------------------------------------------------
104
+ def auto_load_detection(self, project_path: Path) -> bool:
105
+ project_yaml = self._auto_detect_yaml(project_path, ["project.yaml"])
106
+ return project_yaml is not None
107
+
108
+ # ------------------------------------------------------------------
109
+ def _auto_detect_yaml(self, project_path: Path, names: list[str]) -> str | None:
110
+ for name in names:
111
+ path = project_path / name
112
+ if path.is_file():
113
+ return str(path)
114
+ return None
115
+
116
+ # --------------------------------------------------------------------------
117
+ # Project Management
118
+ # --------------------------------------------------------------------------
119
+ def _on_save(self):
120
+ if not self.project_controller.is_dirty:
121
+ return
122
+ self.saver.save(self.project_controller.project_state, self.project_controller.view.block_items)
123
+ self.project_controller.clear_dirty()
124
+
125
+ # ------------------------------------------------------------------
126
+ def update_window_title(self):
127
+ path = self.project_state.directory_path
128
+ project_name = path.name if path else "Untitled"
129
+ star = "*" if self.project_controller.is_dirty else ""
130
+ self.setWindowTitle(f"{project_name}{star} – pySimBlocks")
131
+
132
+ # ------------------------------------------------------------------
133
+ def on_project_loaded(self, project_path: Path):
134
+ self.update_window_title()
135
+
136
+ # ------------------------------------------------------------------
137
+ def cleanup(self):
138
+ cleanup_runtime_project_yaml(self.project_state.directory_path)
139
+
140
+ # ------------------------------------------------------------------
141
+ def closeEvent(self, event):
142
+ if self.confirm_discard_or_save("closing"):
143
+ self.cleanup()
144
+ event.accept()
145
+ else:
146
+ event.ignore()
147
+
148
+ # ------------------------------------------------------------------
149
+ def confirm_discard_or_save(self, action_name: str) -> bool:
150
+ if not self.project_controller.is_dirty:
151
+ return True
152
+
153
+ dlg = UnsavedChangesDialog(action_name, self)
154
+ result = dlg.exec()
155
+
156
+ if result == UnsavedChangesDialog.SAVE:
157
+ self._on_save()
158
+ return True
159
+ elif result == UnsavedChangesDialog.DISCARD:
160
+ return True
161
+ else:
162
+ return False