From 6f8bf7150169b182c813ff2d61739a6031e7b691 Mon Sep 17 00:00:00 2001 From: Lucas K Allmon Date: Fri, 16 Jan 2026 13:38:34 -0800 Subject: [PATCH] refactor (editing): move towards a node editor tool --- src/lucent/canvas_model.py | 56 ++++++++++++-------------------------- src/lucent/edit_context.py | 49 +++++++++++++++++++++++++++++++++ tests/test_edit_context.py | 36 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 39 deletions(-) create mode 100644 src/lucent/edit_context.py create mode 100644 tests/test_edit_context.py diff --git a/src/lucent/canvas_model.py b/src/lucent/canvas_model.py index d53b68d..2d9a1d1 100644 --- a/src/lucent/canvas_model.py +++ b/src/lucent/canvas_model.py @@ -35,6 +35,7 @@ DuplicateItemCommand, ) from lucent.history_manager import HistoryManager +from lucent.edit_context import EditContext from lucent.item_schema import ( parse_item, parse_item_data, @@ -95,8 +96,8 @@ def __init__( # Spatial index for fast viewport queries self._spatial_index = SpatialIndex() - # Locked edit pivots (geometry space) for stable drag operations - self._locked_edit_transforms: Dict[int, Dict[str, float]] = {} + # Edit context for stable drag operations + self._edit_context = EditContext() # Connect signals to update spatial index self.itemAdded.connect(self._on_item_added_spatial) @@ -1176,8 +1177,6 @@ def transformPointToGeometry( Returns: Dictionary with x, y in geometry space, or None if failed. """ - from PySide6.QtCore import QPointF - if not (0 <= index < len(self._items)): return None @@ -1185,20 +1184,10 @@ def transformPointToGeometry( if not hasattr(item, "transform") or not hasattr(item, "geometry"): return None - if item.transform.is_identity(): - return {"x": screen_x, "y": screen_y} - - qtransform = item.transform.to_qtransform_centered( - item.transform.pivot_x, item.transform.pivot_y + return self._edit_context.map_screen_to_geometry( + item.transform, screen_x, screen_y ) - inverted, ok = qtransform.inverted() - if not ok: - return {"x": screen_x, "y": screen_y} - - geom_pt = inverted.map(QPointF(screen_x, screen_y)) - return {"x": geom_pt.x(), "y": geom_pt.y()} - @Slot(int) def lockEditTransform(self, index: int) -> None: """Lock the edit pivot (geometry space) for stable drag mapping.""" @@ -1209,15 +1198,14 @@ def lockEditTransform(self, index: int) -> None: if not hasattr(item, "transform") or not hasattr(item, "geometry"): return - self._locked_edit_transforms[index] = { - "pivot_geom_x": item.transform.pivot_x, - "pivot_geom_y": item.transform.pivot_y, - } + self._edit_context.lock_pivot( + index, item.transform.pivot_x, item.transform.pivot_y + ) @Slot(int) def unlockEditTransform(self, index: int) -> None: """Clear the locked edit transform after drag ends.""" - self._locked_edit_transforms.pop(index, None) + self._edit_context.unlock_pivot(index) @Slot(int, dict) def updateGeometryWithOriginCompensation( @@ -1241,7 +1229,6 @@ def transformPointToGeometryLocked( Uses a locked pivot in geometry space for consistent mapping during drag. Falls back to current transform if not locked. """ - from PySide6.QtCore import QPointF if not (0 <= index < len(self._items)): return None @@ -1250,24 +1237,15 @@ def transformPointToGeometryLocked( if not hasattr(item, "transform") or not hasattr(item, "geometry"): return None - locked_origin = self._locked_edit_transforms.get(index) - if locked_origin: - if item.transform.is_identity(): - return {"x": screen_x, "y": screen_y} - - pivot_geom_x = locked_origin["pivot_geom_x"] - pivot_geom_y = locked_origin["pivot_geom_y"] - qtransform = item.transform.to_qtransform_centered( - pivot_geom_x, pivot_geom_y + 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 ) - inverted, ok = qtransform.inverted() - if not ok: - return {"x": screen_x, "y": screen_y} - geom_pt = inverted.map(QPointF(screen_x, screen_y)) - return {"x": geom_pt.x(), "y": geom_pt.y()} - - # Fall back to current transform - return self.transformPointToGeometry(index, screen_x, screen_y) + + return self._edit_context.map_screen_to_geometry( + item.transform, screen_x, screen_y + ) @Slot(int, result=bool) def hasNonIdentityTransform(self, index: int) -> bool: diff --git a/src/lucent/edit_context.py b/src/lucent/edit_context.py new file mode 100644 index 0000000..ff97f13 --- /dev/null +++ b/src/lucent/edit_context.py @@ -0,0 +1,49 @@ +# Copyright (C) 2026 The Culture List, Inc. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Edit context helpers for path/node editing.""" + +from typing import Dict, Optional, Tuple + +from PySide6.QtCore import QPointF + +from lucent.transforms import Transform + + +class EditContext: + """Lightweight edit state and transform helpers.""" + + def __init__(self) -> None: + self._locked_pivots: Dict[int, Tuple[float, float]] = {} + + def lock_pivot(self, index: int, pivot_x: float, pivot_y: float) -> None: + """Lock the pivot for a given item index.""" + self._locked_pivots[index] = (pivot_x, pivot_y) + + def unlock_pivot(self, index: int) -> None: + """Unlock any stored pivot for a given item index.""" + self._locked_pivots.pop(index, None) + + def get_locked_pivot(self, index: int) -> Optional[Tuple[float, float]]: + """Return the locked pivot for an item index, if any.""" + return self._locked_pivots.get(index) + + def map_screen_to_geometry( + self, + transform: Transform, + screen_x: float, + screen_y: float, + pivot_override: Optional[Tuple[float, float]] = None, + ) -> Dict[str, float]: + """Map screen-space coordinates to geometry space.""" + if transform.is_identity(): + return {"x": screen_x, "y": screen_y} + + pivot_x, pivot_y = pivot_override or (transform.pivot_x, transform.pivot_y) + qtransform = transform.to_qtransform_centered(pivot_x, pivot_y) + inverted, ok = qtransform.inverted() + if not ok: + return {"x": screen_x, "y": screen_y} + + geom_pt = inverted.map(QPointF(screen_x, screen_y)) + return {"x": geom_pt.x(), "y": geom_pt.y()} diff --git a/tests/test_edit_context.py b/tests/test_edit_context.py new file mode 100644 index 0000000..2cad933 --- /dev/null +++ b/tests/test_edit_context.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 The Culture List, Inc. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for EditContext helpers.""" + +from lucent.edit_context import EditContext +from lucent.transforms import Transform + + +def test_lock_and_unlock_pivot(): + context = EditContext() + assert context.get_locked_pivot(1) is None + + context.lock_pivot(1, 12.5, -3.0) + assert context.get_locked_pivot(1) == (12.5, -3.0) + + context.unlock_pivot(1) + assert context.get_locked_pivot(1) is None + + +def test_map_screen_to_geometry_uses_locked_pivot(): + transform = Transform(rotate=90, pivot_x=0, pivot_y=0) + context = EditContext() + locked_pivot = (10.0, 10.0) + + from PySide6.QtCore import QPointF + + qtransform = transform.to_qtransform_centered(*locked_pivot) + screen_point = qtransform.map(QPointF(20.0, 10.0)) + + geom_point = context.map_screen_to_geometry( + transform, screen_point.x(), screen_point.y(), locked_pivot + ) + assert abs(geom_point["x"] - 20.0) < 0.001 + assert abs(geom_point["y"] - 10.0) < 0.001