feat: thumbnail grid, aspect-aware templates, and UI hierarchy polish

Material list:
- Switch asset list from one-per-row to a wrapping thumbnail grid
  (IconMode cards: thumbnail + corner checkbox + elided name, subfolder
  moved to tooltip).

Templates:
- Template.to_transform_state now contains-fits the print inside the
  target box by the print's native aspect ratio (no stretch); falls back
  to the box when print size is unknown.
- Feed print native size into template_panel for aspect-aware fitting.
- Shrink built-in template boxes to realistic print proportions.

UI hierarchy:
- Promote 打开文件夹 buttons to primary (filled accent).
- Add inline 归零 (reset angle) and a 重置位置/尺寸/角度 button to the
  transform panel; wire it to re-apply the selected template.
- Hide 保存 for built-in templates instead of leaving it greyed.

Docs: record template box/contain behavior (PRD) and the grid layout,
button hierarchy, and reset/save-visibility rules (UI design).
Tests: add 3 aspect-fit cases for to_transform_state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:11:56 +08:00
co-authored by Claude Opus 4.8
parent df9145abdb
commit 8a34fe93ff
9 changed files with 273 additions and 78 deletions
+9 -5
View File
@@ -67,19 +67,21 @@
模板是一组可复用的合成参数。 模板是一组可复用的合成参数。
模板字段: 模板使用**比例坐标**存储,以适配任意尺寸的衣服底图(字段为相对衣服宽高的比例 `x_ratio` / `y_ratio` / `width_ratio` / `height_ratio` 及 `rotation`),应用时再换算为像素:
```json ```json
{ {
"name": "左胸小号模板", "name": "左胸小号模板",
"x": 120, "x_ratio": 0.50,
"y": 180, "y_ratio": 0.22,
"width": 160, "width_ratio": 0.16,
"height": 160, "height_ratio": 0.16,
"rotation": 0 "rotation": 0
} }
``` ```
其中 `width_ratio` / `height_ratio` 表示目标框,而非印花最终尺寸;印花在框内按原始宽高比 contain 适配(详见 6.6)。
## 6. 功能需求 ## 6. 功能需求
### 6.1 图片加载 ### 6.1 图片加载
@@ -159,6 +161,8 @@
- 用户点击模板后,印花位置、尺寸、旋转参数立即应用到当前预览。 - 用户点击模板后,印花位置、尺寸、旋转参数立即应用到当前预览。
- 模板参数应基于衣服底图尺寸计算,避免只适配单一图片分辨率。 - 模板参数应基于衣服底图尺寸计算,避免只适配单一图片分辨率。
- 模板的 `width_ratio` / `height_ratio` 定义衣服上的**目标框**,而非强制拉伸尺寸。已知印花原始尺寸时,印花按自身宽高比缩放后 contain 进目标框并居中,不被拉伸变形;未知印花尺寸时退化为铺满目标框。
- 内置模板尺寸以贴近真实印花占比为准(例如正方形模板约为衣服宽度的 1/3),避免印花占据过大面积。
### 6.7 自定义模板 ### 6.7 自定义模板
+20 -7
View File
@@ -89,20 +89,22 @@
控件: 控件:
- `打开衣服文件夹` 按钮。 - `打开衣服文件夹` 按钮,作为该区域的主操作,使用主色填充按钮(见第 10 节按钮层级)。
- 图片数量说明,例如:`含子文件夹 · 共 12`。 - 图片数量说明,例如:`含子文件夹 · 共 12`。
- 全选复选框。 - 全选复选框。
- 已选数量。 - 已选数量。
- 排序选择,例如:`文件夹+名称`。 - 排序选择,例如:`文件夹+名称`。
- 缩略图网格。 - 缩略图网格。
缩略图项显示: 缩略图网格采用**每行多张、随面板宽度自动换行**的卡片布局(非每行一张的列表)。
- 图片预览。 缩略图卡片显示:
- 勾选框。
- 来源子文件夹标签。 - 图片预览(卡片上方)。
- 文件名。 - 勾选框(叠加在缩略图左上角)。
- 当前预览选中态。 - 文件名(卡片下方,过长时中间省略)。
- 来源子文件夹(鼠标悬停 tooltip 显示,节省网格空间)。
- 当前预览选中态(卡片高亮)。
交互规则: 交互规则:
@@ -206,6 +208,7 @@ Shift 锁比例
- 选择模板后更新当前印花位置、尺寸和旋转。 - 选择模板后更新当前印花位置、尺寸和旋转。
- 保存用于覆盖当前自定义模板。 - 保存用于覆盖当前自定义模板。
- 另存为用于创建新模板。 - 另存为用于创建新模板。
- `保存` 仅对自定义模板有意义:选中内置模板时**隐藏**该按钮(而非禁用置灰),避免出现长期灰显且无法说明原因的控件。
### 7.2 位置区域 ### 7.2 位置区域
@@ -240,6 +243,7 @@ Shift 锁比例
控件: 控件:
- `角度` 数字输入框。 - `角度` 数字输入框。
- `归零` 按钮(紧邻角度输入框,一键将角度还原为 0,即拉直印花)。
- `左旋 90°` 按钮。 - `左旋 90°` 按钮。
- `右旋 90°` 按钮。 - `右旋 90°` 按钮。
@@ -249,6 +253,7 @@ Shift 锁比例
- 支持负角度。 - 支持负角度。
- 顶部旋转控制点拖动后同步角度输入框。 - 顶部旋转控制点拖动后同步角度输入框。
- 按钮旋转后同步预览和角度输入框。 - 按钮旋转后同步预览和角度输入框。
- 参数栏底部提供 `重置位置 / 尺寸 / 角度` 按钮,将三者一并还原为当前所选模板(与导出区的 `重置为模板` 等效,就近放在参数编辑处)。
### 7.5 输出设置区域 ### 7.5 输出设置区域
@@ -439,6 +444,14 @@ Microsoft YaHei
- 控件圆角保持小半径,约 3 px。 - 控件圆角保持小半径,约 3 px。
- 不使用大圆角卡片。 - 不使用大圆角卡片。
按钮层级(一套主色,按角色克制使用):
- **主按钮**:主色填充、白字。用于每个区域「推动流程前进」的唯一动作:左侧 `打开文件夹`、右侧 `导出当前单张`、底部 `开始批量导出`。三者分处不同区域,各自是本区域主操作,不互相竞争。
- **次按钮**:白底、主色描边与文字。用于有意义但非主线的动作,如 `重置位置 / 尺寸 / 角度`、`应用为模板`。
- **辅助按钮**:浅灰底、细边框。用于就地微调类动作,如 `浏览…`、排序、`左旋 / 右旋 / 归零`。
避免所有按钮使用同一种灰色样式而丧失层级;同一动作在不同位置应使用一致的文案与层级。
## 11. PySide6 控件映射 ## 11. PySide6 控件映射
建议映射: 建议映射:
+13 -1
View File
@@ -269,6 +269,9 @@ class MainWindow(QMainWindow):
self.image_canvas.transform_changed.connect(self._on_transform_changed) self.image_canvas.transform_changed.connect(self._on_transform_changed)
self.transform_panel.transform_changed.connect(self._on_transform_changed) self.transform_panel.transform_changed.connect(self._on_transform_changed)
# Reset position/size/angle back to the selected template
self.transform_panel.reset_to_template.connect(self.template_panel._reset_to_template)
# ── Template panel ───────────────────────────────────────────────── # ── Template panel ─────────────────────────────────────────────────
# Template selection updates canvas, param panel, and queue template # Template selection updates canvas, param panel, and queue template
self.template_panel.template_applied.connect(self.image_canvas.set_transform) self.template_panel.template_applied.connect(self.image_canvas.set_transform)
@@ -305,6 +308,13 @@ class MainWindow(QMainWindow):
path = str(asset.path) path = str(asset.path)
self.image_canvas.load_print(path) self.image_canvas.load_print(path)
self.export_panel.set_print(path) self.export_panel.set_print(path)
# Tell template panel the print dimensions for aspect-aware fitting
try:
from PIL import Image as _PilImage
with _PilImage.open(path) as img:
self.template_panel.set_print_size(img.width, img.height)
except Exception:
pass
def _on_queue_item_activated(self, item): def _on_queue_item_activated(self, item):
"""Load a queue item into the canvas and arm fine-tune tracking.""" """Load a queue item into the canvas and arm fine-tune tracking."""
@@ -321,11 +331,13 @@ class MainWindow(QMainWindow):
if item.transform: if item.transform:
self.image_canvas.set_transform(item.transform) self.image_canvas.set_transform(item.transform)
self.transform_panel.set_transform(item.transform) self.transform_panel.set_transform(item.transform)
# Keep template panel garment size in sync # Keep template panel garment and print size in sync
try: try:
from PIL import Image as _PilImage from PIL import Image as _PilImage
with _PilImage.open(garment_path) as img: with _PilImage.open(garment_path) as img:
self.template_panel.set_garment_size(img.width, img.height) self.template_panel.set_garment_size(img.width, img.height)
with _PilImage.open(print_path) as img:
self.template_panel.set_print_size(img.width, img.height)
except Exception: except Exception:
pass pass
finally: finally:
+67 -47
View File
@@ -2,7 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
@@ -24,7 +24,9 @@ from services.file_service import scan_image_folder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_THUMB_SIZE = 52 # thumbnail size in px _THUMB_SIZE = 84 # thumbnail size in px
_CARD_W = 104 # grid cell width
_CARD_H = 116 # grid cell height
# CheckState int values (avoids enum import issues across PySide6 versions) # CheckState int values (avoids enum import issues across PySide6 versions)
_UNCHECKED = 0 _UNCHECKED = 0
@@ -33,7 +35,7 @@ _CHECKED = 2
class _AssetItemWidget(QWidget): class _AssetItemWidget(QWidget):
"""Single row: checkbox + thumbnail + subfolder label + filename.""" """Grid card: thumbnail with checkbox overlay on top, filename below."""
check_changed = Signal() check_changed = Signal()
@@ -51,46 +53,51 @@ class _AssetItemWidget(QWidget):
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
row = QHBoxLayout(self) self.setObjectName("assetCard")
row.setContentsMargins(4, 3, 4, 3) self.setFixedSize(_CARD_W, _CARD_H)
row.setSpacing(6)
# Batch-selection checkbox col = QVBoxLayout(self)
self._check = QCheckBox() col.setContentsMargins(4, 4, 4, 4)
self._check.setChecked(self._asset.selected) col.setSpacing(3)
self._check.stateChanged.connect(self._on_check_changed)
# Thumbnail # Thumbnail with the batch-selection checkbox overlaid on its corner
self._thumb = QLabel() thumb_box = QWidget()
self._thumb.setFixedSize(_THUMB_SIZE, _THUMB_SIZE) thumb_box.setFixedSize(_THUMB_SIZE, _THUMB_SIZE)
self._thumb.setAlignment(Qt.AlignCenter)
self._thumb = QLabel(thumb_box)
self._thumb.setObjectName("thumbLabel") self._thumb.setObjectName("thumbLabel")
self._thumb.setGeometry(0, 0, _THUMB_SIZE, _THUMB_SIZE)
self._thumb.setAlignment(Qt.AlignCenter)
self._load_thumbnail() self._load_thumbnail()
# Info column: subfolder + filename self._check = QCheckBox(thumb_box)
info_col = QWidget() self._check.setObjectName("cardCheck")
info_layout = QVBoxLayout(info_col) self._check.setChecked(self._asset.selected)
info_layout.setContentsMargins(0, 0, 0, 0) self._check.stateChanged.connect(self._on_check_changed)
info_layout.setSpacing(2) self._check.move(3, 3)
subfolder = self._compute_subfolder() thumb_row = QHBoxLayout()
self._folder_label = QLabel(subfolder) thumb_row.setContentsMargins(0, 0, 0, 0)
self._folder_label.setObjectName("subfolderLabel") thumb_row.addStretch()
self._folder_label.setVisible(bool(subfolder)) thumb_row.addWidget(thumb_box)
thumb_row.addStretch()
col.addLayout(thumb_row)
self._name_label = QLabel(Path(self._asset.path).name) # Filename (middle-elided to fit the card width)
name = Path(self._asset.path).name
self._name_label = QLabel()
self._name_label.setObjectName("fileNameLabel") self._name_label.setObjectName("fileNameLabel")
self._name_label.setWordWrap(False) self._name_label.setAlignment(Qt.AlignHCenter | Qt.AlignTop)
elided = self._name_label.fontMetrics().elidedText(
name, Qt.ElideMiddle, _CARD_W - 10
)
self._name_label.setText(elided)
col.addWidget(self._name_label)
col.addStretch()
info_layout.addWidget(self._folder_label) # Subfolder + filename shown on hover (no room for a label in the grid)
info_layout.addWidget(self._name_label) subfolder = self._compute_subfolder()
info_layout.addStretch() self.setToolTip("{}/{}".format(subfolder, name) if subfolder else name)
row.addWidget(self._check, 0, Qt.AlignVCenter)
row.addWidget(self._thumb)
row.addWidget(info_col, 1)
self.setFixedHeight(64)
def _compute_subfolder(self) -> str: def _compute_subfolder(self) -> str:
asset_path = Path(self._asset.path) asset_path = Path(self._asset.path)
@@ -136,7 +143,9 @@ class _AssetItemWidget(QWidget):
def set_preview_selected(self, active: bool): def set_preview_selected(self, active: bool):
"""Highlight or clear the current-preview indicator.""" """Highlight or clear the current-preview indicator."""
if active: if active:
self.setStyleSheet("background-color: #cce4f7;") self.setStyleSheet(
"#assetCard { background-color: #cce4f7; border-radius: 4px; }"
)
else: else:
self.setStyleSheet("") self.setStyleSheet("")
@@ -166,11 +175,19 @@ class _AssetPanel(QWidget):
self._list = QListWidget() self._list = QListWidget()
self._list.setObjectName("assetList") self._list.setObjectName("assetList")
# Thumbnail grid: wrap items left-to-right, reflow on resize
self._list.setViewMode(QListWidget.IconMode)
self._list.setFlow(QListWidget.LeftToRight)
self._list.setWrapping(True)
self._list.setResizeMode(QListWidget.Adjust)
self._list.setMovement(QListWidget.Static)
self._list.setUniformItemSizes(True)
self._list.setGridSize(QSize(_CARD_W + 6, _CARD_H + 6))
self._list.setSpacing(2)
self._list.setVerticalScrollMode(QListWidget.ScrollPerPixel) self._list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
self._list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# Disable QListWidget's own selection highlight — preview uses custom style # Disable QListWidget's own selection highlight — preview uses custom style
self._list.setSelectionMode(QListWidget.NoSelection) self._list.setSelectionMode(QListWidget.NoSelection)
self._list.setSpacing(1)
self._list.itemClicked.connect(self._on_item_clicked) self._list.itemClicked.connect(self._on_item_clicked)
col.addWidget(self._list, 1) col.addWidget(self._list, 1)
@@ -384,12 +401,15 @@ class ImageListPanel(QWidget):
} }
#openFolderBtn { #openFolderBtn {
font-size: 12px; font-size: 12px;
padding: 2px 8px; font-weight: bold;
border: 1px solid #d6d6d6; color: #ffffff;
border-radius: 3px; padding: 5px 12px;
background: #ffffff; border: none;
border-radius: 4px;
background: #0078d4;
} }
#openFolderBtn:hover { background: #e8e8e8; } #openFolderBtn:hover { background: #106ebe; }
#openFolderBtn:pressed { background: #005a9e; }
#ctrlRow { #ctrlRow {
background-color: #fafafa; background-color: #fafafa;
border-bottom: 1px solid #eeeeee; border-bottom: 1px solid #eeeeee;
@@ -410,13 +430,9 @@ class ImageListPanel(QWidget):
background: transparent; background: transparent;
border: none; border: none;
} }
#subfolderLabel {
font-size: 10px;
color: #888888;
}
#fileNameLabel { #fileNameLabel {
font-size: 12px; font-size: 11px;
color: #1a1a1a; color: #333333;
} }
#thumbLabel { #thumbLabel {
background-color: #f0f0f0; background-color: #f0f0f0;
@@ -425,6 +441,10 @@ class ImageListPanel(QWidget):
color: #aaaaaa; color: #aaaaaa;
font-size: 16px; font-size: 16px;
} }
#cardCheck {
background-color: rgba(255, 255, 255, 0.75);
border-radius: 2px;
}
QSplitter#assetSplitter::handle { QSplitter#assetSplitter::handle {
background-color: #d6d6d6; background-color: #d6d6d6;
height: 3px; height: 3px;
+19 -3
View File
@@ -32,6 +32,8 @@ class TemplatePanel(QWidget):
self._current_state: Optional[TransformState] = None self._current_state: Optional[TransformState] = None
self._garment_w: float = 0.0 self._garment_w: float = 0.0
self._garment_h: float = 0.0 self._garment_h: float = 0.0
self._print_w: float = 0.0
self._print_h: float = 0.0
self._updating = False self._updating = False
self._setup_ui() self._setup_ui()
self._load_templates() self._load_templates()
@@ -110,7 +112,9 @@ class TemplatePanel(QWidget):
def _update_button_states(self): def _update_button_states(self):
t = self._current_template() t = self._current_template()
is_builtin = (t is None) or (t.type == "builtin") is_builtin = (t is None) or (t.type == "builtin")
self._save_btn.setEnabled(not is_builtin) # "保存" overwrites a custom template — meaningless for built-ins, so
# hide it entirely rather than leaving a permanently-greyed button.
self._save_btn.setVisible(not is_builtin)
def _current_template(self) -> Optional[Template]: def _current_template(self) -> Optional[Template]:
return self._combo.currentData() return self._combo.currentData()
@@ -122,6 +126,18 @@ class TemplatePanel(QWidget):
self._garment_w = w self._garment_w = w
self._garment_h = h self._garment_h = h
def set_print_size(self, w: float, h: float):
"""Update the active print's native pixel size for aspect-aware fitting."""
self._print_w = w
self._print_h = h
def _state_for_template(self, t: Template) -> TransformState:
"""Convert a template to pixel coords, fitting print aspect ratio if known."""
return t.to_transform_state(
self._garment_w, self._garment_h,
self._print_w or None, self._print_h or None,
)
def set_transform(self, state: TransformState): def set_transform(self, state: TransformState):
"""Store current print transform (used when saving a template).""" """Store current print transform (used when saving a template)."""
self._current_state = state self._current_state = state
@@ -134,7 +150,7 @@ class TemplatePanel(QWidget):
self._update_button_states() self._update_button_states()
t = self._combo.itemData(index) t = self._combo.itemData(index)
if t and self._garment_w > 0 and self._garment_h > 0: if t and self._garment_w > 0 and self._garment_h > 0:
state = t.to_transform_state(self._garment_w, self._garment_h) state = self._state_for_template(t)
logger.debug("Template applied: %s", t.name) logger.debug("Template applied: %s", t.name)
self.template_applied.emit(state) self.template_applied.emit(state)
@@ -146,7 +162,7 @@ class TemplatePanel(QWidget):
if self._garment_w <= 0 or self._garment_h <= 0: if self._garment_w <= 0 or self._garment_h <= 0:
QMessageBox.information(self, "提示", "请先加载衣服图片。") QMessageBox.information(self, "提示", "请先加载衣服图片。")
return return
state = t.to_transform_state(self._garment_w, self._garment_h) state = self._state_for_template(t)
self.template_applied.emit(state) self.template_applied.emit(state)
def _save_template(self): def _save_template(self):
+59 -1
View File
@@ -23,6 +23,7 @@ class TransformPanel(QWidget):
"""Right-panel section: position, size, aspect-ratio lock, rotation.""" """Right-panel section: position, size, aspect-ratio lock, rotation."""
transform_changed = Signal(object) # emits TransformState transform_changed = Signal(object) # emits TransformState
reset_to_template = Signal() # "重置位置/尺寸/角度" clicked
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -41,6 +42,7 @@ class TransformPanel(QWidget):
col.addWidget(self._make_section("位置", self._make_pos_form())) col.addWidget(self._make_section("位置", self._make_pos_form()))
col.addWidget(self._make_section("尺寸", self._make_size_form())) col.addWidget(self._make_section("尺寸", self._make_size_form()))
col.addWidget(self._make_section("旋转", self._make_rot_form())) col.addWidget(self._make_section("旋转", self._make_rot_form()))
col.addWidget(self._make_footer())
def _make_section(self, title: str, body: QWidget) -> QWidget: def _make_section(self, title: str, body: QWidget) -> QWidget:
section = QWidget() section = QWidget()
@@ -113,7 +115,21 @@ class TransformPanel(QWidget):
self._rot_spin.setDecimals(1) self._rot_spin.setDecimals(1)
self._rot_spin.setSingleStep(1.0) self._rot_spin.setSingleStep(1.0)
self._rot_spin.valueChanged.connect(self._on_rot_changed) self._rot_spin.valueChanged.connect(self._on_rot_changed)
form.addRow("角度", self._rot_spin)
# Angle field: spinbox + inline "归零" (reset angle to 0)
rot_field = QWidget()
rot_row = QHBoxLayout(rot_field)
rot_row.setContentsMargins(0, 0, 0, 0)
rot_row.setSpacing(4)
self._rot_zero_btn = QPushButton("归零")
self._rot_zero_btn.setObjectName("rotZeroBtn")
self._rot_zero_btn.setFixedWidth(46)
self._rot_zero_btn.setToolTip("将角度归零(拉直印花)")
self._rot_zero_btn.clicked.connect(self._reset_rotation)
rot_row.addWidget(self._rot_spin, 1)
rot_row.addWidget(self._rot_zero_btn)
form.addRow("角度", rot_field)
col.addLayout(form) col.addLayout(form)
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
@@ -132,6 +148,20 @@ class TransformPanel(QWidget):
col.addLayout(btn_row) col.addLayout(btn_row)
return w return w
def _make_footer(self) -> QWidget:
footer = QWidget()
footer.setObjectName("transformFooter")
col = QVBoxLayout(footer)
col.setContentsMargins(10, 8, 10, 10)
col.setSpacing(0)
self._reset_btn = QPushButton("重置位置 / 尺寸 / 角度")
self._reset_btn.setObjectName("transformResetBtn")
self._reset_btn.setToolTip("将印花的位置、尺寸、角度还原为所选模板")
self._reset_btn.clicked.connect(lambda: self.reset_to_template.emit())
col.addWidget(self._reset_btn)
return footer
@staticmethod @staticmethod
def _make_spin(minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox: def _make_spin(minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox:
spin = QDoubleSpinBox() spin = QDoubleSpinBox()
@@ -177,6 +207,31 @@ class TransformPanel(QWidget):
} }
#rotBtn:hover { background: #e0e0e0; } #rotBtn:hover { background: #e0e0e0; }
#rotBtn:pressed { background: #d0d0d0; } #rotBtn:pressed { background: #d0d0d0; }
#rotZeroBtn {
font-size: 12px;
padding: 2px 4px;
border: 1px solid #d6d6d6;
border-radius: 3px;
background: #f0f0f0;
color: #444444;
}
#rotZeroBtn:hover { background: #e0e0e0; }
#rotZeroBtn:pressed { background: #d0d0d0; }
#transformFooter {
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
}
#transformResetBtn {
font-size: 12px;
font-weight: bold;
color: #0078d4;
background: #ffffff;
border: 1px solid #0078d4;
border-radius: 4px;
padding: 6px 0;
}
#transformResetBtn:hover { background: #eaf3fc; }
#transformResetBtn:pressed { background: #d6e8f9; }
""") """)
# ── public API ──────────────────────────────────────────────────────────── # ── public API ────────────────────────────────────────────────────────────
@@ -263,6 +318,9 @@ class TransformPanel(QWidget):
self._aspect_ratio = w / h self._aspect_ratio = w / h
self._emit() self._emit()
def _reset_rotation(self):
self._rot_spin.setValue(0.0)
def _rotate_left(self): def _rotate_left(self):
self._rot_spin.setValue(self._rot_spin.value() - 90.0) self._rot_spin.setValue(self._rot_spin.value() - 90.0)
+36 -6
View File
@@ -59,13 +59,43 @@ class Template:
rotation: float = 0.0 rotation: float = 0.0
type: str = "custom" # "builtin" | "custom" type: str = "custom" # "builtin" | "custom"
def to_transform_state(self, garment_width: float, garment_height: float) -> TransformState: def to_transform_state(
"""将比例参数转换为针对指定衣服尺寸的像素坐标 TransformState。""" self,
garment_width: float,
garment_height: float,
print_width: float = None,
print_height: float = None,
) -> TransformState:
"""将比例参数转换为针对指定衣服尺寸的像素坐标 TransformState。
width_ratio / height_ratio 定义衣服上的目标框。若提供印花原始尺寸
(print_width / print_height),印花按原始宽高比缩放后 contain 进目标框
并居中,避免被拉伸变形;未提供时退化为直接铺满目标框(旧行为)。
"""
box_x = self.x_ratio * garment_width
box_y = self.y_ratio * garment_height
box_w = self.width_ratio * garment_width
box_h = self.height_ratio * garment_height
if print_width and print_height and print_width > 0 and print_height > 0:
scale = min(box_w / print_width, box_h / print_height)
draw_w = print_width * scale
draw_h = print_height * scale
cx = box_x + box_w / 2.0
cy = box_y + box_h / 2.0
return TransformState(
x=cx - draw_w / 2.0,
y=cy - draw_h / 2.0,
width=draw_w,
height=draw_h,
rotation=self.rotation,
)
return TransformState( return TransformState(
x=self.x_ratio * garment_width, x=box_x,
y=self.y_ratio * garment_height, y=box_y,
width=self.width_ratio * garment_width, width=box_w,
height=self.height_ratio * garment_height, height=box_h,
rotation=self.rotation, rotation=self.rotation,
) )
+8 -8
View File
@@ -15,26 +15,26 @@ _TEMPLATES_FILENAME = "templates.json"
BUILTIN_TEMPLATES: List[Template] = [ BUILTIN_TEMPLATES: List[Template] = [
Template( Template(
name="正方形模板", name="正方形模板",
x_ratio=0.25, y_ratio=0.25, x_ratio=0.34, y_ratio=0.22,
width_ratio=0.50, height_ratio=0.50, width_ratio=0.32, height_ratio=0.32,
rotation=0.0, type="builtin", rotation=0.0, type="builtin",
), ),
Template( Template(
name="纵向长方形模板", name="纵向长方形模板",
x_ratio=0.30, y_ratio=0.15, x_ratio=0.35, y_ratio=0.16,
width_ratio=0.40, height_ratio=0.55, width_ratio=0.30, height_ratio=0.46,
rotation=0.0, type="builtin", rotation=0.0, type="builtin",
), ),
Template( Template(
name="横向长方形模板", name="横向长方形模板",
x_ratio=0.15, y_ratio=0.30, x_ratio=0.27, y_ratio=0.26,
width_ratio=0.70, height_ratio=0.40, width_ratio=0.46, height_ratio=0.30,
rotation=0.0, type="builtin", rotation=0.0, type="builtin",
), ),
Template( Template(
name="左胸小号模板", name="左胸小号模板",
x_ratio=0.35, y_ratio=0.28, x_ratio=0.50, y_ratio=0.22,
width_ratio=0.12, height_ratio=0.12, width_ratio=0.16, height_ratio=0.16,
rotation=0.0, type="builtin", rotation=0.0, type="builtin",
), ),
] ]
+42
View File
@@ -192,6 +192,48 @@ class TestTemplateToTransformState(unittest.TestCase):
self.assertAlmostEqual(state.width, 100.0) self.assertAlmostEqual(state.width, 100.0)
self.assertAlmostEqual(state.height, 200.0) self.assertAlmostEqual(state.height, 200.0)
def test_aspect_fit_square_box_wide_print(self):
"""A wide print in a square box fits by width and stays undistorted."""
from core.models import Template
# Box: 200x200 at (100,100) on a 400x400 garment
t = Template(name="t", x_ratio=0.25, y_ratio=0.25,
width_ratio=0.5, height_ratio=0.5)
state = t.to_transform_state(400, 400, print_width=100, print_height=50)
# Wide print (2:1) → limited by width: 200 wide, 100 tall
self.assertAlmostEqual(state.width, 200.0)
self.assertAlmostEqual(state.height, 100.0)
# Aspect ratio preserved
self.assertAlmostEqual(state.width / state.height, 100 / 50)
# Centered in the box (centre at 200,200)
self.assertAlmostEqual(state.x + state.width / 2, 200.0)
self.assertAlmostEqual(state.y + state.height / 2, 200.0)
def test_aspect_fit_square_box_tall_print(self):
"""A tall print in a square box fits by height and stays undistorted."""
from core.models import Template
t = Template(name="t", x_ratio=0.25, y_ratio=0.25,
width_ratio=0.5, height_ratio=0.5)
state = t.to_transform_state(400, 400, print_width=50, print_height=100)
# Tall print (1:2) → limited by height: 100 wide, 200 tall
self.assertAlmostEqual(state.width, 100.0)
self.assertAlmostEqual(state.height, 200.0)
self.assertAlmostEqual(state.x + state.width / 2, 200.0)
self.assertAlmostEqual(state.y + state.height / 2, 200.0)
def test_aspect_fit_never_exceeds_box(self):
"""Fitted print must never exceed the template box bounds."""
from core.models import Template
t = Template(name="t", x_ratio=0.34, y_ratio=0.22,
width_ratio=0.32, height_ratio=0.32)
# Portrait garment, square print
state = t.to_transform_state(800, 1200, print_width=300, print_height=300)
box_w = 0.32 * 800
box_h = 0.32 * 1200
self.assertLessEqual(state.width, box_w + 1e-6)
self.assertLessEqual(state.height, box_h + 1e-6)
# Square print stays square (no stretch despite portrait garment)
self.assertAlmostEqual(state.width, state.height)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# File scanning # File scanning