feat: implement image canvas with drag/scale/rotate and preview zoom
Replace placeholder ImageCanvas with full QGraphicsView/QGraphicsScene editor: Layout: ImageCanvas(QWidget) = top bar + _CanvasView(QGraphicsView) + hint bar Top bar: garment/print filenames, mode buttons (移动/缩放/旋转, context-aware), zoom controls (−/% label/+, scroll wheel). _CanvasView: - drawBackground: checkerboard in garment area, gray outside - wheelEvent: zoom in/out anchored at cursor - mousePressEvent/mouseMoveEvent/mouseReleaseEvent: delegate to ImageCanvas Scene (1:1 with garment pixel coords): - garment QGraphicsPixmapItem at (0,0), not interactive - print QGraphicsPixmapItem positioned/rotated from TransformState - cosmetic selection rect (blue, 1.5px) - 4 corner handles (white circle, blue border) for scaling - rotation arm (dashed) + handle (blue circle) above top-center Interaction: - Hit test: rotation handle > corner handles > print body (priority order) - Move: delta in scene coords added to TransformState.x/y - Scale: _scene_to_local_xy() maps mouse to item local coords, computes new size keeping opposite corner fixed (_do_scale); supports aspect ratio lock via Shift or keep_aspect_ratio flag; correct with rotation - Rotate: atan2(mouse - center) + angle offset at press Coordinate helpers (static): - _local_to_scene_xy(lx, ly, state): item-local → scene - _scene_to_local_xy(sx, sy, state): scene → item-local (inverse rotation) Both use TransformState snapshot, not live item state, to avoid stale-pixmap issues. transform_changed signal fires on mouse release with final TransformState. Preview zoom changes only QGraphicsView transform; TransformState is unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,592 @@
|
||||
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QGraphicsEllipseItem,
|
||||
QGraphicsLineItem,
|
||||
QGraphicsPixmapItem,
|
||||
QGraphicsRectItem,
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from core.models import TransformState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── constants ─────────────────────────────────────────────────────────────────
|
||||
_HANDLE_R = 5 # handle circle radius (scene px, cosmetic)
|
||||
_ROT_GAP = 24 # rotation handle offset above print top-center (scene px)
|
||||
_MIN_SIZE = 10.0 # minimum print dimension (scene px)
|
||||
_ZOOM_STEP = 1.25
|
||||
_ZOOM_MIN = 0.04
|
||||
_ZOOM_MAX = 20.0
|
||||
_CHECKER_TILE = 12 # checkerboard tile size (scene px)
|
||||
|
||||
# Item data(0) tags for hit testing
|
||||
_T_PRINT = 'print'
|
||||
_T_ROT = 'rot'
|
||||
_T_TL = 'tl'; _T_TR = 'tr'
|
||||
_T_BR = 'br'; _T_BL = 'bl'
|
||||
_CORNER_TAGS = (_T_TL, _T_TR, _T_BR, _T_BL)
|
||||
|
||||
|
||||
class ImageCanvas(QGraphicsView):
|
||||
# ── inner view ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _CanvasView(QGraphicsView):
|
||||
"""QGraphicsView that delegates interaction to the parent ImageCanvas."""
|
||||
|
||||
def __init__(self, canvas: 'ImageCanvas', parent=None):
|
||||
super().__init__(parent)
|
||||
self._canvas = canvas
|
||||
self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
|
||||
self.setDragMode(QGraphicsView.NoDrag)
|
||||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||||
self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
|
||||
self.setBackgroundBrush(QColor('#9a9da3'))
|
||||
self.setStyleSheet('border: none; background-color: #9a9da3;')
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect):
|
||||
super().drawBackground(painter, rect)
|
||||
garment = self._canvas._garment_item
|
||||
if garment is None:
|
||||
return
|
||||
gr = garment.mapToScene(garment.boundingRect()).boundingRect()
|
||||
t = _CHECKER_TILE
|
||||
light, dark = QColor('#cccccc'), QColor('#aaaaaa')
|
||||
painter.save()
|
||||
painter.setClipRect(gr)
|
||||
c0 = int(gr.left()) - (int(gr.left()) % (2 * t))
|
||||
r0 = int(gr.top()) - (int(gr.top()) % (2 * t))
|
||||
for row in range(r0, int(gr.bottom()) + 2 * t, t):
|
||||
for col in range(c0, int(gr.right()) + 2 * t, t):
|
||||
color = light if ((row // t) + (col // t)) % 2 == 0 else dark
|
||||
painter.fillRect(col, row, t, t, color)
|
||||
painter.restore()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
delta = event.angleDelta().y()
|
||||
self._canvas._apply_zoom(_ZOOM_STEP if delta > 0 else 1.0 / _ZOOM_STEP)
|
||||
event.accept()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self._canvas._on_press(self.mapToScene(event.pos()))
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if event.buttons() & Qt.LeftButton:
|
||||
shift = bool(event.modifiers() & Qt.ShiftModifier)
|
||||
self._canvas._on_move(self.mapToScene(event.pos()), shift)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self._canvas._on_release(self.mapToScene(event.pos()))
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
# ── public widget ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ImageCanvas(QWidget):
|
||||
"""Preview canvas: garment + draggable / scalable / rotatable print layer."""
|
||||
|
||||
transform_changed = Signal(object) # emits TransformState
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setScene(QGraphicsScene(self))
|
||||
|
||||
# Current asset paths
|
||||
self._garment_path: Optional[Path] = None
|
||||
self._print_path: Optional[Path] = None
|
||||
self._print_pixmap_orig: Optional[QPixmap] = None
|
||||
|
||||
# Scene items (all None until loaded)
|
||||
self._garment_item: Optional[QGraphicsPixmapItem] = None
|
||||
self._print_item: Optional[QGraphicsPixmapItem] = None
|
||||
self._sel_rect: Optional[QGraphicsRectItem] = None
|
||||
self._corner_handles: List[QGraphicsEllipseItem] = []
|
||||
self._rot_line: Optional[QGraphicsLineItem] = None
|
||||
self._rot_handle: Optional[QGraphicsEllipseItem] = None
|
||||
|
||||
# Current transform (authoritative)
|
||||
self._state: Optional[TransformState] = None
|
||||
|
||||
# Drag state
|
||||
self._drag_tag: Optional[str] = None
|
||||
self._drag_start_scene = QPointF(0.0, 0.0)
|
||||
self._drag_snap: Optional[TransformState] = None # snapshot at press
|
||||
# For rotation: centre in scene and angle at press
|
||||
self._drag_rot_center = QPointF(0.0, 0.0)
|
||||
self._drag_rot_offset = 0.0
|
||||
|
||||
# Zoom
|
||||
self._zoom_level = 1.0
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _setup_ui(self):
|
||||
col = QVBoxLayout(self)
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.setSpacing(0)
|
||||
|
||||
col.addWidget(self._make_top_bar())
|
||||
|
||||
self._scene = QGraphicsScene(self)
|
||||
self._view = _CanvasView(self)
|
||||
self._view.setScene(self._scene)
|
||||
col.addWidget(self._view, 1)
|
||||
|
||||
# Hint overlay at bottom
|
||||
hint = QLabel('拖动移动 · 四角缩放 · 顶部旋转 · Shift 锁比例')
|
||||
hint.setObjectName('canvasHint')
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
col.addWidget(hint)
|
||||
|
||||
self._apply_stylesheet()
|
||||
self._show_placeholder()
|
||||
|
||||
def _make_top_bar(self) -> QWidget:
|
||||
bar = QWidget()
|
||||
bar.setObjectName('canvasTopBar')
|
||||
bar.setFixedHeight(34)
|
||||
row = QHBoxLayout(bar)
|
||||
row.setContentsMargins(8, 0, 8, 0)
|
||||
row.setSpacing(6)
|
||||
|
||||
self._garment_name_lbl = QLabel('— 未选择衣服 —')
|
||||
self._garment_name_lbl.setObjectName('canvasFileLbl')
|
||||
sep = QLabel('×')
|
||||
sep.setObjectName('canvasSep')
|
||||
self._print_name_lbl = QLabel('— 未选择印花 —')
|
||||
self._print_name_lbl.setObjectName('canvasFileLbl')
|
||||
|
||||
row.addWidget(self._garment_name_lbl)
|
||||
row.addWidget(sep)
|
||||
row.addWidget(self._print_name_lbl)
|
||||
row.addStretch()
|
||||
|
||||
# Mode buttons (visual indicators; interaction is context-aware)
|
||||
self._mode_btns = {}
|
||||
for mode, label in [('move', '移动'), ('scale', '缩放'), ('rotate', '旋转')]:
|
||||
btn = QPushButton(label)
|
||||
btn.setObjectName('modeBtn')
|
||||
btn.setCheckable(True)
|
||||
btn.setFixedWidth(46)
|
||||
self._mode_btns[mode] = btn
|
||||
row.addWidget(btn)
|
||||
self._mode_btns['move'].setChecked(True)
|
||||
|
||||
row.addSpacing(10)
|
||||
|
||||
# Zoom controls
|
||||
btn_out = QPushButton('−')
|
||||
btn_out.setObjectName('zoomBtn')
|
||||
btn_out.setFixedWidth(22)
|
||||
btn_out.clicked.connect(lambda: self._apply_zoom(1.0 / _ZOOM_STEP))
|
||||
|
||||
self._zoom_lbl = QLabel('100%')
|
||||
self._zoom_lbl.setObjectName('zoomLbl')
|
||||
self._zoom_lbl.setFixedWidth(46)
|
||||
self._zoom_lbl.setAlignment(Qt.AlignCenter)
|
||||
|
||||
btn_in = QPushButton('+')
|
||||
btn_in.setObjectName('zoomBtn')
|
||||
btn_in.setFixedWidth(22)
|
||||
btn_in.clicked.connect(lambda: self._apply_zoom(_ZOOM_STEP))
|
||||
|
||||
row.addWidget(btn_out)
|
||||
row.addWidget(self._zoom_lbl)
|
||||
row.addWidget(btn_in)
|
||||
|
||||
return bar
|
||||
|
||||
def _apply_stylesheet(self):
|
||||
self.setStyleSheet("""
|
||||
#canvasTopBar {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d6d6;
|
||||
}
|
||||
#canvasFileLbl { font-size: 12px; color: #333333; }
|
||||
#canvasSep { font-size: 12px; color: #aaaaaa; }
|
||||
#modeBtn {
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #d6d6d6;
|
||||
border-radius: 3px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
#modeBtn:checked {
|
||||
background: #0078d4;
|
||||
color: #ffffff;
|
||||
border-color: #0067c0;
|
||||
}
|
||||
#zoomBtn {
|
||||
font-size: 14px;
|
||||
border: 1px solid #d6d6d6;
|
||||
border-radius: 3px;
|
||||
background: #f0f0f0;
|
||||
padding: 0px;
|
||||
}
|
||||
#zoomBtn:hover { background: #e0e0e0; }
|
||||
#zoomLbl { font-size: 12px; color: #333333; }
|
||||
#canvasHint {
|
||||
font-size: 11px;
|
||||
color: #888888;
|
||||
background-color: #f0f0f0;
|
||||
border-top: 1px solid #d6d6d6;
|
||||
padding: 2px 0px;
|
||||
}
|
||||
""")
|
||||
|
||||
def _show_placeholder(self):
|
||||
self._scene.clear()
|
||||
text = self._scene.addText('请打开衣服文件夹并选择衣服图片')
|
||||
text.setDefaultTextColor(QColor('#aaaaaa'))
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_garment(self, path):
|
||||
"""Display a garment image; clears any existing print layer."""
|
||||
self._garment_path = Path(path)
|
||||
pix = QPixmap(str(path))
|
||||
if pix.isNull():
|
||||
logger.error("Cannot load garment: %s", path)
|
||||
return
|
||||
self._scene.clear()
|
||||
self._print_item = None
|
||||
self._sel_rect = None
|
||||
self._corner_handles = []
|
||||
self._rot_line = None
|
||||
self._rot_handle = None
|
||||
self._state = None
|
||||
|
||||
self._garment_item = QGraphicsPixmapItem(pix)
|
||||
self._garment_item.setZValue(0)
|
||||
self._garment_item.setData(0, 'garment')
|
||||
self._scene.addItem(self._garment_item)
|
||||
self._scene.setSceneRect(QRectF(0, 0, pix.width(), pix.height()))
|
||||
|
||||
self._garment_name_lbl.setText(self._garment_path.name)
|
||||
self._fit_view()
|
||||
logger.info("Garment loaded: %s (%dx%d)", path, pix.width(), pix.height())
|
||||
|
||||
def load_print(self, path):
|
||||
"""Display a print image on top of the current garment."""
|
||||
if self._garment_item is None:
|
||||
logger.warning("Cannot load print without garment: %s", path)
|
||||
return
|
||||
self._print_path = Path(path)
|
||||
self._print_pixmap_orig = QPixmap(str(path))
|
||||
if self._print_pixmap_orig.isNull():
|
||||
logger.error("Cannot load print: %s", path)
|
||||
return
|
||||
|
||||
gw = self._scene.sceneRect().width()
|
||||
gh = self._scene.sceneRect().height()
|
||||
pw = self._print_pixmap_orig.width() or 1
|
||||
ph = self._print_pixmap_orig.height() or 1
|
||||
dw = gw * 0.4
|
||||
dh = dw * (ph / pw)
|
||||
self._state = TransformState(
|
||||
x=(gw - dw) / 2, y=(gh - dh) / 2,
|
||||
width=dw, height=dh,
|
||||
rotation=0.0, keep_aspect_ratio=True,
|
||||
)
|
||||
self._print_name_lbl.setText(self._print_path.name)
|
||||
self._build_print_items()
|
||||
self._refresh_print_display()
|
||||
self.transform_changed.emit(self._state)
|
||||
logger.info("Print loaded: %s (default pos %.0f,%.0f size %.0fx%.0f)",
|
||||
path, self._state.x, self._state.y,
|
||||
self._state.width, self._state.height)
|
||||
|
||||
def set_transform(self, state: TransformState):
|
||||
"""Apply an externally computed TransformState (from param panel or template)."""
|
||||
self._state = state
|
||||
if self._print_item is not None:
|
||||
self._refresh_print_display()
|
||||
|
||||
def get_transform(self) -> Optional[TransformState]:
|
||||
return self._state
|
||||
|
||||
# ── scene management ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_print_items(self):
|
||||
"""Create (or recreate) all print-layer scene items."""
|
||||
for item in (self._print_item, self._sel_rect, self._rot_line, self._rot_handle):
|
||||
if item and item.scene():
|
||||
self._scene.removeItem(item)
|
||||
for h in self._corner_handles:
|
||||
if h.scene():
|
||||
self._scene.removeItem(h)
|
||||
|
||||
s = self._state
|
||||
pix = self._print_pixmap_orig.scaled(
|
||||
max(1, round(s.width)), max(1, round(s.height)),
|
||||
Qt.IgnoreAspectRatio, Qt.SmoothTransformation,
|
||||
)
|
||||
self._print_item = QGraphicsPixmapItem(pix)
|
||||
self._print_item.setZValue(1)
|
||||
self._print_item.setData(0, _T_PRINT)
|
||||
self._scene.addItem(self._print_item)
|
||||
|
||||
# Selection outline (drawn in scene space, updated in _refresh)
|
||||
pen_blue = QPen(QColor('#0078d4'), 1.5)
|
||||
pen_blue.setCosmetic(True)
|
||||
self._sel_rect = QGraphicsRectItem()
|
||||
self._sel_rect.setPen(pen_blue)
|
||||
self._sel_rect.setBrush(Qt.NoBrush)
|
||||
self._sel_rect.setZValue(2)
|
||||
self._scene.addItem(self._sel_rect)
|
||||
|
||||
# Four corner handles
|
||||
pen_h = QPen(QColor('#0078d4'), 1.5)
|
||||
pen_h.setCosmetic(True)
|
||||
self._corner_handles = []
|
||||
for tag in _CORNER_TAGS:
|
||||
h = QGraphicsEllipseItem(-_HANDLE_R, -_HANDLE_R, 2 * _HANDLE_R, 2 * _HANDLE_R)
|
||||
h.setPen(pen_h)
|
||||
h.setBrush(QBrush(QColor('#ffffff')))
|
||||
h.setZValue(3)
|
||||
h.setData(0, tag)
|
||||
self._scene.addItem(h)
|
||||
self._corner_handles.append(h)
|
||||
|
||||
# Rotation arm and handle
|
||||
pen_d = QPen(QColor('#0078d4'), 1.2, Qt.DashLine)
|
||||
pen_d.setCosmetic(True)
|
||||
self._rot_line = QGraphicsLineItem()
|
||||
self._rot_line.setPen(pen_d)
|
||||
self._rot_line.setZValue(2)
|
||||
self._scene.addItem(self._rot_line)
|
||||
|
||||
pen_r = QPen(QColor('#0067c0'), 1.5)
|
||||
pen_r.setCosmetic(True)
|
||||
self._rot_handle = QGraphicsEllipseItem(-_HANDLE_R, -_HANDLE_R, 2 * _HANDLE_R, 2 * _HANDLE_R)
|
||||
self._rot_handle.setPen(pen_r)
|
||||
self._rot_handle.setBrush(QBrush(QColor('#0078d4')))
|
||||
self._rot_handle.setZValue(3)
|
||||
self._rot_handle.setData(0, _T_ROT)
|
||||
self._scene.addItem(self._rot_handle)
|
||||
|
||||
def _refresh_print_display(self):
|
||||
"""Reposition all print-layer items from self._state."""
|
||||
if self._state is None or self._print_item is None:
|
||||
return
|
||||
s = self._state
|
||||
cx, cy = s.x + s.width / 2.0, s.y + s.height / 2.0
|
||||
|
||||
# ── rescale pixmap if needed ────────────────────────────────────────
|
||||
nw, nh = max(1, round(s.width)), max(1, round(s.height))
|
||||
cur = self._print_item.pixmap()
|
||||
if cur.width() != nw or cur.height() != nh:
|
||||
pix = self._print_pixmap_orig.scaled(
|
||||
nw, nh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation
|
||||
)
|
||||
self._print_item.setPixmap(pix)
|
||||
|
||||
# ── position + rotate print item ────────────────────────────────────
|
||||
self._print_item.setPos(s.x, s.y)
|
||||
self._print_item.setTransformOriginPoint(s.width / 2.0, s.height / 2.0)
|
||||
self._print_item.setRotation(s.rotation)
|
||||
|
||||
# ── selection rect (same transform as print item) ───────────────────
|
||||
self._sel_rect.setRect(0.0, 0.0, s.width, s.height)
|
||||
self._sel_rect.setPos(s.x, s.y)
|
||||
self._sel_rect.setTransformOriginPoint(s.width / 2.0, s.height / 2.0)
|
||||
self._sel_rect.setRotation(s.rotation)
|
||||
|
||||
# ── corner handles in scene space ───────────────────────────────────
|
||||
hw, hh = s.width / 2.0, s.height / 2.0
|
||||
corners_local = [(-hw, -hh), (+hw, -hh), (+hw, +hh), (-hw, +hh)]
|
||||
for handle, (lx, ly) in zip(self._corner_handles, corners_local):
|
||||
sx, sy = self._local_to_scene_xy(lx + hw, ly + hh, s)
|
||||
handle.setPos(sx, sy)
|
||||
|
||||
# ── rotation arm + handle ───────────────────────────────────────────
|
||||
top_cx, top_cy = self._local_to_scene_xy(hw, 0.0, s)
|
||||
rot_sx, rot_sy = self._local_to_scene_xy(hw, -_ROT_GAP, s)
|
||||
self._rot_line.setLine(top_cx, top_cy, rot_sx, rot_sy)
|
||||
self._rot_handle.setPos(rot_sx, rot_sy)
|
||||
|
||||
# ── coordinate helpers ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _local_to_scene_xy(lx: float, ly: float, s: TransformState):
|
||||
"""Map item-local (lx, ly) to scene using s.x/y/width/height/rotation."""
|
||||
cx, cy = s.width / 2.0, s.height / 2.0
|
||||
dx, dy = lx - cx, ly - cy
|
||||
rad = math.radians(s.rotation)
|
||||
cos_r, sin_r = math.cos(rad), math.sin(rad)
|
||||
return s.x + cx + cos_r * dx - sin_r * dy, s.y + cy + sin_r * dx + cos_r * dy
|
||||
|
||||
@staticmethod
|
||||
def _scene_to_local_xy(sx: float, sy: float, s: TransformState):
|
||||
"""Map scene (sx, sy) to item-local coords using s geometry."""
|
||||
cx, cy = s.width / 2.0, s.height / 2.0
|
||||
dx, dy = sx - s.x - cx, sy - s.y - cy
|
||||
rad = math.radians(s.rotation)
|
||||
cos_r, sin_r = math.cos(rad), math.sin(rad)
|
||||
return cos_r * dx + sin_r * dy + cx, -sin_r * dx + cos_r * dy + cy
|
||||
|
||||
# ── zoom ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _apply_zoom(self, factor: float):
|
||||
new_level = max(_ZOOM_MIN, min(_ZOOM_MAX, self._zoom_level * factor))
|
||||
actual = new_level / self._zoom_level
|
||||
self._zoom_level = new_level
|
||||
self._view.scale(actual, actual)
|
||||
self._zoom_lbl.setText('{:.0f}%'.format(self._zoom_level * 100))
|
||||
|
||||
def _fit_view(self):
|
||||
self._view.fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio)
|
||||
self._zoom_level = self._view.transform().m11()
|
||||
self._zoom_lbl.setText('{:.0f}%'.format(self._zoom_level * 100))
|
||||
|
||||
# ── hit testing ───────────────────────────────────────────────────────────
|
||||
|
||||
def _hit_test(self, scene_pos: QPointF) -> Optional[str]:
|
||||
"""Return the highest-priority interactive tag at scene_pos."""
|
||||
tol = _HANDLE_R + 3
|
||||
items = self._scene.items(
|
||||
QRectF(scene_pos.x() - tol, scene_pos.y() - tol, 2 * tol, 2 * tol)
|
||||
)
|
||||
priority = {_T_ROT: 0, _T_TL: 1, _T_TR: 1, _T_BR: 1, _T_BL: 1, _T_PRINT: 2}
|
||||
best_tag, best_pri = None, 999
|
||||
for item in items:
|
||||
tag = item.data(0)
|
||||
if tag in priority and priority[tag] < best_pri:
|
||||
best_tag, best_pri = tag, priority[tag]
|
||||
return best_tag
|
||||
|
||||
# ── mouse handlers (called from _CanvasView) ──────────────────────────────
|
||||
|
||||
def _on_press(self, scene_pos: QPointF):
|
||||
if self._state is None or self._print_item is None:
|
||||
return
|
||||
tag = self._hit_test(scene_pos)
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
self._drag_tag = tag
|
||||
self._drag_start_scene = scene_pos
|
||||
s = self._state
|
||||
# Snapshot state at press
|
||||
self._drag_snap = TransformState(
|
||||
x=s.x, y=s.y, width=s.width, height=s.height,
|
||||
rotation=s.rotation, keep_aspect_ratio=s.keep_aspect_ratio,
|
||||
)
|
||||
|
||||
if tag == _T_ROT:
|
||||
self._drag_rot_center = QPointF(s.x + s.width / 2.0, s.y + s.height / 2.0)
|
||||
dx = scene_pos.x() - self._drag_rot_center.x()
|
||||
dy = scene_pos.y() - self._drag_rot_center.y()
|
||||
self._drag_rot_offset = s.rotation - math.degrees(math.atan2(dy, dx))
|
||||
|
||||
# Update mode button indicator
|
||||
if tag == _T_PRINT:
|
||||
self._set_active_mode('move')
|
||||
elif tag in _CORNER_TAGS:
|
||||
self._set_active_mode('scale')
|
||||
elif tag == _T_ROT:
|
||||
self._set_active_mode('rotate')
|
||||
|
||||
def _on_move(self, scene_pos: QPointF, shift: bool):
|
||||
if self._drag_tag is None or self._drag_snap is None:
|
||||
return
|
||||
snap = self._drag_snap
|
||||
tag = self._drag_tag
|
||||
|
||||
if tag == _T_PRINT:
|
||||
dx = scene_pos.x() - self._drag_start_scene.x()
|
||||
dy = scene_pos.y() - self._drag_start_scene.y()
|
||||
self._state.x = snap.x + dx
|
||||
self._state.y = snap.y + dy
|
||||
|
||||
elif tag in _CORNER_TAGS:
|
||||
lock = shift or snap.keep_aspect_ratio
|
||||
self._do_scale(scene_pos, snap, tag, lock)
|
||||
|
||||
elif tag == _T_ROT:
|
||||
dx = scene_pos.x() - self._drag_rot_center.x()
|
||||
dy = scene_pos.y() - self._drag_rot_center.y()
|
||||
self._state.rotation = math.degrees(math.atan2(dy, dx)) + self._drag_rot_offset
|
||||
|
||||
self._refresh_print_display()
|
||||
|
||||
def _on_release(self, scene_pos: QPointF):
|
||||
if self._drag_tag is not None:
|
||||
self._drag_tag = None
|
||||
self._drag_snap = None
|
||||
if self._state:
|
||||
self.transform_changed.emit(self._state)
|
||||
|
||||
# ── scale computation ─────────────────────────────────────────────────────
|
||||
|
||||
def _do_scale(self, scene_pos: QPointF, snap: TransformState,
|
||||
tag: str, lock_ratio: bool):
|
||||
"""Compute new size/position from corner drag, keeping opposite corner fixed."""
|
||||
# Mouse in OLD local coordinates (using snapshot geometry)
|
||||
lx, ly = self._scene_to_local_xy(scene_pos.x(), scene_pos.y(), snap)
|
||||
w, h = snap.width, snap.height
|
||||
|
||||
# New size and which corner is fixed (in OLD local coords)
|
||||
if tag == _T_TL:
|
||||
new_w, new_h = w - lx, h - ly
|
||||
fixed_old_local = QPointF(w, h) # BR fixed
|
||||
new_fixed_fn = lambda nw, nh: QPointF(nw, nh)
|
||||
elif tag == _T_TR:
|
||||
new_w, new_h = lx, h - ly
|
||||
fixed_old_local = QPointF(0.0, h) # BL fixed
|
||||
new_fixed_fn = lambda nw, nh: QPointF(0.0, nh)
|
||||
elif tag == _T_BR:
|
||||
new_w, new_h = lx, ly
|
||||
fixed_old_local = QPointF(0.0, 0.0) # TL fixed
|
||||
new_fixed_fn = lambda nw, nh: QPointF(0.0, 0.0)
|
||||
else: # BL
|
||||
new_w, new_h = w - lx, ly
|
||||
fixed_old_local = QPointF(w, 0.0) # TR fixed
|
||||
new_fixed_fn = lambda nw, nh: QPointF(nw, 0.0)
|
||||
|
||||
new_w = max(_MIN_SIZE, new_w)
|
||||
new_h = max(_MIN_SIZE, new_h)
|
||||
|
||||
if lock_ratio and w > 0 and h > 0:
|
||||
scale = max(new_w / w, new_h / h)
|
||||
new_w = max(_MIN_SIZE, w * scale)
|
||||
new_h = max(_MIN_SIZE, h * scale)
|
||||
|
||||
# Scene position of the fixed corner (from OLD snapshot geometry)
|
||||
fx, fy = self._local_to_scene_xy(fixed_old_local.x(), fixed_old_local.y(), snap)
|
||||
|
||||
# New item position: solve fixed_scene = new_pos + R*(nfl - new_c) + new_c
|
||||
nfl = new_fixed_fn(new_w, new_h)
|
||||
ncx, ncy = new_w / 2.0, new_h / 2.0
|
||||
ddx, ddy = nfl.x() - ncx, nfl.y() - ncy
|
||||
rad = math.radians(snap.rotation)
|
||||
cos_r, sin_r = math.cos(rad), math.sin(rad)
|
||||
rdx = cos_r * ddx - sin_r * ddy
|
||||
rdy = sin_r * ddx + cos_r * ddy
|
||||
|
||||
self._state.x = fx - rdx - ncx
|
||||
self._state.y = fy - rdy - ncy
|
||||
self._state.width = new_w
|
||||
self._state.height = new_h
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _set_active_mode(self, mode: str):
|
||||
for m, btn in self._mode_btns.items():
|
||||
btn.setChecked(m == mode)
|
||||
|
||||
@@ -318,25 +318,25 @@
|
||||
|
||||
任务:
|
||||
|
||||
- [ ] 先读取 `src/app/widgets/image_canvas.py` 现有内容
|
||||
- [ ] 使用 `QGraphicsView / QGraphicsScene`
|
||||
- [ ] 显示衣服底图(scene 坐标与原图像素坐标保持一致)
|
||||
- [ ] 显示印花图层
|
||||
- [ ] 实现印花拖动
|
||||
- [ ] 实现缩放控制点(至少四角)
|
||||
- [ ] 实现旋转控制点(顶部中心外侧)
|
||||
- [ ] 实现预览缩放(通过 `QGraphicsView` view transform,不修改 scene 内容)
|
||||
- [ ] 实现预览顶部栏:显示当前底图文件名、印花文件名、操作模式按钮(移动/缩放/旋转)、缩放比例控制
|
||||
- [ ] 实现画布操作提示 Overlay(拖动移动 / 四角缩放 / 顶部旋转 / Shift 锁比例)
|
||||
- [ ] 将交互结果同步为 `TransformState`
|
||||
- [x] 先读取 `src/app/widgets/image_canvas.py` 现有内容
|
||||
- [x] 使用 `QGraphicsView / QGraphicsScene`
|
||||
- [x] 显示衣服底图(scene 坐标与原图像素坐标保持一致)
|
||||
- [x] 显示印花图层
|
||||
- [x] 实现印花拖动
|
||||
- [x] 实现缩放控制点(至少四角)
|
||||
- [x] 实现旋转控制点(顶部中心外侧)
|
||||
- [x] 实现预览缩放(通过 `QGraphicsView` view transform,不修改 scene 内容)
|
||||
- [x] 实现预览顶部栏:显示当前底图文件名、印花文件名、操作模式按钮(移动/缩放/旋转)、缩放比例控制
|
||||
- [x] 实现画布操作提示 Overlay(拖动移动 / 四角缩放 / 顶部旋转 / Shift 锁比例)
|
||||
- [x] 将交互结果同步为 `TransformState`
|
||||
|
||||
验收:
|
||||
|
||||
- [ ] 预览缩放不改变真实合成参数
|
||||
- [ ] 拖动后坐标为衣服原图像素坐标
|
||||
- [ ] 缩放后宽高同步
|
||||
- [ ] 旋转后角度同步
|
||||
- [ ] 不使用截图作为导出结果
|
||||
- [x] 预览缩放不改变真实合成参数
|
||||
- [x] 拖动后坐标为衣服原图像素坐标
|
||||
- [x] 缩放后宽高同步
|
||||
- [x] 旋转后角度同步
|
||||
- [x] 不使用截图作为导出结果
|
||||
|
||||
## 10. 参数面板 UI
|
||||
|
||||
|
||||
Reference in New Issue
Block a user