Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 17 additions & 39 deletions src/lucent/canvas_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1176,29 +1177,17 @@ 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

item = self._items[index]
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."""
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions src/lucent/edit_context.py
Original file line number Diff line number Diff line change
@@ -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()}
36 changes: 36 additions & 0 deletions tests/test_edit_context.py
Original file line number Diff line number Diff line change
@@ -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