2026-02-20 12:07:13 +00:00
|
|
|
import numpy as np
|
|
|
|
|
from PySide6.QtCore import QPointF, Qt
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 15:33:30 +00:00
|
|
|
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()
|