From 3e3e06b7a1e09cf1bbd787c217b62cb13bc95e51 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 16 Jun 2026 09:01:15 +0800 Subject: [PATCH] feat: implement image canvas with drag/scale/rotate and preview zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/widgets/image_canvas.py | 591 +++++++++++++++++++++++++++++++- tasks.md | 32 +- 2 files changed, 604 insertions(+), 19 deletions(-) diff --git a/src/app/widgets/image_canvas.py b/src/app/widgets/image_canvas.py index 60e6511..1f4f622 100644 --- a/src/app/widgets/image_canvas.py +++ b/src/app/widgets/image_canvas.py @@ -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) diff --git a/tasks.md b/tasks.md index f750c18..feafd3f 100644 --- a/tasks.md +++ b/tasks.md @@ -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