Duncan Tourolle 46585228fd
Some checks failed
Lint / lint (push) Failing after 2m46s
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
first commit
2025-10-21 22:02:49 +02:00

408 lines
14 KiB
Python

"""
Snapping system for pyPhotoAlbum
Provides grid snapping, edge snapping, and custom guide snapping
"""
from typing import List, Tuple, Optional
from dataclasses import dataclass
@dataclass
class Guide:
"""Represents a snapping guide (vertical or horizontal line)"""
position: float # Position in mm
orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict:
"""Serialize guide to dictionary"""
return {
"position": self.position,
"orientation": self.orientation
}
@staticmethod
def deserialize(data: dict) -> 'Guide':
"""Deserialize guide from dictionary"""
return Guide(
position=data.get("position", 0),
orientation=data.get("orientation", "vertical")
)
class SnappingSystem:
"""Manages snapping behavior for layout elements"""
def __init__(self, snap_threshold_mm: float = 5.0):
"""
Initialize snapping system
Args:
snap_threshold_mm: Distance in mm within which snapping occurs
"""
self.snap_threshold_mm = snap_threshold_mm
self.grid_size_mm = 10.0 # Grid spacing in mm
self.snap_to_grid = False
self.snap_to_edges = True
self.snap_to_guides = True
self.guides: List[Guide] = []
def add_guide(self, position: float, orientation: str):
"""Add a new guide"""
guide = Guide(position=position, orientation=orientation)
self.guides.append(guide)
return guide
def remove_guide(self, guide: Guide):
"""Remove a guide"""
if guide in self.guides:
self.guides.remove(guide)
def clear_guides(self):
"""Remove all guides"""
self.guides.clear()
def snap_position(self,
position: Tuple[float, float],
size: Tuple[float, float],
page_size: Tuple[float, float],
dpi: int = 300) -> Tuple[float, float]:
"""
Apply snapping to a position (per-axis)
Args:
position: Current position (x, y) in pixels
size: Element size (width, height) in pixels
page_size: Page size (width, height) in mm
dpi: DPI for conversion
Returns:
Snapped position (x, y) in pixels
"""
x, y = position
width, height = size
page_width_mm, page_height_mm = page_size
# Convert threshold from mm to pixels
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4
# Snap X axis
snapped_x = self._snap_axis(
x, width, page_width_mm, dpi, snap_threshold_px, 'vertical'
)
# Snap Y axis
snapped_y = self._snap_axis(
y, height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
)
return (snapped_x, snapped_y)
def snap_resize(self,
position: Tuple[float, float],
size: Tuple[float, float],
dx: float,
dy: float,
resize_handle: str,
page_size: Tuple[float, float],
dpi: int = 300) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""
Apply snapping during resize operations
Args:
position: Current position (x, y) in pixels
size: Current size (width, height) in pixels
dx: Delta x movement in pixels
dy: Delta y movement in pixels
resize_handle: Which handle is being dragged ('nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w')
page_size: Page size (width, height) in mm
dpi: DPI for conversion
Returns:
Tuple of (snapped_position, snapped_size) in pixels
"""
x, y = position
width, height = size
page_width_mm, page_height_mm = page_size
# Convert threshold from mm to pixels
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4
# Calculate new position and size based on resize handle
new_x, new_y = x, y
new_width, new_height = width, height
# Apply resize based on handle
if resize_handle in ['nw', 'n', 'ne']:
# Top edge moving
new_y = y + dy
new_height = height - dy
if resize_handle in ['sw', 's', 'se']:
# Bottom edge moving
new_height = height + dy
if resize_handle in ['nw', 'w', 'sw']:
# Left edge moving
new_x = x + dx
new_width = width - dx
if resize_handle in ['ne', 'e', 'se']:
# Right edge moving
new_width = width + dx
# Now apply snapping to the edges that are being moved
# Snap left edge (for nw, w, sw handles)
if resize_handle in ['nw', 'w', 'sw']:
snapped_left = self._snap_axis(
new_x, new_width, page_width_mm, dpi, snap_threshold_px, 'vertical'
)
# Adjust width to compensate for position change
width_adjustment = new_x - snapped_left
new_x = snapped_left
new_width += width_adjustment
# Snap right edge (for ne, e, se handles)
if resize_handle in ['ne', 'e', 'se']:
# Calculate right edge position
right_edge = new_x + new_width
# Try to snap the right edge
snapped_right = self._snap_edge_to_targets(
right_edge, page_width_mm, dpi, snap_threshold_px, 'vertical'
)
if snapped_right is not None:
new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles)
if resize_handle in ['nw', 'n', 'ne']:
snapped_top = self._snap_axis(
new_y, new_height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
)
# Adjust height to compensate for position change
height_adjustment = new_y - snapped_top
new_y = snapped_top
new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles)
if resize_handle in ['sw', 's', 'se']:
# Calculate bottom edge position
bottom_edge = new_y + new_height
# Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets(
bottom_edge, page_height_mm, dpi, snap_threshold_px, 'horizontal'
)
if snapped_bottom is not None:
new_height = snapped_bottom - new_y
# Ensure minimum size
min_size = 10 # Minimum 10 pixels
new_width = max(new_width, min_size)
new_height = max(new_height, min_size)
return ((new_x, new_y), (new_width, new_height))
def _snap_edge_to_targets(self,
edge_position: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str) -> Optional[float]:
"""
Snap an edge position to available targets (grid, edges, guides)
Args:
edge_position: Current edge position in pixels
page_size_mm: Page size along axis in mm
dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
Returns:
Snapped edge position in pixels, or None if no snap
"""
snap_candidates = []
# 1. Page edge snapping
if self.snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0, abs(edge_position - 0)))
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px, abs(edge_position - page_size_px)))
# 2. Grid snapping
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Snap to nearest grid line
nearest_grid = round(edge_position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid)))
# 3. Guide snapping
if self.snap_to_guides:
for guide in self.guides:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
return best_snap
def _snap_axis(self,
position: float,
size: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str) -> float:
"""
Snap along a single axis
Args:
position: Current position along axis in pixels
size: Element size along axis in pixels
page_size_mm: Page size along axis in mm
dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
Returns:
Snapped position in pixels
"""
snap_candidates = []
# 1. Page edge snapping
if self.snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0, abs(position - 0)))
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
# Also snap element's far edge to page edge
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
# 2. Grid snapping
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Snap to nearest grid line
nearest_grid = round(position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
# Also try snapping element's far edge to grid
element_end = position + size
nearest_grid_end = round(element_end / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end)))
# 3. Guide snapping
if self.snap_to_guides:
for guide in self.guides:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
# Snap start edge to guide
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
# Snap end edge to guide
element_end = position + size
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
return best_snap if best_snap is not None else position
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
"""
Get all snap lines for visualization
Args:
page_size: Page size (width, height) in mm
dpi: DPI for conversion
Returns:
Dictionary with 'grid', 'edges', and 'guides' lists
"""
page_width_mm, page_height_mm = page_size
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
result = {
'grid': [],
'edges': [],
'guides': []
}
# Grid lines
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Vertical grid lines
x = 0
while x <= page_width_px:
result['grid'].append(('vertical', x))
x += grid_size_px
# Horizontal grid lines
y = 0
while y <= page_height_px:
result['grid'].append(('horizontal', y))
y += grid_size_px
# Edge lines
if self.snap_to_edges:
result['edges'].extend([
('vertical', 0),
('vertical', page_width_px),
('horizontal', 0),
('horizontal', page_height_px)
])
# Guide lines
if self.snap_to_guides:
for guide in self.guides:
guide_pos_px = guide.position * dpi / 25.4
result['guides'].append((guide.orientation, guide_pos_px))
return result
def serialize(self) -> dict:
"""Serialize snapping system to dictionary"""
return {
"snap_threshold_mm": self.snap_threshold_mm,
"grid_size_mm": self.grid_size_mm,
"snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges,
"snap_to_guides": self.snap_to_guides,
"guides": [guide.serialize() for guide in self.guides]
}
def deserialize(self, data: dict):
"""Deserialize from dictionary"""
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
self.grid_size_mm = data.get("grid_size_mm", 10.0)
self.snap_to_grid = data.get("snap_to_grid", False)
self.snap_to_edges = data.get("snap_to_edges", True)
self.snap_to_guides = data.get("snap_to_guides", True)
self.guides = []
for guide_data in data.get("guides", []):
self.guides.append(Guide.deserialize(guide_data))