diff --git a/components/Canvas.qml b/components/Canvas.qml index 86336d8..3b0cdcc 100644 --- a/components/Canvas.qml +++ b/components/Canvas.qml @@ -91,14 +91,14 @@ Item { onOverlayRotateRequested: function (angle) { var idx = Lucent.SelectionManager.selectedItemIndex; if (idx >= 0 && canvasModel) { - canvasModel.updateTransformProperty(idx, "rotate", angle); + canvasModel.rotateItem(idx, angle); } } onOverlayScaleResizeRequested: function (scaleX, scaleY, anchorX, anchorY) { var idx = Lucent.SelectionManager.selectedItemIndex; if (idx >= 0 && canvasModel) { - canvasModel.applyScaleResize(idx, scaleX, scaleY, anchorX, anchorY); + canvasModel.scaleItem(idx, scaleX, scaleY, anchorX, anchorY); } } @@ -371,7 +371,7 @@ Item { var indices = Lucent.SelectionManager.selectedIndices || []; if (indices.length === 0) return; - canvasModel.moveItems(indices, canvasDx, canvasDy); + canvasModel.translateItems(indices, canvasDx, canvasDy); } function deleteSelectedItem() { diff --git a/components/PathEditController.qml b/components/PathEditController.qml index 835fc33..98bf4a0 100644 --- a/components/PathEditController.qml +++ b/components/PathEditController.qml @@ -95,7 +95,7 @@ QtObject { } } - canvasModel.updateGeometryWithOriginCompensation(idx, { + canvasModel.updateGeometryLocked(idx, { points: newPoints, closed: item.geometry.closed }); @@ -175,7 +175,7 @@ QtObject { } } - canvasModel.updateGeometryWithOriginCompensation(idx, { + canvasModel.updateGeometryLocked(idx, { points: newPoints, closed: item.geometry.closed }); @@ -205,11 +205,9 @@ QtObject { canvasModel.removeItem(idx); Lucent.SelectionManager.exitEditMode(); } else { - canvasModel.updateItem(idx, { - geometry: { - points: newPoints, - closed: item.geometry.closed && newPoints.length >= 3 - } + canvasModel.updateGeometryLocked(idx, { + points: newPoints, + closed: item.geometry.closed && newPoints.length >= 3 }); Lucent.SelectionManager.clearPointSelection(); } diff --git a/components/panels/TransformPanel.qml b/components/panels/TransformPanel.qml index b768d07..d48886c 100644 --- a/components/panels/TransformPanel.qml +++ b/components/panels/TransformPanel.qml @@ -462,7 +462,7 @@ Item { // Model normalizes to 0-360° automatically var val = parseInt(text) || 0; - canvasModel.updateTransformProperty(root.selectedIndex, "rotate", val); + canvasModel.rotateItem(root.selectedIndex, val); appController.focusCanvas(); isCommitting = false; @@ -487,7 +487,7 @@ Item { canvasModel.endTransaction(); } - onMoved: canvasModel.updateTransformProperty(root.selectedIndex, "rotate", value) + onMoved: canvasModel.rotateItem(root.selectedIndex, value) handle: Rectangle { x: rotationSlider.leftPadding + rotationSlider.visualPosition * (rotationSlider.availableWidth - width) diff --git a/src/lucent/canvas_model.py b/src/lucent/canvas_model.py index 894008d..700812e 100644 --- a/src/lucent/canvas_model.py +++ b/src/lucent/canvas_model.py @@ -4,7 +4,6 @@ """Canvas model for Lucent - manages canvas items.""" from typing import List, Optional, Dict, Any, Union -import os from PySide6.QtCore import ( QAbstractListModel, QModelIndex, @@ -37,6 +36,7 @@ ) from lucent.history_manager import HistoryManager from lucent.edit_context import EditContext +from lucent.transform_service import TransformService from lucent.item_schema import ( parse_item, parse_item_data, @@ -100,6 +100,17 @@ def __init__( # Edit context for stable drag operations self._edit_context = EditContext() + self._transform_service = TransformService( + get_item=self.getItem, + is_valid_index=self._is_valid_index, + edit_context=self._edit_context, + item_to_dict=self._itemToDict, + update_item=self.updateItem, + emit_transform_changed=self.itemTransformChanged.emit, + begin_transaction=self.beginTransaction, + end_transaction=self.endTransaction, + ) + # Connect signals to update spatial index self.itemAdded.connect(self._on_item_added_spatial) self.itemRemoved.connect(self._on_item_removed_spatial) @@ -253,6 +264,9 @@ def _get_descendant_indices(self, container_id: str) -> List[int]: """Return indices of all descendants (any depth) of a container.""" return get_descendant_indices(self._items, container_id, self._is_container) + def _is_valid_index(self, index: int) -> bool: + return 0 <= index < len(self._items) + def _move_single_item(self, idx: int, dx: float, dy: float) -> Optional[Command]: """Move a single item by dx, dy. Returns UpdateItemCommand or None.""" if not (0 <= idx < len(self._items)): @@ -265,7 +279,6 @@ def _move_single_item(self, idx: int, dx: float, dy: float) -> Optional[Command] return None old_data = self._itemToDict(item) - self._log_move_debug("before", idx, dx, dy, item) new_data = dict(old_data) new_transform = item.transform.to_dict() if hasattr(item, "transform") else {} new_transform["translateX"] = item.transform.translate_x + dx @@ -273,7 +286,6 @@ def _move_single_item(self, idx: int, dx: float, dy: float) -> Optional[Command] new_data["transform"] = new_transform cmd = UpdateItemCommand(self, idx, old_data, new_data) - self._log_move_debug("after", idx, dx, dy, item) return cmd @Slot(list, float, float) @@ -361,56 +373,17 @@ def moveItems(self, indices: List[int], dx: float, dy: float) -> None: container_idx, self._itemToDict(self._items[container_idx]) ) - def _log_move_debug( - self, phase: str, idx: int, dx: float, dy: float, item: CanvasItem - ) -> None: - if os.getenv("LUCENT_DEBUG_MOVE") != "1": - return - if not hasattr(item, "geometry"): - return - bounds = compute_geometry_bounds(item) - transform = item.transform.to_dict() if hasattr(item, "transform") else None - geom = item.geometry.to_dict() if hasattr(item, "geometry") else None - print( - "[move-debug]", - phase, - "idx=", - idx, - "dx=", - dx, - "dy=", - dy, - "bounds=", - bounds, - "transform=", - transform, - "geometry=", - geom, - ) + @Slot(list, float, float) + def translateItems(self, indices: List[int], dx: float, dy: float) -> None: + """Translate items by dx, dy using transform-only movement.""" + self.moveItems(indices, dx, dy) @Slot(int, float, float) - def moveGroup(self, group_index: int, dx: float, dy: float) -> None: - """Translate all descendant shapes of a group/layer by dx, dy.""" - if not (0 <= group_index < len(self._items)): - return - container = self._items[group_index] - if not isinstance(container, (GroupItem, LayerItem)): + def translateItem(self, index: int, dx: float, dy: float) -> None: + """Translate a single item by dx, dy using transform-only movement.""" + if not (0 <= index < len(self._items)): return - # Apply deltas to descendant shapes - for idx in self._get_descendant_indices(container.id): - item = self._items[idx] - if hasattr(item, "geometry"): - item_data = self._itemToDict(item) - new_transform = ( - item.transform.to_dict() if hasattr(item, "transform") else {} - ) - new_transform["translateX"] = item.transform.translate_x + dx - new_transform["translateY"] = item.transform.translate_y + dy - item_data["transform"] = new_transform - self.updateItem(idx, item_data) - - # Refresh container selection overlays that depend on descendant bounds. - self.itemModified.emit(group_index, self._itemToDict(container)) + self.translateItems([index], dx, dy) @Slot(int) def ungroup(self, group_index: int) -> None: @@ -978,81 +951,7 @@ def getItemTransform(self, index: int) -> Optional[Dict[str, Any]]: Dictionary with translateX, translateY, rotate, scaleX, scaleY or None if item doesn't support transforms. """ - if not (0 <= index < len(self._items)): - return None - item = self._items[index] - if not hasattr(item, "transform"): - return None - - transform_dict = item.transform.to_dict() - bounds = compute_geometry_bounds(item) - if not bounds: - transform_dict["originX"] = 0 - transform_dict["originY"] = 0 - return transform_dict - - origin_x, origin_y = self._derive_origin_from_pivot( - bounds, item.transform.pivot_x, item.transform.pivot_y - ) - if os.getenv("LUCENT_DEBUG_MOVE") == "1": - print( - "[move-debug]", - "origin", - "idx=", - index, - "bounds=", - bounds, - "pivot=", - (item.transform.pivot_x, item.transform.pivot_y), - "origin=", - (origin_x, origin_y), - "translate=", - (item.transform.translate_x, item.transform.translate_y), - "rotate=", - item.transform.rotate, - "scale=", - (item.transform.scale_x, item.transform.scale_y), - ) - transform_dict["originX"] = origin_x - transform_dict["originY"] = origin_y - return transform_dict - - @staticmethod - def _derive_origin_from_pivot( - bounds: Dict[str, float], pivot_x: float, pivot_y: float - ) -> tuple[float, float]: - """Convert absolute pivot coords into normalized origin for UI.""" - origin_x = 0.0 - origin_y = 0.0 - if bounds["width"] != 0: - origin_x = (pivot_x - bounds["x"]) / bounds["width"] - if bounds["height"] != 0: - origin_y = (pivot_y - bounds["y"]) / bounds["height"] - return origin_x, origin_y - - @staticmethod - def _pivot_from_origin( - bounds: Dict[str, float], origin_x: float, origin_y: float - ) -> tuple[float, float]: - """Convert normalized origin into absolute pivot coords.""" - pivot_x = bounds["x"] + bounds["width"] * origin_x - pivot_y = bounds["y"] + bounds["height"] * origin_y - return pivot_x, pivot_y - - @staticmethod - def _normalizeRotation(degrees: float) -> float: - """Normalize rotation to 0-360° range. - - Args: - degrees: Rotation in degrees (any value). - - Returns: - Rotation normalized to 0 <= value < 360. - """ - normalized = degrees % 360 - if normalized < 0: - normalized += 360 - return normalized + return self._transform_service.get_item_transform(index) @Slot(int, dict) def setItemTransform(self, index: int, transform: Dict[str, Any]) -> None: @@ -1064,31 +963,7 @@ def setItemTransform(self, index: int, transform: Dict[str, Any]) -> None: index: Index of the item. transform: Dictionary with transform properties. """ - if not (0 <= index < len(self._items)): - return - item = self._items[index] - if not hasattr(item, "transform"): - return - - # Normalize rotation to 0-360° range - if "rotate" in transform: - transform = dict(transform) # Copy to avoid mutating input - transform["rotate"] = self._normalizeRotation(transform["rotate"]) - - bounds = compute_geometry_bounds(item) - if not bounds: - return - - if "pivotX" not in transform or "pivotY" not in transform: - transform = dict(transform) - transform["pivotX"] = item.transform.pivot_x - transform["pivotY"] = item.transform.pivot_y - - # Get current item data and update transform - current_data = self._itemToDict(item) - current_data["transform"] = transform - self.updateItem(index, current_data) - self.itemTransformChanged.emit(index) + self._transform_service.set_item_transform(index, transform) @Slot(int, str, float) def updateTransformProperty(self, index: int, prop: str, value: float) -> None: @@ -1099,21 +974,12 @@ def updateTransformProperty(self, index: int, prop: str, value: float) -> None: prop: Property name (translateX, translateY, rotate, scaleX, scaleY). value: New value for the property. """ - item = self._items[index] - if not hasattr(item, "transform"): - return + self._transform_service.update_transform_property(index, prop, value) - new_transform = { - "translateX": item.transform.translate_x, - "translateY": item.transform.translate_y, - "rotate": item.transform.rotate, - "scaleX": item.transform.scale_x, - "scaleY": item.transform.scale_y, - "pivotX": item.transform.pivot_x, - "pivotY": item.transform.pivot_y, - } - new_transform[prop] = value - self.setItemTransform(index, new_transform) + @Slot(int, float) + def rotateItem(self, index: int, angle: float) -> None: + """Rotate an item by setting its transform rotation.""" + self._transform_service.rotate_item(index, angle) @Slot(int, result="QVariant") # type: ignore[arg-type] def getDisplayedPosition(self, index: int) -> Optional[Dict[str, float]]: @@ -1127,37 +993,7 @@ def getDisplayedPosition(self, index: int) -> Optional[Dict[str, float]]: Returns: Dictionary with x, y or None if not applicable. """ - if not (0 <= index < len(self._items)): - return None - - item = self._items[index] - if not hasattr(item, "transform"): - return None - - bounds = compute_geometry_bounds(item) - if not bounds: - return None - - if os.getenv("LUCENT_DEBUG_MOVE") == "1": - print( - "[move-debug]", - "displayed", - "idx=", - index, - "pivot=", - (item.transform.pivot_x, item.transform.pivot_y), - "translate=", - (item.transform.translate_x, item.transform.translate_y), - "displayed=", - ( - item.transform.pivot_x + item.transform.translate_x, - item.transform.pivot_y + item.transform.translate_y, - ), - ) - return { - "x": item.transform.pivot_x + item.transform.translate_x, - "y": item.transform.pivot_y + item.transform.translate_y, - } + return self._transform_service.get_displayed_position(index) @Slot(int, result="QVariant") # type: ignore[arg-type] def getDisplayedSize(self, index: int) -> Optional[Dict[str, float]]: @@ -1169,25 +1005,7 @@ def getDisplayedSize(self, index: int) -> Optional[Dict[str, float]]: Returns: Dictionary with width, height or None if not applicable. """ - if not (0 <= index < len(self._items)): - return None - - item = self._items[index] - if not hasattr(item, "transform"): - return None - - bounds = compute_geometry_bounds(item) - if not bounds: - return None - - current = self.getItemTransform(index) or {} - scale_x = current.get("scaleX", 1) - scale_y = current.get("scaleY", 1) - - return { - "width": bounds["width"] * scale_x, - "height": bounds["height"] * scale_y, - } + return self._transform_service.get_displayed_size(index) @Slot(int, result="QVariant") # type: ignore[arg-type] def getTransformedPathPoints(self, index: int) -> Optional[List[Dict[str, Any]]]: @@ -1247,65 +1065,20 @@ def _point_to_dict(pt: Dict[str, Any]) -> Dict[str, Any]: } return result - @Slot(int, float, float, result="QVariant") # type: ignore[arg-type] - def transformPointToGeometry( - self, index: int, screen_x: float, screen_y: float - ) -> Optional[Dict[str, float]]: - """Transform a screen-space point back to geometry space. - - This is the inverse of getTransformedPathPoints, used when dragging - handles to convert screen positions to geometry coordinates. - - Args: - index: Index of the path item. - screen_x: X coordinate in screen space. - screen_y: Y coordinate in screen space. - - Returns: - Dictionary with x, y in geometry space, or None if failed. - """ - if not (0 <= index < len(self._items)): - return None - - item = self._items[index] - if not hasattr(item, "transform") or not hasattr(item, "geometry"): - return None - - return self._edit_context.map_screen_to_geometry( - item.transform, screen_x, screen_y - ) - @Slot(int) def lockEditTransform(self, index: int) -> None: """Lock the edit pivot (geometry space) for stable drag mapping.""" - if not (0 <= index < len(self._items)): - return - - item = self._items[index] - if not hasattr(item, "transform") or not hasattr(item, "geometry"): - return - - self._edit_context.lock_pivot( - index, item.transform.pivot_x, item.transform.pivot_y - ) + self._transform_service.lock_edit_transform(index) @Slot(int) def unlockEditTransform(self, index: int) -> None: """Clear the locked edit transform after drag ends.""" - self._edit_context.unlock_pivot(index) + self._transform_service.unlock_edit_transform(index) @Slot(int, dict) - def updateGeometryWithOriginCompensation( - self, index: int, geometry_data: Dict[str, Any] - ) -> None: - """Update item geometry while keeping the transform pivot stable. - - With absolute pivots, geometry updates do not require compensation. - """ - if not (0 <= index < len(self._items)): - return - - self.updateItem(index, {"geometry": geometry_data}) + def updateGeometryLocked(self, index: int, geometry_data: Dict[str, Any]) -> None: + """Update geometry without altering the current transform.""" + self._transform_service.update_geometry_locked(index, geometry_data) @Slot(int, float, float, result="QVariant") # type: ignore[arg-type] def transformPointToGeometryLocked( @@ -1316,22 +1089,8 @@ def transformPointToGeometryLocked( Uses a locked pivot in geometry space for consistent mapping during drag. Falls back to current transform if not locked. """ - - if not (0 <= index < len(self._items)): - return None - - item = self._items[index] - if not hasattr(item, "transform") or not hasattr(item, "geometry"): - return None - - locked_pivot = self._edit_context.get_locked_pivot(index) - if locked_pivot: - return self._edit_context.map_screen_to_geometry( - item.transform, screen_x, screen_y, locked_pivot - ) - - return self._edit_context.map_screen_to_geometry( - item.transform, screen_x, screen_y + return self._transform_service.transform_point_to_geometry_locked( + index, screen_x, screen_y ) @Slot(int, result=bool) @@ -1346,22 +1105,7 @@ def hasNonIdentityTransform(self, index: int) -> bool: Returns: True if transform differs from identity, False otherwise. """ - if not (0 <= index < len(self._items)): - return False - - item = self._items[index] - if not hasattr(item, "transform"): - return False - - current = self.getItemTransform(index) or {} - - return ( - current.get("rotate", 0) != 0 - or current.get("scaleX", 1) != 1 - or current.get("scaleY", 1) != 1 - or current.get("translateX", 0) != 0 - or current.get("translateY", 0) != 0 - ) + return self._transform_service.has_non_identity_transform(index) @Slot(int, str, float) def setItemPosition(self, index: int, axis: str, value: float) -> None: @@ -1372,33 +1116,7 @@ def setItemPosition(self, index: int, axis: str, value: float) -> None: axis: "x" or "y". value: New position value in canvas coordinates. """ - if not (0 <= index < len(self._items)): - return - - item = self._items[index] - if not hasattr(item, "transform"): - return - - bounds = compute_geometry_bounds(item) - if not bounds: - return - - new_transform = { - "translateX": item.transform.translate_x, - "translateY": item.transform.translate_y, - "rotate": item.transform.rotate, - "scaleX": item.transform.scale_x, - "scaleY": item.transform.scale_y, - "pivotX": item.transform.pivot_x, - "pivotY": item.transform.pivot_y, - } - - if axis == "x": - new_transform["translateX"] = value - item.transform.pivot_x - else: - new_transform["translateY"] = value - item.transform.pivot_y - - self.setItemTransform(index, new_transform) + self._transform_service.set_item_position(index, axis, value) @Slot(int, str, float, bool) def setDisplayedSize( @@ -1412,50 +1130,9 @@ def setDisplayedSize( value: Target displayed size in pixels. proportional: If True, scale both axes proportionally. """ - if not (0 <= index < len(self._items)): - return - - item = self._items[index] - if not hasattr(item, "transform"): - return - - bounds = compute_geometry_bounds(item) - if not bounds: - return - - # Prevent division by zero - if bounds["width"] <= 0 or bounds["height"] <= 0: - return - - # Minimum displayed size is 1px - value = max(1.0, value) - - current = self.getItemTransform(index) or {} - current_scale_x = current.get("scaleX", 1) - current_scale_y = current.get("scaleY", 1) - - if dimension == "width": - new_scale_x = value / bounds["width"] - if proportional: - ratio = new_scale_x / current_scale_x - new_scale_y = current_scale_y * ratio - self.beginTransaction() - self.updateTransformProperty(index, "scaleX", new_scale_x) - self.updateTransformProperty(index, "scaleY", new_scale_y) - self.endTransaction() - else: - self.updateTransformProperty(index, "scaleX", new_scale_x) - else: - new_scale_y = value / bounds["height"] - if proportional: - ratio = new_scale_y / current_scale_y - new_scale_x = current_scale_x * ratio - self.beginTransaction() - self.updateTransformProperty(index, "scaleX", new_scale_x) - self.updateTransformProperty(index, "scaleY", new_scale_y) - self.endTransaction() - else: - self.updateTransformProperty(index, "scaleY", new_scale_y) + self._transform_service.set_displayed_size( + index, dimension, value, proportional + ) @Slot(int, float, float) def setItemOrigin(self, index: int, new_ox: float, new_oy: float) -> None: @@ -1469,53 +1146,21 @@ def setItemOrigin(self, index: int, new_ox: float, new_oy: float) -> None: new_ox: New origin X (0=left, 0.5=center, 1=right). new_oy: New origin Y (0=top, 0.5=center, 1=bottom). """ - import math - - if not (0 <= index < len(self._items)): - return + self._transform_service.set_item_origin(index, new_ox, new_oy) - item = self._items[index] - if not hasattr(item, "transform"): - return - - bounds = compute_geometry_bounds(item) - if not bounds: - return - - rotation = item.transform.rotate - scale_x = item.transform.scale_x - scale_y = item.transform.scale_y - old_tx = item.transform.translate_x - old_ty = item.transform.translate_y - - old_pivot_x = item.transform.pivot_x - old_pivot_y = item.transform.pivot_y - new_pivot_x, new_pivot_y = self._pivot_from_origin(bounds, new_ox, new_oy) - - # Adjust translation to keep shape visually in place when pivot changes - dx = old_pivot_x - new_pivot_x - dy = old_pivot_y - new_pivot_y - - scaled_dx = dx * scale_x - scaled_dy = dy * scale_y - - radians = rotation * math.pi / 180 - cos_r = math.cos(radians) - sin_r = math.sin(radians) - rotated_scaled_dx = scaled_dx * cos_r - scaled_dy * sin_r - rotated_scaled_dy = scaled_dx * sin_r + scaled_dy * cos_r - - new_transform = { - "translateX": old_tx + dx - rotated_scaled_dx, - "translateY": old_ty + dy - rotated_scaled_dy, - "rotate": rotation, - "scaleX": scale_x, - "scaleY": scale_y, - "pivotX": new_pivot_x, - "pivotY": new_pivot_y, - } - - self.setItemTransform(index, new_transform) + @Slot(int, float, float, float, float) + def scaleItem( + self, + index: int, + new_scale_x: float, + new_scale_y: float, + anchor_x: float, + anchor_y: float, + ) -> None: + """Scale an item while keeping the anchor point fixed.""" + self._transform_service.scale_item( + index, new_scale_x, new_scale_y, anchor_x, anchor_y + ) @Slot(int, float, float, float, float) def applyScaleResize( @@ -1538,66 +1183,9 @@ def applyScaleResize( anchor_x: Anchor point X (0=left, 0.5=center, 1=right). anchor_y: Anchor point Y (0=top, 0.5=center, 1=bottom). """ - if not (0 <= index < len(self._items)): - return - - item = self._items[index] - if not hasattr(item, "transform"): - return - - bounds = compute_geometry_bounds(item) - if not bounds: - return - - pivot_x = item.transform.pivot_x - pivot_y = item.transform.pivot_y - old_scale_x = item.transform.scale_x - old_scale_y = item.transform.scale_y - rotation = item.transform.rotate - old_tx = item.transform.translate_x - old_ty = item.transform.translate_y - - import math - - # Origin points in geometry space - anchor_geom_x = bounds["x"] + bounds["width"] * anchor_x - anchor_geom_y = bounds["y"] + bounds["height"] * anchor_y - - # Displacement from pivot to anchor in geometry space - d_x = anchor_geom_x - pivot_x - d_y = anchor_geom_y - pivot_y - - # Scale the displacement (old vs new) - scaled_old_x = d_x * old_scale_x - scaled_old_y = d_y * old_scale_y - scaled_new_x = d_x * new_scale_x - scaled_new_y = d_y * new_scale_y - - # Rotate the scaled displacements - radians = rotation * math.pi / 180 - cos_r = math.cos(radians) - sin_r = math.sin(radians) - rotated_old_x = scaled_old_x * cos_r - scaled_old_y * sin_r - rotated_old_y = scaled_old_x * sin_r + scaled_old_y * cos_r - rotated_new_x = scaled_new_x * cos_r - scaled_new_y * sin_r - rotated_new_y = scaled_new_x * sin_r + scaled_new_y * cos_r - - # Keep anchor fixed in world space: - # T_new = T_old + R(S_old * d) - R(S_new * d) - new_tx = old_tx + (rotated_old_x - rotated_new_x) - new_ty = old_ty + (rotated_old_y - rotated_new_y) - - new_transform = { - "translateX": new_tx, - "translateY": new_ty, - "rotate": rotation, - "scaleX": new_scale_x, - "scaleY": new_scale_y, - "pivotX": pivot_x, - "pivotY": pivot_y, - } - - self.setItemTransform(index, new_transform) + self._transform_service.apply_scale_resize( + index, new_scale_x, new_scale_y, anchor_x, anchor_y + ) @Slot(int) def bakeTransform(self, index: int) -> None: diff --git a/src/lucent/transform_service.py b/src/lucent/transform_service.py new file mode 100644 index 0000000..1dd1638 --- /dev/null +++ b/src/lucent/transform_service.py @@ -0,0 +1,432 @@ +# Copyright (C) 2026 The Culture List, Inc. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Transform and edit-mapping helpers for CanvasModel.""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional, Tuple +import math + +from lucent.canvas_items import CanvasItem +from lucent.edit_context import EditContext +from lucent.model_geometry import compute_geometry_bounds + + +class TransformService: + """Pure transform/edit-mapping logic for CanvasModel.""" + + def __init__( + self, + get_item: Callable[[int], Optional[CanvasItem]], + is_valid_index: Callable[[int], bool], + edit_context: EditContext, + item_to_dict: Callable[[CanvasItem], Dict[str, Any]], + update_item: Callable[[int, Dict[str, Any]], None], + emit_transform_changed: Callable[[int], None], + begin_transaction: Optional[Callable[[], None]] = None, + end_transaction: Optional[Callable[[], None]] = None, + ) -> None: + self._get_item = get_item + self._is_valid_index = is_valid_index + self._edit_context = edit_context + self._item_to_dict = item_to_dict + self._update_item = update_item + self._emit_transform_changed = emit_transform_changed + self._begin_transaction = begin_transaction + self._end_transaction = end_transaction + + @staticmethod + def _normalize_rotation(degrees: float) -> float: + """Normalize rotation to 0-360° range.""" + normalized = degrees % 360 + if normalized < 0: + normalized += 360 + return normalized + + @staticmethod + def _derive_origin_from_pivot( + bounds: Dict[str, float], pivot_x: float, pivot_y: float + ) -> Tuple[float, float]: + """Convert absolute pivot coords into normalized origin for UI.""" + origin_x = 0.0 + origin_y = 0.0 + if bounds["width"] != 0: + origin_x = (pivot_x - bounds["x"]) / bounds["width"] + if bounds["height"] != 0: + origin_y = (pivot_y - bounds["y"]) / bounds["height"] + return origin_x, origin_y + + @staticmethod + def _pivot_from_origin( + bounds: Dict[str, float], origin_x: float, origin_y: float + ) -> Tuple[float, float]: + pivot_x = bounds["x"] + bounds["width"] * origin_x + pivot_y = bounds["y"] + bounds["height"] * origin_y + return pivot_x, pivot_y + + def get_item_transform(self, index: int) -> Optional[Dict[str, Any]]: + if not self._is_valid_index(index): + return None + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return None + + transform_dict = item.transform.to_dict() + bounds = compute_geometry_bounds(item) + if not bounds: + transform_dict["originX"] = 0 + transform_dict["originY"] = 0 + return transform_dict + + origin_x, origin_y = self._derive_origin_from_pivot( + bounds, item.transform.pivot_x, item.transform.pivot_y + ) + transform_dict["originX"] = origin_x + transform_dict["originY"] = origin_y + return transform_dict + + def set_item_transform(self, index: int, transform: Dict[str, Any]) -> None: + if not self._is_valid_index(index): + return + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return + + if "rotate" in transform: + transform = dict(transform) + transform["rotate"] = self._normalize_rotation(transform["rotate"]) + + bounds = compute_geometry_bounds(item) + if not bounds: + return + + if "pivotX" not in transform or "pivotY" not in transform: + transform = dict(transform) + transform["pivotX"] = item.transform.pivot_x + transform["pivotY"] = item.transform.pivot_y + + current_data = self._item_to_dict(item) + current_data["transform"] = transform + self._update_item(index, current_data) + self._emit_transform_changed(index) + + def update_transform_property(self, index: int, prop: str, value: float) -> None: + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return + + new_transform = { + "translateX": item.transform.translate_x, + "translateY": item.transform.translate_y, + "rotate": item.transform.rotate, + "scaleX": item.transform.scale_x, + "scaleY": item.transform.scale_y, + "pivotX": item.transform.pivot_x, + "pivotY": item.transform.pivot_y, + } + new_transform[prop] = value + self.set_item_transform(index, new_transform) + + def rotate_item(self, index: int, angle: float) -> None: + if not self._is_valid_index(index): + return + self.update_transform_property(index, "rotate", angle) + + def get_displayed_position(self, index: int) -> Optional[Dict[str, float]]: + if not self._is_valid_index(index): + return None + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return None + bounds = compute_geometry_bounds(item) + if not bounds: + return None + return { + "x": item.transform.pivot_x + item.transform.translate_x, + "y": item.transform.pivot_y + item.transform.translate_y, + } + + def get_displayed_size(self, index: int) -> Optional[Dict[str, float]]: + if not self._is_valid_index(index): + return None + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return None + bounds = compute_geometry_bounds(item) + if not bounds: + return None + + current = self.get_item_transform(index) or {} + scale_x = current.get("scaleX", 1) + scale_y = current.get("scaleY", 1) + + return { + "width": bounds["width"] * scale_x, + "height": bounds["height"] * scale_y, + } + + def transform_point_to_geometry( + self, index: int, screen_x: float, screen_y: float + ) -> Optional[Dict[str, float]]: + if not self._is_valid_index(index): + return None + item = self._get_item(index) + if not item or not hasattr(item, "transform") or not hasattr(item, "geometry"): + return None + + return self._edit_context.map_screen_to_geometry( + item.transform, screen_x, screen_y + ) + + def lock_edit_transform(self, index: int) -> None: + if not self._is_valid_index(index): + return + item = self._get_item(index) + if not item or not hasattr(item, "transform") or not hasattr(item, "geometry"): + return + + self._edit_context.lock_pivot( + index, item.transform.pivot_x, item.transform.pivot_y + ) + + def unlock_edit_transform(self, index: int) -> None: + self._edit_context.unlock_pivot(index) + + def update_geometry_with_origin_compensation( + self, index: int, geometry_data: Dict[str, Any] + ) -> None: + if not self._is_valid_index(index): + return + self._update_item(index, {"geometry": geometry_data}) + + def update_geometry_locked(self, index: int, geometry_data: Dict[str, Any]) -> None: + self.update_geometry_with_origin_compensation(index, geometry_data) + + def transform_point_to_geometry_locked( + self, index: int, screen_x: float, screen_y: float + ) -> Optional[Dict[str, float]]: + if not self._is_valid_index(index): + return None + item = self._get_item(index) + if not item or not hasattr(item, "transform") or not hasattr(item, "geometry"): + return None + + locked_pivot = self._edit_context.get_locked_pivot(index) + if locked_pivot: + return self._edit_context.map_screen_to_geometry( + item.transform, screen_x, screen_y, locked_pivot + ) + + return self._edit_context.map_screen_to_geometry( + item.transform, screen_x, screen_y + ) + + def has_non_identity_transform(self, index: int) -> bool: + if not self._is_valid_index(index): + return False + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return False + + current = self.get_item_transform(index) or {} + return ( + current.get("rotate", 0) != 0 + or current.get("scaleX", 1) != 1 + or current.get("scaleY", 1) != 1 + or current.get("translateX", 0) != 0 + or current.get("translateY", 0) != 0 + ) + + def set_item_position(self, index: int, axis: str, value: float) -> None: + if not self._is_valid_index(index): + return + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return + + bounds = compute_geometry_bounds(item) + if not bounds: + return + + new_transform = { + "translateX": item.transform.translate_x, + "translateY": item.transform.translate_y, + "rotate": item.transform.rotate, + "scaleX": item.transform.scale_x, + "scaleY": item.transform.scale_y, + "pivotX": item.transform.pivot_x, + "pivotY": item.transform.pivot_y, + } + if axis == "x": + new_transform["translateX"] = value - item.transform.pivot_x + elif axis == "y": + new_transform["translateY"] = value - item.transform.pivot_y + else: + return + + self.set_item_transform(index, new_transform) + + def set_item_origin(self, index: int, new_ox: float, new_oy: float) -> None: + if not self._is_valid_index(index): + return + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return + + bounds = compute_geometry_bounds(item) + if not bounds: + return + + rotation = item.transform.rotate + scale_x = item.transform.scale_x + scale_y = item.transform.scale_y + old_tx = item.transform.translate_x + old_ty = item.transform.translate_y + + old_pivot_x = item.transform.pivot_x + old_pivot_y = item.transform.pivot_y + new_pivot_x, new_pivot_y = self._pivot_from_origin(bounds, new_ox, new_oy) + + dx = old_pivot_x - new_pivot_x + dy = old_pivot_y - new_pivot_y + + scaled_dx = dx * scale_x + scaled_dy = dy * scale_y + + radians = rotation * math.pi / 180 + cos_r = math.cos(radians) + sin_r = math.sin(radians) + rotated_scaled_dx = scaled_dx * cos_r - scaled_dy * sin_r + rotated_scaled_dy = scaled_dx * sin_r + scaled_dy * cos_r + + new_transform = { + "translateX": old_tx + dx - rotated_scaled_dx, + "translateY": old_ty + dy - rotated_scaled_dy, + "rotate": rotation, + "scaleX": scale_x, + "scaleY": scale_y, + "pivotX": new_pivot_x, + "pivotY": new_pivot_y, + } + + self.set_item_transform(index, new_transform) + + def set_displayed_size( + self, index: int, dimension: str, value: float, proportional: bool + ) -> None: + if not self._is_valid_index(index): + return + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return + + bounds = compute_geometry_bounds(item) + if not bounds: + return + + if bounds["width"] <= 0 or bounds["height"] <= 0: + return + + value = max(1.0, value) + + current = self.get_item_transform(index) or {} + current_scale_x = current.get("scaleX", 1) + current_scale_y = current.get("scaleY", 1) + + if dimension == "width": + new_scale_x = value / bounds["width"] + if proportional: + ratio = new_scale_x / current_scale_x + new_scale_y = current_scale_y * ratio + if self._begin_transaction: + self._begin_transaction() + self.update_transform_property(index, "scaleX", new_scale_x) + self.update_transform_property(index, "scaleY", new_scale_y) + if self._end_transaction: + self._end_transaction() + else: + self.update_transform_property(index, "scaleX", new_scale_x) + elif dimension == "height": + new_scale_y = value / bounds["height"] + if proportional: + ratio = new_scale_y / current_scale_y + new_scale_x = current_scale_x * ratio + if self._begin_transaction: + self._begin_transaction() + self.update_transform_property(index, "scaleX", new_scale_x) + self.update_transform_property(index, "scaleY", new_scale_y) + if self._end_transaction: + self._end_transaction() + else: + self.update_transform_property(index, "scaleY", new_scale_y) + + def scale_item( + self, + index: int, + new_scale_x: float, + new_scale_y: float, + anchor_x: float, + anchor_y: float, + ) -> None: + self.apply_scale_resize(index, new_scale_x, new_scale_y, anchor_x, anchor_y) + + def apply_scale_resize( + self, + index: int, + new_scale_x: float, + new_scale_y: float, + anchor_x: float, + anchor_y: float, + ) -> None: + if not self._is_valid_index(index): + return + item = self._get_item(index) + if not item or not hasattr(item, "transform"): + return + + bounds = compute_geometry_bounds(item) + if not bounds: + return + + pivot_x = item.transform.pivot_x + pivot_y = item.transform.pivot_y + old_scale_x = item.transform.scale_x + old_scale_y = item.transform.scale_y + rotation = item.transform.rotate + old_tx = item.transform.translate_x + old_ty = item.transform.translate_y + + anchor_geom_x = bounds["x"] + bounds["width"] * anchor_x + anchor_geom_y = bounds["y"] + bounds["height"] * anchor_y + + d_x = anchor_geom_x - pivot_x + d_y = anchor_geom_y - pivot_y + + scaled_old_x = d_x * old_scale_x + scaled_old_y = d_y * old_scale_y + scaled_new_x = d_x * new_scale_x + scaled_new_y = d_y * new_scale_y + + radians = rotation * math.pi / 180 + cos_r = math.cos(radians) + sin_r = math.sin(radians) + rotated_old_x = scaled_old_x * cos_r - scaled_old_y * sin_r + rotated_old_y = scaled_old_x * sin_r + scaled_old_y * cos_r + rotated_new_x = scaled_new_x * cos_r - scaled_new_y * sin_r + rotated_new_y = scaled_new_x * sin_r + scaled_new_y * cos_r + + new_tx = old_tx + (rotated_old_x - rotated_new_x) + new_ty = old_ty + (rotated_old_y - rotated_new_y) + + new_transform = { + "translateX": new_tx, + "translateY": new_ty, + "rotate": rotation, + "scaleX": new_scale_x, + "scaleY": new_scale_y, + "pivotX": pivot_x, + "pivotY": pivot_y, + } + + self.set_item_transform(index, new_transform) diff --git a/tests/test_canvas_contracts.py b/tests/test_canvas_contracts.py index 1904c49..d643329 100644 --- a/tests/test_canvas_contracts.py +++ b/tests/test_canvas_contracts.py @@ -18,7 +18,6 @@ make_ellipse, make_path, make_text, - make_group, ) @@ -333,36 +332,6 @@ def test_text_position_update_preserves_text_properties(self, canvas_model): assert result["textColor"] == "#ff0000" -@pytest.mark.contract -class TestGroupMoveContract: - """Contract: Canvas.qml moves groups via canvasModel.moveGroup().""" - - def test_group_move_translates_children(self, canvas_model): - """moveGroup translates all descendant shapes.""" - # Create group with children - canvas_model.addItem(make_group(name="Test Group", group_id="grp1")) - canvas_model.addItem( - make_rectangle(x=0, y=0, width=50, height=50, parent_id="grp1") - ) - canvas_model.addItem(make_ellipse(center_x=100, center_y=100, parent_id="grp1")) - - # Move group (Canvas.qml calls this for groups/layers) - canvas_model.moveGroup(0, 10, 20) - - # Verify children moved - rect_data = canvas_model.getItemData(1) - ellipse_data = canvas_model.getItemData(2) - - assert rect_data["geometry"]["x"] == 0 - assert rect_data["geometry"]["y"] == 0 - assert rect_data["transform"]["translateX"] == 10 - assert rect_data["transform"]["translateY"] == 20 - assert ellipse_data["geometry"]["centerX"] == 100 - assert ellipse_data["geometry"]["centerY"] == 100 - assert ellipse_data["transform"]["translateX"] == 10 - assert ellipse_data["transform"]["translateY"] == 20 - - @pytest.mark.contract class TestBoundingBoxContract: """Contract: getBoundingBox returns consistent format for all types.""" diff --git a/tests/test_canvas_model_grouping.py b/tests/test_canvas_model_grouping.py deleted file mode 100644 index b0380db..0000000 --- a/tests/test_canvas_model_grouping.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright (C) 2026 The Culture List, Inc. -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Grouping, duplication, and hierarchy movement tests for CanvasModel.""" - -from lucent.canvas_items import GroupItem, RectangleItem -from lucent.commands import DEFAULT_DUPLICATE_OFFSET -from test_helpers import make_rectangle, make_ellipse, make_path, make_text, make_layer - - -class TestCanvasModelDuplicate: - """Tests for duplicating single items.""" - - def test_duplicate_rectangle(self, canvas_model, qtbot): - canvas_model.addItem( - make_rectangle(x=10, y=20, width=50, height=30, name="Original") - ) - - with qtbot.waitSignal(canvas_model.itemAdded, timeout=1000): - canvas_model.duplicateItem(0) - - assert canvas_model.count() == 2 - items = canvas_model.getItems() - assert items[1].geometry.x == 10 + DEFAULT_DUPLICATE_OFFSET - assert items[1].geometry.y == 20 + DEFAULT_DUPLICATE_OFFSET - - -class TestCanvasModelDuplicateItems: - """Tests for duplicating multiple items.""" - - def test_duplicate_multiple_items(self, canvas_model): - canvas_model.addItem(make_rectangle(x=10, y=10, name="A")) - canvas_model.addItem(make_rectangle(x=20, y=20, name="B")) - - new_indices = canvas_model.duplicateItems([0, 1]) - - assert len(new_indices) == 2 - assert canvas_model.count() == 4 - - -class TestCanvasModelGrouping: - """Tests for grouping and ungrouping items.""" - - def test_group_items(self, canvas_model): - canvas_model.addItem(make_rectangle(name="A")) - canvas_model.addItem(make_rectangle(name="B")) - - group_idx = canvas_model.groupItems([0, 1]) - assert group_idx >= 0 - assert canvas_model.count() == 3 - - def test_ungroup_items(self, canvas_model): - canvas_model.addItem(make_rectangle(name="A")) - canvas_model.addItem(make_rectangle(name="B")) - - group_idx = canvas_model.groupItems([0, 1]) - - canvas_model.ungroup(group_idx) - items = canvas_model.getItems() - shapes = [i for i in items if isinstance(i, RectangleItem)] - assert len(shapes) == 2 - - def test_ungroup_invalid_index(self, canvas_model): - canvas_model.addItem(make_rectangle()) - canvas_model.ungroup(999) - assert canvas_model.count() == 1 - - def test_ungroup_non_group_item(self, canvas_model): - canvas_model.addItem(make_rectangle()) - canvas_model.ungroup(0) - assert canvas_model.count() == 1 - - def test_ungroup_undo_restores_group_with_children( - self, canvas_model, history_manager - ): - """Undoing ungroup should restore the group with its children.""" - canvas_model.addItem(make_rectangle(name="A")) - canvas_model.addItem(make_rectangle(name="B")) - - group_idx = canvas_model.groupItems([0, 1]) - group_data = canvas_model.getItemData(group_idx) - group_id = group_data["id"] - - # Verify children are parented to the group - items_before = canvas_model.getItems() - children_before = [ - i for i in items_before if getattr(i, "parent_id", None) == group_id - ] - assert len(children_before) == 2 - - # Ungroup - canvas_model.ungroup(group_idx) - assert canvas_model.count() == 2 # Group removed, 2 shapes remain - - # Undo the ungroup - history_manager.undo() - assert canvas_model.count() == 3 # Group restored - - # Verify children are back under the group - items_after = canvas_model.getItems() - groups = [i for i in items_after if isinstance(i, GroupItem)] - assert len(groups) == 1 - restored_group_id = groups[0].id - children_after = [ - i for i in items_after if getattr(i, "parent_id", None) == restored_group_id - ] - assert len(children_after) == 2 - - -class TestCanvasModelMoveGroup: - """Tests for moving groups.""" - - def test_move_group_translates_children(self, canvas_model): - canvas_model.addItem(make_rectangle(x=0, y=0, width=10, height=10, name="A")) - canvas_model.addItem(make_rectangle(x=20, y=20, width=10, height=10, name="B")) - - group_idx = canvas_model.groupItems([0, 1]) - - canvas_model.moveGroup(group_idx, 100, 50) - - items = canvas_model.getItems() - rects = [i for i in items if isinstance(i, RectangleItem)] - assert rects[0].geometry.x == 0 - assert rects[0].geometry.y == 0 - assert rects[0].transform.translate_x == 100 - assert rects[0].transform.translate_y == 50 - assert rects[1].geometry.x == 20 - assert rects[1].geometry.y == 20 - assert rects[1].transform.translate_x == 100 - assert rects[1].transform.translate_y == 50 - - def test_move_group_invalid_index(self, canvas_model): - canvas_model.addItem(make_rectangle(x=0, y=0, width=10, height=10)) - canvas_model.moveGroup(999, 100, 50) - item = canvas_model.getItems()[0] - assert item.geometry.x == 0 - assert item.transform.translate_x == 0 - assert item.transform.translate_y == 0 - - def test_move_group_non_container(self, canvas_model): - canvas_model.addItem(make_rectangle(x=0, y=0, width=10, height=10)) - canvas_model.moveGroup(0, 100, 50) - item = canvas_model.getItems()[0] - assert item.geometry.x == 0 - assert item.transform.translate_x == 0 - assert item.transform.translate_y == 0 - - -class TestCanvasModelMoveGroupExtended: - """Extended tests for moveGroup with different item types.""" - - def test_move_group_with_ellipse(self, canvas_model): - canvas_model.addItem(make_layer(name="Layer", layer_id="layer-1")) - layer = canvas_model.getItems()[0] - - ellipse_data = make_ellipse(center_x=50, center_y=50, radius_x=30, radius_y=20) - ellipse_data["parentId"] = layer.id - canvas_model.addItem(ellipse_data) - - canvas_model.moveGroup(0, 10, 20) - - ellipse = canvas_model.getItems()[1] - assert ellipse.geometry.center_x == 50 - assert ellipse.geometry.center_y == 50 - assert ellipse.transform.translate_x == 10 - assert ellipse.transform.translate_y == 20 - - def test_move_group_with_text(self, canvas_model): - canvas_model.addItem(make_layer(name="Layer", layer_id="layer-1")) - layer = canvas_model.getItems()[0] - - text_data = make_text(x=100, y=200, width=150, text="Hello") - text_data["parentId"] = layer.id - canvas_model.addItem(text_data) - - canvas_model.moveGroup(0, 15, 25) - - text = canvas_model.getItems()[1] - assert text.x == 100 - assert text.y == 200 - assert text.transform.translate_x == 15 - assert text.transform.translate_y == 25 - - def test_move_group_with_path(self, canvas_model): - canvas_model.addItem(make_layer(name="Layer", layer_id="layer-1")) - layer = canvas_model.getItems()[0] - - path_data = make_path(points=[{"x": 10, "y": 20}, {"x": 110, "y": 70}]) - path_data["parentId"] = layer.id - canvas_model.addItem(path_data) - - canvas_model.moveGroup(0, 5, 10) - - path = canvas_model.getItems()[1] - assert path.geometry.points[0]["x"] == 10 - assert path.geometry.points[0]["y"] == 20 - assert path.geometry.points[1]["x"] == 110 - assert path.geometry.points[1]["y"] == 70 - assert path.transform.translate_x == 5 - assert path.transform.translate_y == 10 diff --git a/tests/test_canvas_model_transforms.py b/tests/test_canvas_model_transforms.py index 3ee67dc..878c6d7 100644 --- a/tests/test_canvas_model_transforms.py +++ b/tests/test_canvas_model_transforms.py @@ -630,90 +630,6 @@ def test_get_transformed_path_points_non_path_item(self, canvas_model): canvas_model.addItem(make_rectangle(x=0, y=0, width=100, height=100)) assert canvas_model.getTransformedPathPoints(0) is None - def test_transform_point_to_geometry_identity(self, canvas_model): - """Identity transform returns input point.""" - path_data = make_path(points=[{"x": 0, "y": 0}, {"x": 100, "y": 0}]) - canvas_model.addItem(path_data) - - result = canvas_model.transformPointToGeometry(0, 50, 25) - assert result is not None - assert result["x"] == 50 - assert result["y"] == 25 - - def test_transform_point_to_geometry_with_translation(self, canvas_model): - """Inverse transform subtracts translation.""" - path_data = make_path(points=[{"x": 0, "y": 0}, {"x": 100, "y": 0}]) - path_data["transform"] = {"translateX": 50, "translateY": 25} - canvas_model.addItem(path_data) - - result = canvas_model.transformPointToGeometry(0, 100, 50) - assert result is not None - assert abs(result["x"] - 50) < 0.001 - assert abs(result["y"] - 25) < 0.001 - - def test_transform_point_round_trip(self, canvas_model): - """Forward and inverse transforms are consistent.""" - path_data = make_path(points=[{"x": 0, "y": 0}, {"x": 100, "y": 100}]) - path_data["transform"] = { - "translateX": 20, - "translateY": 30, - "rotate": 45, - "scaleX": 1.5, - "scaleY": 0.8, - "pivotX": 50, - "pivotY": 50, - } - canvas_model.addItem(path_data) - - transformed = canvas_model.getTransformedPathPoints(0) - assert transformed is not None - - for i, tp in enumerate(transformed): - geom = canvas_model.transformPointToGeometry(0, tp["x"], tp["y"]) - orig = path_data["geometry"]["points"][i] - assert abs(geom["x"] - orig["x"]) < 0.001 - assert abs(geom["y"] - orig["y"]) < 0.001 - - def test_transform_point_to_geometry_invalid_index(self, canvas_model): - """Invalid index returns None.""" - assert canvas_model.transformPointToGeometry(-1, 0, 0) is None - assert canvas_model.transformPointToGeometry(999, 0, 0) is None - - -class TestPivotStability: - """Tests for updateGeometryWithOriginCompensation method.""" - - def test_geometry_update_keeps_pivot(self, canvas_model): - """Geometry updates should not change pivot or translation.""" - path_data = make_path( - points=[{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}], - closed=False, - ) - path_data["transform"] = {"rotate": 45, "pivotX": 50, "pivotY": 50} - canvas_model.addItem(path_data) - - item = canvas_model._items[0] - old_pivot = (item.transform.pivot_x, item.transform.pivot_y) - old_translate = (item.transform.translate_x, item.transform.translate_y) - - new_geometry = { - "points": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 150, "y": 150}], - "closed": False, - } - canvas_model.updateGeometryWithOriginCompensation(0, new_geometry) - - new_item = canvas_model._items[0] - new_pivot = (new_item.transform.pivot_x, new_item.transform.pivot_y) - new_translate = (new_item.transform.translate_x, new_item.transform.translate_y) - - assert new_pivot == old_pivot - assert new_translate == old_translate - - def test_geometry_update_invalid_index(self, canvas_model): - """Invalid index should not raise.""" - canvas_model.updateGeometryWithOriginCompensation(-1, {"points": []}) - canvas_model.updateGeometryWithOriginCompensation(999, {"points": []}) - class TestEditTransformLock: """Tests for stable edit transform locking during path edits.""" @@ -743,7 +659,7 @@ def test_locked_transform_round_trip_after_bounds_change(self, canvas_model): ], "closed": False, } - canvas_model.updateGeometryWithOriginCompensation(0, new_geometry) + canvas_model.updateGeometryLocked(0, new_geometry) transformed = canvas_model.getTransformedPathPoints(0) assert transformed is not None @@ -784,7 +700,7 @@ def _origin_world(): locked_origin = _origin_world() - canvas_model.updateGeometryWithOriginCompensation( + canvas_model.updateGeometryLocked( 0, { "points": [{"x": 0, "y": 0}, {"x": 140, "y": 40}, {"x": 60, "y": 160}], @@ -793,7 +709,7 @@ def _origin_world(): ) origin_after_first = _origin_world() - canvas_model.updateGeometryWithOriginCompensation( + canvas_model.updateGeometryLocked( 0, { "points": [{"x": 0, "y": 0}, {"x": 120, "y": 80}, {"x": 90, "y": 190}], diff --git a/tests/test_interaction_contracts.py b/tests/test_interaction_contracts.py new file mode 100644 index 0000000..9a253c3 --- /dev/null +++ b/tests/test_interaction_contracts.py @@ -0,0 +1,67 @@ +# Copyright (C) 2026 The Culture List, Inc. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Interaction contract tests for core transformations.""" + +from test_helpers import make_path, make_rectangle + + +def test_translate_items_updates_transform_only(canvas_model): + canvas_model.addItem(make_rectangle(x=10, y=20, width=50, height=40)) + + canvas_model.translateItems([0], 12, -5) + + data = canvas_model.getItemData(0) + assert data["geometry"]["x"] == 10 + assert data["geometry"]["y"] == 20 + transform = data.get("transform", {}) + assert transform.get("translateX", 0) == 12 + assert transform.get("translateY", 0) == -5 + + +def test_rotate_item_preserves_geometry(canvas_model): + canvas_model.addItem(make_rectangle(x=0, y=0, width=100, height=50)) + + canvas_model.rotateItem(0, 45) + + data = canvas_model.getItemData(0) + assert data["geometry"]["width"] == 100 + assert data["geometry"]["height"] == 50 + transform = data.get("transform", {}) + assert transform.get("rotate", 0) == 45 + + +def test_scale_item_preserves_geometry_and_pivot(canvas_model): + canvas_model.addItem(make_rectangle(x=0, y=0, width=80, height=40)) + + canvas_model.scaleItem(0, 2.0, 1.5, 0.5, 0.5) + + after = canvas_model.getItemData(0) + assert after["geometry"]["width"] == 80 + assert after["geometry"]["height"] == 40 + after_transform = after.get("transform", {}) + assert after_transform.get("scaleX", 1) == 2.0 + assert after_transform.get("scaleY", 1) == 1.5 + assert after_transform.get("pivotX") == 40.0 + assert after_transform.get("pivotY") == 20.0 + + +def test_update_geometry_locked_does_not_change_transform(canvas_model): + points = [{"x": 0, "y": 0}, {"x": 20, "y": 0}, {"x": 20, "y": 10}] + canvas_model.addItem(make_path(points=points, closed=False)) + before = canvas_model.getItemData(0) + before_transform = dict(before.get("transform", {})) + + canvas_model.updateGeometryLocked( + 0, + { + "points": [{"x": 5, "y": 5}, {"x": 25, "y": 5}, {"x": 25, "y": 15}], + "closed": False, + }, + ) + + after = canvas_model.getItemData(0) + assert after["geometry"]["points"][0] == {"x": 5, "y": 5} + assert after["geometry"]["points"][1] == {"x": 25, "y": 5} + assert after["geometry"]["points"][2] == {"x": 25, "y": 15} + assert after.get("transform", {}) == before_transform diff --git a/tests/test_transform_service.py b/tests/test_transform_service.py new file mode 100644 index 0000000..9883f93 --- /dev/null +++ b/tests/test_transform_service.py @@ -0,0 +1,95 @@ +# Copyright (C) 2026 The Culture List, Inc. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Unit tests for TransformService.""" + +from lucent.appearances import Fill, Stroke +from lucent.canvas_items import RectangleItem +from lucent.edit_context import EditContext +from lucent.geometry import RectGeometry +from lucent.item_schema import parse_item, parse_item_data, item_to_dict +from lucent.transform_service import TransformService + + +def _make_rectangle_item(): + geometry = RectGeometry(x=0, y=0, width=100, height=50) + appearances = [Fill("#ffffff", 1.0, True), Stroke("#000000", 1.0, 1.0, True)] + return RectangleItem(geometry=geometry, appearances=appearances) + + +def _make_service(items, begin_calls=None, end_calls=None): + edit_context = EditContext() + + def get_item(index): + return items[index] if 0 <= index < len(items) else None + + def is_valid_index(index): + return 0 <= index < len(items) + + def update_item(index, data): + parsed = parse_item_data(data) + items[index] = parse_item(parsed.data) + + def emit_transform_changed(index): + pass + + def begin_transaction(): + if begin_calls is not None: + begin_calls.append(True) + + def end_transaction(): + if end_calls is not None: + end_calls.append(True) + + return TransformService( + get_item=get_item, + is_valid_index=is_valid_index, + edit_context=edit_context, + item_to_dict=item_to_dict, + update_item=update_item, + emit_transform_changed=emit_transform_changed, + begin_transaction=begin_transaction, + end_transaction=end_transaction, + ) + + +def test_rotate_item_normalizes_degrees(): + items = [_make_rectangle_item()] + service = _make_service(items) + + service.rotate_item(0, 370) + + assert items[0].transform.rotate == 10 + + +def test_get_item_transform_reports_origin(): + items = [_make_rectangle_item()] + service = _make_service(items) + + transform = service.get_item_transform(0) + + assert transform["originX"] == 0.5 + assert transform["originY"] == 0.5 + + +def test_set_displayed_size_proportional_scales_both_axes(): + items = [_make_rectangle_item()] + begin_calls = [] + end_calls = [] + service = _make_service(items, begin_calls=begin_calls, end_calls=end_calls) + + service.set_displayed_size(0, "width", 200, True) + + assert items[0].transform.scale_x == 2.0 + assert items[0].transform.scale_y == 2.0 + assert len(begin_calls) == 1 + assert len(end_calls) == 1 + + +def test_set_item_position_moves_pivot_to_target(): + items = [_make_rectangle_item()] + service = _make_service(items) + + service.set_item_position(0, "x", 100) + + assert items[0].transform.translate_x == 50