diff --git a/examples/generate/closed_chain/layout.yaml b/examples/generate/closed_chain/layout.yaml index f3af269..d695568 100644 --- a/examples/generate/closed_chain/layout.yaml +++ b/examples/generate/closed_chain/layout.yaml @@ -4,15 +4,23 @@ blocks: x: 0.0 y: -80.0 orientation: normal + width: 120.0 + height: 60.0 error: x: 195.0 y: -71.0 orientation: normal + width: 120.0 + height: 60.0 pid: - x: 364.0 - y: -71.0 + x: 365.0 + y: -70.0 orientation: normal + width: 115.0 + height: 60.0 plant: x: 535.0 y: -70.0 orientation: normal + width: 120.0 + height: 60.0 diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py index 7b3db5b..6aa6972 100644 --- a/pySimBlocks/gui/graphics/block_item.py +++ b/pySimBlocks/gui/graphics/block_item.py @@ -20,8 +20,8 @@ from typing import TYPE_CHECKING -from PySide6.QtCore import QPoint, QPointF, Qt -from PySide6.QtGui import QPen +from PySide6.QtCore import QPoint, QPointF, QRectF, Qt +from PySide6.QtGui import QPainterPath, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle from pySimBlocks.gui.dialogs.block_dialog import BlockDialog @@ -35,8 +35,12 @@ class BlockItem(QGraphicsRectItem): WIDTH = 120 HEIGHT = 60 + MIN_WIDTH = 40 + MIN_HEIGHT = 30 GRID_DX = 5 GRID_DY = 5 + SELECTION_HANDLE_SIZE = 8 + SELECTION_HANDLE_HIT_SIZE = 16 def __init__(self, instance: "BlockInstance", @@ -44,11 +48,22 @@ def __init__(self, view: "DiagramView", layout: dict | None = None, ): - super().__init__(0, 0, self.WIDTH, self.HEIGHT) + layout = layout or {} + width = layout.get("width", self.WIDTH) + height = layout.get("height", self.HEIGHT) + width = float(width) if isinstance(width, (int, float)) else float(self.WIDTH) + height = float(height) if isinstance(height, (int, float)) else float(self.HEIGHT) + width = max(float(self.MIN_WIDTH), width) + height = max(float(self.MIN_HEIGHT), height) + super().__init__(0, 0, width, height) self.view = view self.instance = instance - layout = layout or {} self.orientation = layout.get("orientation", "normal") + self._resize_handle: str | None = None + self._resize_start_mouse: QPointF | None = None + self._resize_start_pos: QPointF | None = None + self._resize_start_width = self.WIDTH + self._resize_start_height = self.HEIGHT self.setPos(pos) self.setFlag(QGraphicsRectItem.ItemIsMovable) @@ -96,10 +111,24 @@ def toggle_orientation(self): # -------------------------------------------------------------------------- # Visual Methods # -------------------------------------------------------------------------- + def boundingRect(self) -> QRectF: + half = self.SELECTION_HANDLE_HIT_SIZE / 2 + return self.rect().adjusted(-half, -half, half, half) + + # -------------------------------------------------------------- + def shape(self) -> QPainterPath: + path = QPainterPath() + path.addRect(self.rect()) + for rect in self._handle_hit_rects().values(): + path.addRect(rect) + return path + + # -------------------------------------------------------------- def paint(self, painter, option, widget=None): t = self.view.theme + selected = bool(option.state & QStyle.State_Selected) - if option.state & QStyle.State_Selected: + if selected: painter.setBrush(t.block_bg_selected) painter.setPen(QPen(t.block_border_selected, 3)) else: @@ -107,15 +136,88 @@ def paint(self, painter, option, widget=None): painter.setPen(QPen(t.block_border, 3)) painter.drawRect(self.rect()) - if option.state & QStyle.State_Selected: + if selected: painter.setPen(t.text_selected) else: painter.setPen(t.text) painter.drawText(self.rect(), Qt.AlignCenter, self.instance.name) + if selected: + half = self.SELECTION_HANDLE_SIZE / 2 + r = self.rect() + corners = [ + (r.left(), r.top()), + (r.right(), r.top()), + (r.left(), r.bottom()), + (r.right(), r.bottom()), + ] + + painter.setPen(QPen(t.block_border_selected, 1)) + painter.setBrush(t.text_selected) + for x, y in corners: + painter.drawRect(x - half, y - half, self.SELECTION_HANDLE_SIZE, self.SELECTION_HANDLE_SIZE) + # -------------------------------------------------------------------------- # Event Methods # -------------------------------------------------------------------------- + def mousePressEvent(self, event): + if self.isSelected(): + handle = self._handle_at(event.pos()) + if handle is not None: + self._resize_handle = handle + self._resize_start_mouse = event.scenePos() + self._resize_start_pos = self.pos() + self._resize_start_width = self.rect().width() + self._resize_start_height = self.rect().height() + event.accept() + return + + super().mousePressEvent(event) + + # -------------------------------------------------------------- + def mouseMoveEvent(self, event): + if self._resize_handle and self._resize_start_mouse and self._resize_start_pos: + delta = event.scenePos() - self._resize_start_mouse + dx = round(delta.x() / self.GRID_DX) * self.GRID_DX + dy = round(delta.y() / self.GRID_DY) * self.GRID_DY + + start_x = self._resize_start_pos.x() + start_y = self._resize_start_pos.y() + start_w = self._resize_start_width + start_h = self._resize_start_height + + if self._resize_handle in ("tl", "bl"): # drag left edge + new_x = min(start_x + dx, start_x + start_w - self.MIN_WIDTH) + new_w = max(self.MIN_WIDTH, (start_x + start_w) - new_x) + else: # drag right edge + new_x = start_x + new_w = max(self.MIN_WIDTH, start_w + dx) + + if self._resize_handle in ("tl", "tr"): # drag top edge + new_y = min(start_y + dy, start_y + start_h - self.MIN_HEIGHT) + new_h = max(self.MIN_HEIGHT, (start_y + start_h) - new_y) + else: # drag bottom edge + new_y = start_y + new_h = max(self.MIN_HEIGHT, start_h + dy) + + self.setPos(QPointF(new_x, new_y)) + self.setRect(0, 0, new_w, new_h) + self._layout_ports() + self.view.on_block_moved(self) + self.update() + event.accept() + return + + super().mouseMoveEvent(event) + + # -------------------------------------------------------------- + def mouseReleaseEvent(self, event): + self._resize_handle = None + self._resize_start_mouse = None + self._resize_start_pos = None + super().mouseReleaseEvent(event) + + # -------------------------------------------------------------- def mouseDoubleClickEvent(self, event): dialog = BlockDialog(self, readonly=False) dialog.exec() @@ -137,17 +239,36 @@ def itemChange(self, change, value): # -------------------------------------------------------------------------- # Private Methods # -------------------------------------------------------------------------- + def _handle_hit_rects(self) -> dict[str, QRectF]: + half = self.SELECTION_HANDLE_HIT_SIZE / 2 + r = self.rect() + return { + "tl": QRectF(r.left() - half, r.top() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE), + "tr": QRectF(r.right() - half, r.top() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE), + "bl": QRectF(r.left() - half, r.bottom() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE), + "br": QRectF(r.right() - half, r.bottom() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE), + } + + # -------------------------------------------------------------- + def _handle_at(self, local_pos: QPointF) -> str | None: + for name, rect in self._handle_hit_rects().items(): + if rect.contains(local_pos): + return name + return None + + # -------------------------------------------------------------- def _layout_ports(self): inputs = [p for p in self.port_items if p.is_input] outputs = [p for p in self.port_items if not p.is_input] flipped = self.orientation == "flipped" + width = self.rect().width() if not flipped: self._layout_side(inputs, x=0) - self._layout_side(outputs, x=self.WIDTH) + self._layout_side(outputs, x=width) else: - self._layout_side(inputs, x=self.WIDTH) + self._layout_side(inputs, x=width) self._layout_side(outputs, x=0) # -------------------------------------------------------------- @@ -155,7 +276,7 @@ def _layout_side(self, ports, x): if not ports: return - step = self.HEIGHT / (len(ports) + 1) + step = self.rect().height() / (len(ports) + 1) for i, port in enumerate(ports, start=1): port.setPos(x, i * step) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index f4d16e7..573514d 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -124,8 +124,9 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: src_block = self.src_port.parent_block dst_block = self.dst_port.parent_block - src_rect = src_block.sceneBoundingRect() - dst_rect = dst_block.sceneBoundingRect() + # Use the visual block rect (not selection handle hit area) for routing. + src_rect = src_block.mapRectToScene(src_block.rect()) + dst_rect = dst_block.mapRectToScene(dst_block.rect()) src_out_sign = 1 if not self.src_port.is_on_left_side else -1 dst_in_sign = -1 if self.dst_port.is_on_left_side else 1 diff --git a/pySimBlocks/gui/graphics/port_item.py b/pySimBlocks/gui/graphics/port_item.py index 5e723d1..2ba761c 100644 --- a/pySimBlocks/gui/graphics/port_item.py +++ b/pySimBlocks/gui/graphics/port_item.py @@ -61,7 +61,7 @@ def is_input(self): # -------------------------------------------------------------- @property def is_on_left_side(self) -> bool: - return self.pos().x() < (self.parent_block.WIDTH * 0.5) + return self.pos().x() < (self.parent_block.rect().width() * 0.5) # -------------------------------------------------------------------------- # Public methods diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 2260ace..3fbf260 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -63,7 +63,7 @@ def _load_blocks(self, category = block["category"] block_type = block["type"] - block_layout = layout_blocks.get(name, {}) + block_layout = self._sanitize_block_layout(layout_blocks.get(name, {})) controller.view.drop_event_pos = positions.get(name, QPointF(0, 0)) block = controller.add_block(category, block_type, block_layout) @@ -78,6 +78,26 @@ def _load_blocks(self, block.resolve_ports() controller.view.refresh_block_port(block) + def _sanitize_block_layout(self, block_layout: dict | None) -> dict: + if not isinstance(block_layout, dict): + return {} + + out = {} + + orientation = block_layout.get("orientation") + if orientation in {"normal", "flipped"}: + out["orientation"] = orientation + + width = block_layout.get("width") + if isinstance(width, (int, float)) and width > 0: + out["width"] = float(width) + + height = block_layout.get("height") + if isinstance(height, (int, float)) and height > 0: + out["height"] = float(height) + + return out + def _load_connections(self, controller: ProjectController, model_data: dict, diff --git a/pySimBlocks/gui/services/project_saver.py b/pySimBlocks/gui/services/project_saver.py index 4039796..003aefd 100644 --- a/pySimBlocks/gui/services/project_saver.py +++ b/pySimBlocks/gui/services/project_saver.py @@ -24,17 +24,17 @@ def save(self, project_state: ProjectState, block_items: dict[str, BlockItem] | None = None ): - save_yaml(project_state, block_items) + save_yaml(project_state, block_items if block_items is not None else {}) def export(self, project_state: ProjectState, block_items: dict[str, BlockItem] | None = None ): - save_yaml(project_state, block_items) + save_yaml(project_state, block_items if block_items is not None else {}) run_py = project_state.directory_path / "run.py" run_py.write_text( generate_python_content( model_yaml_path="model.yaml", parameters_yaml_path="parameters.yaml" ) - ) \ No newline at end of file + ) diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index 601e3a5..7a30722 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -172,7 +172,7 @@ def save_yaml( (directory / "parameters.yaml").write_text(dump_parameter_yaml(raw=params_yaml)) (directory / "model.yaml").write_text(dump_model_yaml(raw=model_yaml)) - if not temp and block_items: + if not temp and block_items is not None: layout_yaml = build_layout_yaml(block_items) (directory / "layout.yaml").write_text(dump_layout_yaml(raw=layout_yaml)) @@ -244,7 +244,9 @@ def build_layout_yaml(block_items: dict[str, BlockItem]) -> dict: data["blocks"][name] = { "x": float(pos.x()), "y": float(pos.y()), - "orientation": block.orientation + "orientation": block.orientation, + "width": float(block.rect().width()), + "height": float(block.rect().height()), } view = block.view for conn in view.connections.values():