Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions examples/generate/closed_chain/layout.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
139 changes: 130 additions & 9 deletions pySimBlocks/gui/graphics/block_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,20 +35,35 @@
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",
pos: QPointF | QPoint,
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)
Expand Down Expand Up @@ -96,26 +111,113 @@ 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:
painter.setBrush(t.block_bg)
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()
Expand All @@ -137,25 +239,44 @@ 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)

# --------------------------------------------------------------
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)
Expand Down
5 changes: 3 additions & 2 deletions pySimBlocks/gui/graphics/connection_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pySimBlocks/gui/graphics/port_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion pySimBlocks/gui/services/project_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions pySimBlocks/gui/services/project_saver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
)
)
6 changes: 4 additions & 2 deletions pySimBlocks/gui/services/yaml_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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():
Expand Down