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:
2026-06-16 09:01:15 +08:00
co-authored by Claude Sonnet 4.6
parent 94d30ab183
commit 3e3e06b7a1
2 changed files with 604 additions and 19 deletions
+588 -3
View File
@@ -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)
+16 -16
View File
@@ -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