feat(canvas_interaction): implement class CanvasInteractionMixin

This commit is contained in:
dev 2026-02-20 15:33:30 +00:00
parent ba1b8ffb61
commit a194631b60

View file

@ -3,3 +3,330 @@ from PySide6.QtCore import QPointF, Qt
# CanvasInteractionMixin: class definition for ScanSlicer behavior. # 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()