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:
+9
-5
@@ -67,19 +67,21 @@
|
||||
|
||||
模板是一组可复用的合成参数。
|
||||
|
||||
模板字段:
|
||||
模板使用**比例坐标**存储,以适配任意尺寸的衣服底图(字段为相对衣服宽高的比例 `x_ratio` / `y_ratio` / `width_ratio` / `height_ratio` 及 `rotation`),应用时再换算为像素:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "左胸小号模板",
|
||||
"x": 120,
|
||||
"y": 180,
|
||||
"width": 160,
|
||||
"height": 160,
|
||||
"x_ratio": 0.50,
|
||||
"y_ratio": 0.22,
|
||||
"width_ratio": 0.16,
|
||||
"height_ratio": 0.16,
|
||||
"rotation": 0
|
||||
}
|
||||
```
|
||||
|
||||
其中 `width_ratio` / `height_ratio` 表示目标框,而非印花最终尺寸;印花在框内按原始宽高比 contain 适配(详见 6.6)。
|
||||
|
||||
## 6. 功能需求
|
||||
|
||||
### 6.1 图片加载
|
||||
@@ -159,6 +161,8 @@
|
||||
|
||||
- 用户点击模板后,印花位置、尺寸、旋转参数立即应用到当前预览。
|
||||
- 模板参数应基于衣服底图尺寸计算,避免只适配单一图片分辨率。
|
||||
- 模板的 `width_ratio` / `height_ratio` 定义衣服上的**目标框**,而非强制拉伸尺寸。已知印花原始尺寸时,印花按自身宽高比缩放后 contain 进目标框并居中,不被拉伸变形;未知印花尺寸时退化为铺满目标框。
|
||||
- 内置模板尺寸以贴近真实印花占比为准(例如正方形模板约为衣服宽度的 1/3),避免印花占据过大面积。
|
||||
|
||||
### 6.7 自定义模板
|
||||
|
||||
|
||||
+20
-7
@@ -89,20 +89,22 @@
|
||||
|
||||
控件:
|
||||
|
||||
- `打开衣服文件夹` 按钮。
|
||||
- `打开衣服文件夹` 按钮,作为该区域的主操作,使用主色填充按钮(见第 10 节按钮层级)。
|
||||
- 图片数量说明,例如:`含子文件夹 · 共 12`。
|
||||
- 全选复选框。
|
||||
- 已选数量。
|
||||
- 排序选择,例如:`文件夹+名称`。
|
||||
- 缩略图网格。
|
||||
|
||||
缩略图项显示:
|
||||
缩略图网格采用**每行多张、随面板宽度自动换行**的卡片布局(非每行一张的列表)。
|
||||
|
||||
- 图片预览。
|
||||
- 勾选框。
|
||||
- 来源子文件夹标签。
|
||||
- 文件名。
|
||||
- 当前预览选中态。
|
||||
缩略图卡片显示:
|
||||
|
||||
- 图片预览(卡片上方)。
|
||||
- 勾选框(叠加在缩略图左上角)。
|
||||
- 文件名(卡片下方,过长时中间省略)。
|
||||
- 来源子文件夹(鼠标悬停 tooltip 显示,节省网格空间)。
|
||||
- 当前预览选中态(卡片高亮)。
|
||||
|
||||
交互规则:
|
||||
|
||||
@@ -206,6 +208,7 @@ Shift 锁比例
|
||||
- 选择模板后更新当前印花位置、尺寸和旋转。
|
||||
- 保存用于覆盖当前自定义模板。
|
||||
- 另存为用于创建新模板。
|
||||
- `保存` 仅对自定义模板有意义:选中内置模板时**隐藏**该按钮(而非禁用置灰),避免出现长期灰显且无法说明原因的控件。
|
||||
|
||||
### 7.2 位置区域
|
||||
|
||||
@@ -240,6 +243,7 @@ Shift 锁比例
|
||||
控件:
|
||||
|
||||
- `角度` 数字输入框。
|
||||
- `归零` 按钮(紧邻角度输入框,一键将角度还原为 0,即拉直印花)。
|
||||
- `左旋 90°` 按钮。
|
||||
- `右旋 90°` 按钮。
|
||||
|
||||
@@ -249,6 +253,7 @@ Shift 锁比例
|
||||
- 支持负角度。
|
||||
- 顶部旋转控制点拖动后同步角度输入框。
|
||||
- 按钮旋转后同步预览和角度输入框。
|
||||
- 参数栏底部提供 `重置位置 / 尺寸 / 角度` 按钮,将三者一并还原为当前所选模板(与导出区的 `重置为模板` 等效,就近放在参数编辑处)。
|
||||
|
||||
### 7.5 输出设置区域
|
||||
|
||||
@@ -439,6 +444,14 @@ Microsoft YaHei
|
||||
- 控件圆角保持小半径,约 3 px。
|
||||
- 不使用大圆角卡片。
|
||||
|
||||
按钮层级(一套主色,按角色克制使用):
|
||||
|
||||
- **主按钮**:主色填充、白字。用于每个区域「推动流程前进」的唯一动作:左侧 `打开文件夹`、右侧 `导出当前单张`、底部 `开始批量导出`。三者分处不同区域,各自是本区域主操作,不互相竞争。
|
||||
- **次按钮**:白底、主色描边与文字。用于有意义但非主线的动作,如 `重置位置 / 尺寸 / 角度`、`应用为模板`。
|
||||
- **辅助按钮**:浅灰底、细边框。用于就地微调类动作,如 `浏览…`、排序、`左旋 / 右旋 / 归零`。
|
||||
|
||||
避免所有按钮使用同一种灰色样式而丧失层级;同一动作在不同位置应使用一致的文案与层级。
|
||||
|
||||
## 11. PySide6 控件映射
|
||||
|
||||
建议映射:
|
||||
|
||||
+13
-1
@@ -269,6 +269,9 @@ class MainWindow(QMainWindow):
|
||||
self.image_canvas.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 selection updates canvas, param panel, and queue template
|
||||
self.template_panel.template_applied.connect(self.image_canvas.set_transform)
|
||||
@@ -305,6 +308,13 @@ class MainWindow(QMainWindow):
|
||||
path = str(asset.path)
|
||||
self.image_canvas.load_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):
|
||||
"""Load a queue item into the canvas and arm fine-tune tracking."""
|
||||
@@ -321,11 +331,13 @@ class MainWindow(QMainWindow):
|
||||
if item.transform:
|
||||
self.image_canvas.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:
|
||||
from PIL import Image as _PilImage
|
||||
with _PilImage.open(garment_path) as img:
|
||||
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:
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from pathlib import Path
|
||||
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.QtWidgets import (
|
||||
QCheckBox,
|
||||
@@ -24,7 +24,9 @@ from services.file_service import scan_image_folder
|
||||
|
||||
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)
|
||||
_UNCHECKED = 0
|
||||
@@ -33,7 +35,7 @@ _CHECKED = 2
|
||||
|
||||
|
||||
class _AssetItemWidget(QWidget):
|
||||
"""Single row: checkbox + thumbnail + subfolder label + filename."""
|
||||
"""Grid card: thumbnail with checkbox overlay on top, filename below."""
|
||||
|
||||
check_changed = Signal()
|
||||
|
||||
@@ -51,46 +53,51 @@ class _AssetItemWidget(QWidget):
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(4, 3, 4, 3)
|
||||
row.setSpacing(6)
|
||||
self.setObjectName("assetCard")
|
||||
self.setFixedSize(_CARD_W, _CARD_H)
|
||||
|
||||
# Batch-selection checkbox
|
||||
self._check = QCheckBox()
|
||||
self._check.setChecked(self._asset.selected)
|
||||
self._check.stateChanged.connect(self._on_check_changed)
|
||||
col = QVBoxLayout(self)
|
||||
col.setContentsMargins(4, 4, 4, 4)
|
||||
col.setSpacing(3)
|
||||
|
||||
# Thumbnail
|
||||
self._thumb = QLabel()
|
||||
self._thumb.setFixedSize(_THUMB_SIZE, _THUMB_SIZE)
|
||||
self._thumb.setAlignment(Qt.AlignCenter)
|
||||
# Thumbnail with the batch-selection checkbox overlaid on its corner
|
||||
thumb_box = QWidget()
|
||||
thumb_box.setFixedSize(_THUMB_SIZE, _THUMB_SIZE)
|
||||
|
||||
self._thumb = QLabel(thumb_box)
|
||||
self._thumb.setObjectName("thumbLabel")
|
||||
self._thumb.setGeometry(0, 0, _THUMB_SIZE, _THUMB_SIZE)
|
||||
self._thumb.setAlignment(Qt.AlignCenter)
|
||||
self._load_thumbnail()
|
||||
|
||||
# Info column: subfolder + filename
|
||||
info_col = QWidget()
|
||||
info_layout = QVBoxLayout(info_col)
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
info_layout.setSpacing(2)
|
||||
self._check = QCheckBox(thumb_box)
|
||||
self._check.setObjectName("cardCheck")
|
||||
self._check.setChecked(self._asset.selected)
|
||||
self._check.stateChanged.connect(self._on_check_changed)
|
||||
self._check.move(3, 3)
|
||||
|
||||
subfolder = self._compute_subfolder()
|
||||
self._folder_label = QLabel(subfolder)
|
||||
self._folder_label.setObjectName("subfolderLabel")
|
||||
self._folder_label.setVisible(bool(subfolder))
|
||||
thumb_row = QHBoxLayout()
|
||||
thumb_row.setContentsMargins(0, 0, 0, 0)
|
||||
thumb_row.addStretch()
|
||||
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.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)
|
||||
info_layout.addWidget(self._name_label)
|
||||
info_layout.addStretch()
|
||||
|
||||
row.addWidget(self._check, 0, Qt.AlignVCenter)
|
||||
row.addWidget(self._thumb)
|
||||
row.addWidget(info_col, 1)
|
||||
|
||||
self.setFixedHeight(64)
|
||||
# Subfolder + filename shown on hover (no room for a label in the grid)
|
||||
subfolder = self._compute_subfolder()
|
||||
self.setToolTip("{}/{}".format(subfolder, name) if subfolder else name)
|
||||
|
||||
def _compute_subfolder(self) -> str:
|
||||
asset_path = Path(self._asset.path)
|
||||
@@ -136,7 +143,9 @@ class _AssetItemWidget(QWidget):
|
||||
def set_preview_selected(self, active: bool):
|
||||
"""Highlight or clear the current-preview indicator."""
|
||||
if active:
|
||||
self.setStyleSheet("background-color: #cce4f7;")
|
||||
self.setStyleSheet(
|
||||
"#assetCard { background-color: #cce4f7; border-radius: 4px; }"
|
||||
)
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
|
||||
@@ -166,11 +175,19 @@ class _AssetPanel(QWidget):
|
||||
|
||||
self._list = QListWidget()
|
||||
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.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
# Disable QListWidget's own selection highlight — preview uses custom style
|
||||
self._list.setSelectionMode(QListWidget.NoSelection)
|
||||
self._list.setSpacing(1)
|
||||
self._list.itemClicked.connect(self._on_item_clicked)
|
||||
|
||||
col.addWidget(self._list, 1)
|
||||
@@ -384,12 +401,15 @@ class ImageListPanel(QWidget):
|
||||
}
|
||||
#openFolderBtn {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d6d6d6;
|
||||
border-radius: 3px;
|
||||
background: #ffffff;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #0078d4;
|
||||
}
|
||||
#openFolderBtn:hover { background: #e8e8e8; }
|
||||
#openFolderBtn:hover { background: #106ebe; }
|
||||
#openFolderBtn:pressed { background: #005a9e; }
|
||||
#ctrlRow {
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
@@ -410,13 +430,9 @@ class ImageListPanel(QWidget):
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
#subfolderLabel {
|
||||
font-size: 10px;
|
||||
color: #888888;
|
||||
}
|
||||
#fileNameLabel {
|
||||
font-size: 12px;
|
||||
color: #1a1a1a;
|
||||
font-size: 11px;
|
||||
color: #333333;
|
||||
}
|
||||
#thumbLabel {
|
||||
background-color: #f0f0f0;
|
||||
@@ -425,6 +441,10 @@ class ImageListPanel(QWidget):
|
||||
color: #aaaaaa;
|
||||
font-size: 16px;
|
||||
}
|
||||
#cardCheck {
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSplitter#assetSplitter::handle {
|
||||
background-color: #d6d6d6;
|
||||
height: 3px;
|
||||
|
||||
@@ -32,6 +32,8 @@ class TemplatePanel(QWidget):
|
||||
self._current_state: Optional[TransformState] = None
|
||||
self._garment_w: 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._setup_ui()
|
||||
self._load_templates()
|
||||
@@ -110,7 +112,9 @@ class TemplatePanel(QWidget):
|
||||
def _update_button_states(self):
|
||||
t = self._current_template()
|
||||
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]:
|
||||
return self._combo.currentData()
|
||||
@@ -122,6 +126,18 @@ class TemplatePanel(QWidget):
|
||||
self._garment_w = w
|
||||
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):
|
||||
"""Store current print transform (used when saving a template)."""
|
||||
self._current_state = state
|
||||
@@ -134,7 +150,7 @@ class TemplatePanel(QWidget):
|
||||
self._update_button_states()
|
||||
t = self._combo.itemData(index)
|
||||
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)
|
||||
self.template_applied.emit(state)
|
||||
|
||||
@@ -146,7 +162,7 @@ class TemplatePanel(QWidget):
|
||||
if self._garment_w <= 0 or self._garment_h <= 0:
|
||||
QMessageBox.information(self, "提示", "请先加载衣服图片。")
|
||||
return
|
||||
state = t.to_transform_state(self._garment_w, self._garment_h)
|
||||
state = self._state_for_template(t)
|
||||
self.template_applied.emit(state)
|
||||
|
||||
def _save_template(self):
|
||||
|
||||
@@ -23,6 +23,7 @@ class TransformPanel(QWidget):
|
||||
"""Right-panel section: position, size, aspect-ratio lock, rotation."""
|
||||
|
||||
transform_changed = Signal(object) # emits TransformState
|
||||
reset_to_template = Signal() # "重置位置/尺寸/角度" clicked
|
||||
|
||||
def __init__(self, parent=None):
|
||||
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_size_form()))
|
||||
col.addWidget(self._make_section("旋转", self._make_rot_form()))
|
||||
col.addWidget(self._make_footer())
|
||||
|
||||
def _make_section(self, title: str, body: QWidget) -> QWidget:
|
||||
section = QWidget()
|
||||
@@ -113,7 +115,21 @@ class TransformPanel(QWidget):
|
||||
self._rot_spin.setDecimals(1)
|
||||
self._rot_spin.setSingleStep(1.0)
|
||||
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)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
@@ -132,6 +148,20 @@ class TransformPanel(QWidget):
|
||||
col.addLayout(btn_row)
|
||||
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
|
||||
def _make_spin(minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox:
|
||||
spin = QDoubleSpinBox()
|
||||
@@ -177,6 +207,31 @@ class TransformPanel(QWidget):
|
||||
}
|
||||
#rotBtn:hover { background: #e0e0e0; }
|
||||
#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 ────────────────────────────────────────────────────────────
|
||||
@@ -263,6 +318,9 @@ class TransformPanel(QWidget):
|
||||
self._aspect_ratio = w / h
|
||||
self._emit()
|
||||
|
||||
def _reset_rotation(self):
|
||||
self._rot_spin.setValue(0.0)
|
||||
|
||||
def _rotate_left(self):
|
||||
self._rot_spin.setValue(self._rot_spin.value() - 90.0)
|
||||
|
||||
|
||||
+36
-6
@@ -59,13 +59,43 @@ class Template:
|
||||
rotation: float = 0.0
|
||||
type: str = "custom" # "builtin" | "custom"
|
||||
|
||||
def to_transform_state(self, garment_width: float, garment_height: float) -> TransformState:
|
||||
"""将比例参数转换为针对指定衣服尺寸的像素坐标 TransformState。"""
|
||||
def to_transform_state(
|
||||
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=self.x_ratio * garment_width,
|
||||
y=self.y_ratio * garment_height,
|
||||
width=self.width_ratio * garment_width,
|
||||
height=self.height_ratio * garment_height,
|
||||
x=cx - draw_w / 2.0,
|
||||
y=cy - draw_h / 2.0,
|
||||
width=draw_w,
|
||||
height=draw_h,
|
||||
rotation=self.rotation,
|
||||
)
|
||||
|
||||
return TransformState(
|
||||
x=box_x,
|
||||
y=box_y,
|
||||
width=box_w,
|
||||
height=box_h,
|
||||
rotation=self.rotation,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,26 +15,26 @@ _TEMPLATES_FILENAME = "templates.json"
|
||||
BUILTIN_TEMPLATES: List[Template] = [
|
||||
Template(
|
||||
name="正方形模板",
|
||||
x_ratio=0.25, y_ratio=0.25,
|
||||
width_ratio=0.50, height_ratio=0.50,
|
||||
x_ratio=0.34, y_ratio=0.22,
|
||||
width_ratio=0.32, height_ratio=0.32,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
Template(
|
||||
name="纵向长方形模板",
|
||||
x_ratio=0.30, y_ratio=0.15,
|
||||
width_ratio=0.40, height_ratio=0.55,
|
||||
x_ratio=0.35, y_ratio=0.16,
|
||||
width_ratio=0.30, height_ratio=0.46,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
Template(
|
||||
name="横向长方形模板",
|
||||
x_ratio=0.15, y_ratio=0.30,
|
||||
width_ratio=0.70, height_ratio=0.40,
|
||||
x_ratio=0.27, y_ratio=0.26,
|
||||
width_ratio=0.46, height_ratio=0.30,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
Template(
|
||||
name="左胸小号模板",
|
||||
x_ratio=0.35, y_ratio=0.28,
|
||||
width_ratio=0.12, height_ratio=0.12,
|
||||
x_ratio=0.50, y_ratio=0.22,
|
||||
width_ratio=0.16, height_ratio=0.16,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -192,6 +192,48 @@ class TestTemplateToTransformState(unittest.TestCase):
|
||||
self.assertAlmostEqual(state.width, 100.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
|
||||
|
||||
Reference in New Issue
Block a user