diff --git a/scanslicer/canvas_interaction.py b/scanslicer/canvas_interaction.py index 59b707e..5849984 100644 --- a/scanslicer/canvas_interaction.py +++ b/scanslicer/canvas_interaction.py @@ -3,3 +3,330 @@ from PySide6.QtCore import QPointF, Qt # CanvasInteractionMixin: class definition for ScanSlicer behavior. +class CanvasInteractionMixin: + @staticmethod + # _distance_sq: function definition for ScanSlicer behavior. + 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 + + # _iter_slice_indices_for_hit_test: function definition for ScanSlicer behavior. + 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 + + # _find_corner: function definition for ScanSlicer behavior. + 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 + + # _point_in_polygon: function definition for ScanSlicer behavior. + 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 + + # _find_slice_containing_point: function definition for ScanSlicer behavior. + 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 + + # _find_side_handle: function definition for ScanSlicer behavior. + 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 + + # _try_begin_handle_drag: function definition for ScanSlicer behavior. + 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 + + # _drag_side: function definition for ScanSlicer behavior. + 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) + + # _start_rotation_drag: function definition for ScanSlicer behavior. + 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), + } + + # _drag_rotation: function definition for ScanSlicer behavior. + 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) + + # _start_box_drag: function definition for ScanSlicer behavior. + 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), + } + + # _drag_box: function definition for ScanSlicer behavior. + 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) + + # mousePressEvent: function definition for ScanSlicer behavior. + 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) + + # mouseDoubleClickEvent: function definition for ScanSlicer behavior. + 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) + + # mouseMoveEvent: function definition for ScanSlicer behavior. + 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() + + # mouseReleaseEvent: function definition for ScanSlicer behavior. + 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 + + # wheelEvent: function definition for ScanSlicer behavior. + 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() + + # resizeEvent: function definition for ScanSlicer behavior. + 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()