From 8a34fe93ff61c26f7bd5fca8384d7462a113738c Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 16 Jun 2026 15:11:56 +0800 Subject: [PATCH] feat: thumbnail grid, aspect-aware templates, and UI hierarchy polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/02-prd.md | 14 ++-- docs/07-ui-design.md | 27 +++++-- src/app/main_window.py | 14 +++- src/app/widgets/image_list_panel.py | 114 ++++++++++++++++------------ src/app/widgets/template_panel.py | 22 +++++- src/app/widgets/transform_panel.py | 60 ++++++++++++++- src/core/models.py | 42 ++++++++-- src/services/template_service.py | 16 ++-- tests/test_templates.py | 42 ++++++++++ 9 files changed, 273 insertions(+), 78 deletions(-) diff --git a/docs/02-prd.md b/docs/02-prd.md index c9e4638..b5ec978 100644 --- a/docs/02-prd.md +++ b/docs/02-prd.md @@ -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 自定义模板 diff --git a/docs/07-ui-design.md b/docs/07-ui-design.md index 6278b22..c2b9d89 100644 --- a/docs/07-ui-design.md +++ b/docs/07-ui-design.md @@ -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 控件映射 建议映射: diff --git a/src/app/main_window.py b/src/app/main_window.py index 7653824..ea5c383 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -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: diff --git a/src/app/widgets/image_list_panel.py b/src/app/widgets/image_list_panel.py index 1c4b539..784fdf9 100644 --- a/src/app/widgets/image_list_panel.py +++ b/src/app/widgets/image_list_panel.py @@ -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; diff --git a/src/app/widgets/template_panel.py b/src/app/widgets/template_panel.py index 3bd28aa..e13cb84 100644 --- a/src/app/widgets/template_panel.py +++ b/src/app/widgets/template_panel.py @@ -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): diff --git a/src/app/widgets/transform_panel.py b/src/app/widgets/transform_panel.py index 5add783..c0a4ef1 100644 --- a/src/app/widgets/transform_panel.py +++ b/src/app/widgets/transform_panel.py @@ -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) diff --git a/src/core/models.py b/src/core/models.py index ca2ec79..eaa4d1e 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -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=cx - draw_w / 2.0, + y=cy - draw_h / 2.0, + width=draw_w, + height=draw_h, + rotation=self.rotation, + ) + 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=box_x, + y=box_y, + width=box_w, + height=box_h, rotation=self.rotation, ) diff --git a/src/services/template_service.py b/src/services/template_service.py index 32c3a23..23ee83e 100644 --- a/src/services/template_service.py +++ b/src/services/template_service.py @@ -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", ), ] diff --git a/tests/test_templates.py b/tests/test_templates.py index fb48ea1..39a8805 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -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