import numpy as np from PySide6.QtCore import QPointF, Qt class CanvasInteractionMixin: @staticmethod def _distance_sq(pos_xy, point_xy): dx = float(pos_xy[0] - point_xy[0]) dy = float(pos_xy[1] - point_xy[1]) return dx * dx + dy * dy def _iter_slice_indices_for_hit_test(self): active = self.active_slice_index if active is not None and 0 <= int(active) < len(self.slices): active_idx = int(active) yield active_idx for si in reversed(range(len(self.slices))): if si != active_idx: yield si return for si in reversed(range(len(self.slices))): yield si def _find_corner(self, pos): p = np.array([pos.x(), pos.y()], dtype=np.float64) hit_radius = float(getattr(self, "CORNER_HANDLE_HIT_RADIUS", 12.0)) radius_sq = hit_radius * hit_radius for si in self._iter_slice_indices_for_hit_test(): s = self.slices[si] for ci, c in enumerate(s.bbox): view_c = self._image_to_view(c) if self._distance_sq(p, (view_c.x(), view_c.y())) <= radius_sq: return si, ci return None def _point_in_polygon(self, point, polygon): x, y = point inside = False vertices = np.array(polygon, dtype=np.float64).reshape(-1, 2) count = len(vertices) for i in range(count): x1, y1 = vertices[i] x2, y2 = vertices[(i + 1) % count] if (y1 > y) != (y2 > y): xinters = (x2 - x1) * (y - y1) / ((y2 - y1) + 1e-12) + x1 if x <= xinters: inside = not inside return inside def _find_slice_containing_point(self, pos): point_img = self._view_to_image(pos) for si in reversed(range(len(self.slices))): if self._point_in_polygon(point_img, self.slices[si].bbox): return si return None def _find_side_handle(self, pos): p = np.array([pos.x(), pos.y()], dtype=np.float64) hit_radius = float(getattr(self, "SIDE_HANDLE_HIT_RADIUS", 14.0)) radius_sq = hit_radius * hit_radius for si in self._iter_slice_indices_for_hit_test(): s = self.slices[si] if not s.side_handles_enabled or not s.is_perfect_rectangle(): continue for ei in range(4): mid = (s.bbox[ei] + s.bbox[(ei + 1) % 4]) / 2.0 mid_view = self._image_to_view(mid) if self._distance_sq(p, (mid_view.x(), mid_view.y())) <= radius_sq: return si, ei return None def _try_begin_handle_drag(self, pos): # Side handles are checked before corners to make squarized edge-drag # interactions easier to grab. side = self._find_side_handle(pos) if side is not None: si, ei = side self.set_active_slice(si) self.side_dragging = (si, ei) return True corner = self._find_corner(pos) if corner is not None: si, _ = corner if self.rotate_handles_mode: self.set_active_slice(si) self.rotation_dragging = self._start_rotation_drag(si, pos) else: self.corner_dragging = corner return True return False def _drag_side(self, si, ei, pos): s = self.slices[si] pts = np.array(s.bbox, dtype=np.float64).reshape(4, 2) i0 = ei i1 = (ei + 1) % 4 o0 = (ei + 2) % 4 o1 = (ei + 3) % 4 mid_edge = (pts[i0] + pts[i1]) / 2.0 mid_opp = (pts[o0] + pts[o1]) / 2.0 normal = mid_opp - mid_edge normal_len = np.linalg.norm(normal) if normal_len < 1e-6: return normal /= normal_len pointer_img = self._view_to_image(self._clamp_view_pos_to_canvas(pos)) delta = np.dot(pointer_img - pts[i0], normal) max_delta = normal_len - 1.0 if max_delta <= 0.0: return delta = min(delta, max_delta) shift = normal * delta pts[i0] += shift pts[i1] += shift s.bbox = self._clamp_bbox_to_image(pts) def _start_rotation_drag(self, si, pos): s = self.slices[si] center = np.mean(s.bbox, axis=0) pointer = self._view_to_image(self._clamp_view_pos_to_canvas(pos)) vector = pointer - center if np.linalg.norm(vector) < 1e-6: return None return { "slice_index": si, "center": center, "start_angle": np.arctan2(vector[1], vector[0]), "start_bbox": np.array(s.bbox, dtype=np.float64), } def _drag_rotation(self, pos): if self.rotation_dragging is None: return si = self.rotation_dragging["slice_index"] if not (0 <= si < len(self.slices)): return center = self.rotation_dragging["center"] pointer = self._view_to_image(self._clamp_view_pos_to_canvas(pos)) vector = pointer - center if np.linalg.norm(vector) < 1e-6: return angle = np.arctan2(vector[1], vector[0]) delta = angle - self.rotation_dragging["start_angle"] cos_a = np.cos(delta) sin_a = np.sin(delta) rotation = np.array( [ [cos_a, -sin_a], [sin_a, cos_a], ], dtype=np.float64, ) start_bbox = self.rotation_dragging["start_bbox"] rotated = (start_bbox - center) @ rotation.T + center self.slices[si].bbox = self._clamp_bbox_to_image(rotated) def _start_box_drag(self, si, pos): return { "slice_index": si, "start_pointer": self._view_to_image(self._clamp_view_pos_to_canvas(pos)), "start_bbox": np.array(self.slices[si].bbox, dtype=np.float64), } def _drag_box(self, pos): if self.box_dragging is None: return si = self.box_dragging["slice_index"] if not (0 <= si < len(self.slices)): return pointer = self._view_to_image(self._clamp_view_pos_to_canvas(pos)) delta = pointer - self.box_dragging["start_pointer"] moved = self.box_dragging["start_bbox"] + delta self.slices[si].bbox = self._clamp_bbox_to_image(moved) def mousePressEvent(self, event): pos = event.position() if event.button() == Qt.RightButton: self.panning = True self.last_mouse_pos = pos return if event.button() != Qt.LeftButton: return if self.image is None: self.set_active_slice(None) return if not self._image_rect_view().contains(pos): # Allow selecting edge handles in a small margin outside the # displayed image, then fall back to the black-area deactivate rule. edge_margin = max( float(getattr(self, "CORNER_HANDLE_HIT_RADIUS", 12.0)), float(getattr(self, "SIDE_HANDLE_HIT_RADIUS", 14.0)), ) if self._image_rect_view().adjusted(-edge_margin, -edge_margin, edge_margin, edge_margin).contains(pos): if self._try_begin_handle_drag(pos): return self.set_active_slice(None) return if self._try_begin_handle_drag(pos): return inside = self._find_slice_containing_point(pos) if inside is not None: self.set_active_slice(inside) def mouseDoubleClickEvent(self, event): if event.button() != Qt.LeftButton or self.image is None: return pos = event.position() inside = self._find_slice_containing_point(pos) if inside is None: return self.corner_dragging = None self.side_dragging = None self.rotation_dragging = None self.set_active_slice(inside) self.box_dragging = self._start_box_drag(inside, pos) def mouseMoveEvent(self, event): pos = event.position() if self.panning: self.user_transformed_view = True delta = pos - self.last_mouse_pos self.pan_offset = QPointF(self.pan_offset.x() + delta.x(), self.pan_offset.y() + delta.y()) self.last_mouse_pos = pos self.update() return if self.corner_dragging is not None: si, ci = self.corner_dragging p = self._view_to_image(self._clamp_view_pos_to_canvas(pos)) p = self._clamp_image_point(p) self.slices[si].update_corner(ci, p[0], p[1]) self.update() return if self.rotation_dragging is not None: self._drag_rotation(pos) self.update() return if self.side_dragging is not None: si, ei = self.side_dragging self._drag_side(si, ei, pos) self.update() return if self.box_dragging is not None: self._drag_box(pos) self.update() def mouseReleaseEvent(self, event): if event.button() == Qt.RightButton: self.panning = False if event.button() == Qt.LeftButton: self.corner_dragging = None self.side_dragging = None self.rotation_dragging = None self.box_dragging = None def wheelEvent(self, event): if self.image is None: return pos = event.position() if not self.rect().contains(pos.toPoint()): return delta = event.angleDelta().y() if delta == 0: return scale = 1.05 if delta > 0 else (1.0 / 1.05) new_zoom = self.zoom * scale if new_zoom < 0.01 or new_zoom > 20.0: return self.user_transformed_view = True image_pos = self._view_to_image(pos) self.zoom = new_zoom self.pan_offset = QPointF( pos.x() - image_pos[0] * self.zoom, pos.y() - image_pos[1] * self.zoom, ) self.update() def resizeEvent(self, event): super().resizeEvent(event) if self.image is not None and not self.user_transformed_view: self.fit_image_to_view() self.update()