408 lines
14 KiB
Python
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))
|