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.
- examples/advanced/hardware/block_diagram.py +162 -0
- examples/advanced/hardware/camera.py +93 -0
- examples/advanced/hardware/gui_emio.py +231 -0
- examples/advanced/hardware/lmi.py +69 -0
- examples/advanced/hardware/main.py +57 -0
- examples/advanced/hardware/parameters.py +54 -0
- examples/advanced/qp_problem/params.py +10 -0
- examples/advanced/qp_problem/run.py +14 -0
- examples/advanced/sofa/gui/finger/Finger.py +108 -0
- examples/advanced/sofa/gui/finger/FingerController.py +53 -0
- examples/advanced/sofa/gui/finger/parameters_auto.py +22 -0
- examples/advanced/sofa/gui/sofa_plant/run.py +14 -0
- examples/advanced/sofa/python/Finger.py +107 -0
- examples/advanced/sofa/python/FingerController.py +48 -0
- examples/advanced/sofa/python/closedloop.py +64 -0
- examples/advanced/sofa/python/openloop.py +58 -0
- examples/basics/algebraic_function/g.py +7 -0
- examples/basics/algebraic_function/run.py +14 -0
- examples/basics/dc_motor/gui/run.py +14 -0
- examples/basics/dc_motor/multiple_order.py +91 -0
- examples/basics/dc_motor/openloop.py +62 -0
- examples/basics/dc_motor/pi.py +99 -0
- examples/basics/dc_motor/pid.py +118 -0
- examples/basics/dc_motor/pid_block.py +82 -0
- examples/basics/dc_motor/pid_block_vs_manual.py +143 -0
- examples/basics/external/parameters.py +4 -0
- examples/basics/external/run.py +14 -0
- examples/basics/file_sources/temp.py +16 -0
- examples/basics/observer.py +76 -0
- examples/basics/state_feedback/gui/parameters.py +9 -0
- examples/basics/state_feedback/gui/run.py +14 -0
- examples/basics/state_feedback/state_feedback.py +102 -0
- examples/basics/state_space/linear/gui/run.py +14 -0
- examples/basics/state_space/linear/ss_block.py +89 -0
- examples/basics/state_space/linear/ss_manual.py +105 -0
- examples/basics/state_space/non_linear/nlss.py +47 -0
- examples/basics/state_space/non_linear/run.py +14 -0
- examples/basics/state_space/polytope/params.py +39 -0
- examples/basics/state_space/polytope/weights.py +12 -0
- examples/comparison/simulink/convert.py +29 -0
- examples/comparison/simulink/openloop.py +127 -0
- examples/comparison/simulink/pi.py +148 -0
- examples/comparison/simulink/pid.py +154 -0
- examples/comparison/simulink/pid_backward.py +155 -0
- examples/quick_start/filter.py +35 -0
- examples/quick_start/gui/run.py +14 -0
- examples/tutorials/tutorial_1_python/main.py +99 -0
- examples/tutorials/tutorial_3_sofa/finger/Finger.py +120 -0
- examples/tutorials/tutorial_3_sofa/finger/FingerController.py +37 -0
- pySimBlocks/__init__.py +35 -0
- pySimBlocks/blocks/__init__.py +68 -0
- pySimBlocks/blocks/controllers/__init__.py +27 -0
- pySimBlocks/blocks/controllers/pid.py +250 -0
- pySimBlocks/blocks/controllers/state_feedback.py +141 -0
- pySimBlocks/blocks/interfaces/__init__.py +27 -0
- pySimBlocks/blocks/interfaces/external_input.py +107 -0
- pySimBlocks/blocks/interfaces/external_output.py +113 -0
- pySimBlocks/blocks/observers/__init__.py +23 -0
- pySimBlocks/blocks/observers/luenberger.py +175 -0
- pySimBlocks/blocks/operators/__init__.py +47 -0
- pySimBlocks/blocks/operators/algebraic_function.py +258 -0
- pySimBlocks/blocks/operators/dead_zone.py +186 -0
- pySimBlocks/blocks/operators/delay.py +254 -0
- pySimBlocks/blocks/operators/demux.py +123 -0
- pySimBlocks/blocks/operators/discrete_derivator.py +237 -0
- pySimBlocks/blocks/operators/discrete_integrator.py +265 -0
- pySimBlocks/blocks/operators/gain.py +294 -0
- pySimBlocks/blocks/operators/mux.py +131 -0
- pySimBlocks/blocks/operators/product.py +194 -0
- pySimBlocks/blocks/operators/rate_limiter.py +214 -0
- pySimBlocks/blocks/operators/saturation.py +178 -0
- pySimBlocks/blocks/operators/sum.py +188 -0
- pySimBlocks/blocks/operators/zero_order_hold.py +141 -0
- pySimBlocks/blocks/optimizers/__init__.py +23 -0
- pySimBlocks/blocks/optimizers/quadratic_program.py +326 -0
- pySimBlocks/blocks/sources/__init__.py +37 -0
- pySimBlocks/blocks/sources/chirp.py +168 -0
- pySimBlocks/blocks/sources/constant.py +82 -0
- pySimBlocks/blocks/sources/file_source.py +294 -0
- pySimBlocks/blocks/sources/function_source.py +197 -0
- pySimBlocks/blocks/sources/ramp.py +99 -0
- pySimBlocks/blocks/sources/sinusoidal.py +113 -0
- pySimBlocks/blocks/sources/step.py +138 -0
- pySimBlocks/blocks/sources/white_noise.py +107 -0
- pySimBlocks/blocks/systems/__init__.py +27 -0
- pySimBlocks/blocks/systems/linear_state_space.py +180 -0
- pySimBlocks/blocks/systems/non_linear_state_space.py +309 -0
- pySimBlocks/blocks/systems/polytopic_state_space.py +193 -0
- pySimBlocks/blocks/systems/sofa/__init__.py +30 -0
- pySimBlocks/blocks/systems/sofa/sofa_controller.py +472 -0
- pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py +120 -0
- pySimBlocks/blocks/systems/sofa/sofa_plant.py +306 -0
- pySimBlocks/cli.py +127 -0
- pySimBlocks/core/__init__.py +32 -0
- pySimBlocks/core/block.py +167 -0
- pySimBlocks/core/block_source.py +94 -0
- pySimBlocks/core/config.py +90 -0
- pySimBlocks/core/fixed_time_manager.py +46 -0
- pySimBlocks/core/model.py +250 -0
- pySimBlocks/core/scheduler.py +30 -0
- pySimBlocks/core/simulator.py +329 -0
- pySimBlocks/core/task.py +73 -0
- pySimBlocks/docs/blocks/controllers/pid.md +78 -0
- pySimBlocks/docs/blocks/controllers/state_feedback.md +60 -0
- pySimBlocks/docs/blocks/interfaces/external_input.md +39 -0
- pySimBlocks/docs/blocks/interfaces/external_output.md +39 -0
- pySimBlocks/docs/blocks/observers/luenberger.md +70 -0
- pySimBlocks/docs/blocks/operators/algebraic_function.md +76 -0
- pySimBlocks/docs/blocks/operators/dead_zone.md +67 -0
- pySimBlocks/docs/blocks/operators/delay.md +76 -0
- pySimBlocks/docs/blocks/operators/demux.md +64 -0
- pySimBlocks/docs/blocks/operators/discrete_derivator.md +61 -0
- pySimBlocks/docs/blocks/operators/discrete_integrator.md +87 -0
- pySimBlocks/docs/blocks/operators/gain.md +97 -0
- pySimBlocks/docs/blocks/operators/mux.md +64 -0
- pySimBlocks/docs/blocks/operators/product.md +51 -0
- pySimBlocks/docs/blocks/operators/rate_limiter.md +70 -0
- pySimBlocks/docs/blocks/operators/saturation.md +62 -0
- pySimBlocks/docs/blocks/operators/sum.md +49 -0
- pySimBlocks/docs/blocks/operators/zero_order_hold.md +61 -0
- pySimBlocks/docs/blocks/optimizers/quadratic_program.md +98 -0
- pySimBlocks/docs/blocks/sources/chirp.md +102 -0
- pySimBlocks/docs/blocks/sources/constant.md +53 -0
- pySimBlocks/docs/blocks/sources/file_source.md +51 -0
- pySimBlocks/docs/blocks/sources/function_source.md +52 -0
- pySimBlocks/docs/blocks/sources/ramp.md +58 -0
- pySimBlocks/docs/blocks/sources/sinusoidal.md +60 -0
- pySimBlocks/docs/blocks/sources/step.md +63 -0
- pySimBlocks/docs/blocks/sources/white_noise.md +58 -0
- pySimBlocks/docs/blocks/systems/linear_state_space.md +66 -0
- pySimBlocks/docs/blocks/systems/non_linear_state_space.md +90 -0
- pySimBlocks/docs/blocks/systems/polytopic_state_space.md +72 -0
- pySimBlocks/docs/blocks/systems/sofa/sofa_exchange_i_o.md +63 -0
- pySimBlocks/docs/blocks/systems/sofa/sofa_plant.md +62 -0
- pySimBlocks/gui/__init__.py +20 -0
- pySimBlocks/gui/addons/__init__.py +19 -0
- pySimBlocks/gui/addons/sofa/__init__.py +19 -0
- pySimBlocks/gui/addons/sofa/sofa_dialog.py +190 -0
- pySimBlocks/gui/addons/sofa/sofa_service.py +209 -0
- pySimBlocks/gui/blocks/block_dialog_session.py +47 -0
- pySimBlocks/gui/blocks/block_meta.py +408 -0
- pySimBlocks/gui/blocks/controllers/pid.py +116 -0
- pySimBlocks/gui/blocks/controllers/state_feedback.py +80 -0
- pySimBlocks/gui/blocks/interfaces/external_input.py +64 -0
- pySimBlocks/gui/blocks/interfaces/external_output.py +63 -0
- pySimBlocks/gui/blocks/observers/luenberger.py +108 -0
- pySimBlocks/gui/blocks/operators/algebraic_function.py +232 -0
- pySimBlocks/gui/blocks/operators/dead_zone.py +79 -0
- pySimBlocks/gui/blocks/operators/delay.py +79 -0
- pySimBlocks/gui/blocks/operators/demux.py +98 -0
- pySimBlocks/gui/blocks/operators/discrete_derivator.py +67 -0
- pySimBlocks/gui/blocks/operators/discrete_integrator.py +80 -0
- pySimBlocks/gui/blocks/operators/gain.py +79 -0
- pySimBlocks/gui/blocks/operators/mux.py +100 -0
- pySimBlocks/gui/blocks/operators/product.py +109 -0
- pySimBlocks/gui/blocks/operators/rate_limiter.py +79 -0
- pySimBlocks/gui/blocks/operators/saturation.py +72 -0
- pySimBlocks/gui/blocks/operators/sum.py +91 -0
- pySimBlocks/gui/blocks/operators/zero_order_hold.py +64 -0
- pySimBlocks/gui/blocks/optimizers/quadratic_program.py +128 -0
- pySimBlocks/gui/blocks/parameter_meta.py +34 -0
- pySimBlocks/gui/blocks/port_meta.py +31 -0
- pySimBlocks/gui/blocks/sources/chirp.py +146 -0
- pySimBlocks/gui/blocks/sources/constant.py +63 -0
- pySimBlocks/gui/blocks/sources/file_source.py +196 -0
- pySimBlocks/gui/blocks/sources/function_source.py +202 -0
- pySimBlocks/gui/blocks/sources/ramp.py +73 -0
- pySimBlocks/gui/blocks/sources/sinusoidal.py +81 -0
- pySimBlocks/gui/blocks/sources/step.py +83 -0
- pySimBlocks/gui/blocks/sources/white_noise.py +70 -0
- pySimBlocks/gui/blocks/systems/linear_state_space.py +102 -0
- pySimBlocks/gui/blocks/systems/non_linear_state_space.py +233 -0
- pySimBlocks/gui/blocks/systems/polytopic_state_space.py +109 -0
- pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +221 -0
- pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +220 -0
- pySimBlocks/gui/dialogs/__init__.py +19 -0
- pySimBlocks/gui/dialogs/block_dialog.py +125 -0
- pySimBlocks/gui/dialogs/display_yaml_dialog.py +89 -0
- pySimBlocks/gui/dialogs/help_dialog.py +39 -0
- pySimBlocks/gui/dialogs/plot_dialog.py +230 -0
- pySimBlocks/gui/dialogs/settings/plots.py +187 -0
- pySimBlocks/gui/dialogs/settings/project.py +146 -0
- pySimBlocks/gui/dialogs/settings/simulation.py +137 -0
- pySimBlocks/gui/dialogs/settings_dialog.py +88 -0
- pySimBlocks/gui/dialogs/unsaved_dialog.py +92 -0
- pySimBlocks/gui/editor.py +49 -0
- pySimBlocks/gui/graphics/__init__.py +29 -0
- pySimBlocks/gui/graphics/block_item.py +289 -0
- pySimBlocks/gui/graphics/connection_item.py +266 -0
- pySimBlocks/gui/graphics/port_item.py +153 -0
- pySimBlocks/gui/graphics/theme.py +119 -0
- pySimBlocks/gui/main_window.py +162 -0
- pySimBlocks/gui/models/__init__.py +31 -0
- pySimBlocks/gui/models/block_instance.py +114 -0
- pySimBlocks/gui/models/connection_instance.py +43 -0
- pySimBlocks/gui/models/port_instance.py +52 -0
- pySimBlocks/gui/models/project_simulation_params.py +50 -0
- pySimBlocks/gui/models/project_state.py +114 -0
- pySimBlocks/gui/project_controller.py +302 -0
- pySimBlocks/gui/services/__init__.py +20 -0
- pySimBlocks/gui/services/project_loader.py +381 -0
- pySimBlocks/gui/services/project_saver.py +68 -0
- pySimBlocks/gui/services/simulation_runner.py +66 -0
- pySimBlocks/gui/services/yaml_tools.py +261 -0
- pySimBlocks/gui/widgets/__init__.py +19 -0
- pySimBlocks/gui/widgets/block_list.py +131 -0
- pySimBlocks/gui/widgets/diagram_view.py +312 -0
- pySimBlocks/gui/widgets/toolbar_view.py +161 -0
- pySimBlocks/project/__init__.py +35 -0
- pySimBlocks/project/build_model.py +92 -0
- pySimBlocks/project/generate_run_script.py +109 -0
- pySimBlocks/project/generate_sofa_controller.py +215 -0
- pySimBlocks/project/load_project_config.py +197 -0
- pySimBlocks/project/load_simulation_config.py +166 -0
- pySimBlocks/project/load_simulator.py +45 -0
- pySimBlocks/project/plot_from_config.py +174 -0
- pySimBlocks/project/pySimBlocks_blocks_index.yaml +106 -0
- pySimBlocks/real_time/__init__.py +23 -0
- pySimBlocks/real_time/real_time_runner.py +148 -0
- pySimBlocks/tools/__init__.py +19 -0
- pySimBlocks/tools/blocks_registry.py +141 -0
- pySimBlocks/tools/generate_blocks_index.py +124 -0
- pysimblocks-0.1.0.dist-info/METADATA +180 -0
- pysimblocks-0.1.0.dist-info/RECORD +262 -0
- pysimblocks-0.1.0.dist-info/WHEEL +5 -0
- pysimblocks-0.1.0.dist-info/entry_points.txt +2 -0
- pysimblocks-0.1.0.dist-info/licenses/LICENSE.md +165 -0
- pysimblocks-0.1.0.dist-info/top_level.txt +3 -0
- tests/blocks/controllers/test_pid.py +143 -0
- tests/blocks/controllers/test_state_feedback.py +193 -0
- tests/blocks/observers/test_luenberger.py +225 -0
- tests/blocks/operators/test_algebraic_function.py +230 -0
- tests/blocks/operators/test_dead_zone.py +106 -0
- tests/blocks/operators/test_delay.py +167 -0
- tests/blocks/operators/test_demux.py +111 -0
- tests/blocks/operators/test_discrete_derivator.py +160 -0
- tests/blocks/operators/test_discrete_integrator.py +149 -0
- tests/blocks/operators/test_gain.py +182 -0
- tests/blocks/operators/test_mux.py +101 -0
- tests/blocks/operators/test_product.py +171 -0
- tests/blocks/operators/test_rate_limiter.py +166 -0
- tests/blocks/operators/test_saturation.py +157 -0
- tests/blocks/operators/test_sum.py +183 -0
- tests/blocks/operators/test_zero_order_hold.py +106 -0
- tests/blocks/sources/test_chirp.py +157 -0
- tests/blocks/sources/test_constant.py +55 -0
- tests/blocks/sources/test_file_source.py +185 -0
- tests/blocks/sources/test_function_source.py +135 -0
- tests/blocks/sources/test_ramp.py +110 -0
- tests/blocks/sources/test_sinusoidal.py +113 -0
- tests/blocks/sources/test_step.py +76 -0
- tests/blocks/sources/test_white_noise.py +108 -0
- tests/blocks/systems/test_linear_state_space.py +135 -0
- tests/blocks/systems/test_polytopic_state_space.py +133 -0
- tests/core/test_core_errors_and_determinism.py +82 -0
- tests/core/test_core_multirate_tasks.py +88 -0
- tests/core/test_core_two_phase_and_propagation.py +132 -0
- tests/gui/main_window_open_test.py +162 -0
- tests/gui/test_block_meta_param_cache.py +16 -0
- tests/gui/test_file_source_meta.py +17 -0
- tests/gui/test_function_source_meta.py +28 -0
- 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
|