520 lines
19 KiB
Python
520 lines
19 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,
|
|
project=None) -> Tuple[float, float]:
|
|
"""
|
|
Apply snapping to a position using combined distance threshold
|
|
|
|
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
|
|
project: Optional project for global snapping settings
|
|
|
|
Returns:
|
|
Snapped position (x, y) in pixels
|
|
"""
|
|
import math
|
|
|
|
x, y = position
|
|
width, height = size
|
|
page_width_mm, page_height_mm = page_size
|
|
|
|
# Use project settings if available, otherwise use local settings
|
|
if project:
|
|
snap_to_grid = project.snap_to_grid
|
|
snap_to_edges = project.snap_to_edges
|
|
snap_to_guides = project.snap_to_guides
|
|
grid_size_mm = project.grid_size_mm
|
|
snap_threshold_mm = project.snap_threshold_mm
|
|
else:
|
|
snap_to_grid = self.snap_to_grid
|
|
snap_to_edges = self.snap_to_edges
|
|
snap_to_guides = self.snap_to_guides
|
|
grid_size_mm = self.grid_size_mm
|
|
snap_threshold_mm = self.snap_threshold_mm
|
|
|
|
# Convert threshold from mm to pixels
|
|
snap_threshold_px = snap_threshold_mm * dpi / 25.4
|
|
|
|
# Collect all potential snap points for both edges of the element
|
|
snap_points = []
|
|
|
|
# 1. Page edge snap points
|
|
if snap_to_edges:
|
|
page_width_px = page_width_mm * dpi / 25.4
|
|
page_height_px = page_height_mm * dpi / 25.4
|
|
|
|
# Corners where element's top-left can snap
|
|
snap_points.extend([
|
|
(0, 0), # Top-left corner
|
|
(page_width_px - width, 0), # Top-right corner
|
|
(0, page_height_px - height), # Bottom-left corner
|
|
(page_width_px - width, page_height_px - height), # Bottom-right corner
|
|
])
|
|
|
|
# Edge positions (element aligned to edge on one axis)
|
|
snap_points.extend([
|
|
(0, y), # Left edge
|
|
(page_width_px - width, y), # Right edge
|
|
(x, 0), # Top edge
|
|
(x, page_height_px - height), # Bottom edge
|
|
])
|
|
|
|
# 2. Grid snap points
|
|
if snap_to_grid:
|
|
grid_size_px = grid_size_mm * dpi / 25.4
|
|
page_width_px = page_width_mm * dpi / 25.4
|
|
page_height_px = page_height_mm * dpi / 25.4
|
|
|
|
# Calculate grid intersection points within range
|
|
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
|
|
x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
|
y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
|
|
y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
|
|
|
grid_x = x_start
|
|
while grid_x <= x_end:
|
|
grid_y = y_start
|
|
while grid_y <= y_end:
|
|
snap_points.append((grid_x, grid_y))
|
|
# Also snap element's far edge to grid
|
|
if grid_x >= width:
|
|
snap_points.append((grid_x - width, grid_y))
|
|
if grid_y >= height:
|
|
snap_points.append((grid_x, grid_y - height))
|
|
grid_y += grid_size_px
|
|
grid_x += grid_size_px
|
|
|
|
# 3. Guide snap points
|
|
if snap_to_guides:
|
|
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
|
|
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
|
|
|
|
# Guide intersections (when both vertical and horizontal guides exist)
|
|
for vg in vertical_guides:
|
|
for hg in horizontal_guides:
|
|
snap_points.append((vg, hg))
|
|
# Also snap element's far edge to intersections
|
|
snap_points.append((vg - width, hg))
|
|
snap_points.append((vg, hg - height))
|
|
snap_points.append((vg - width, hg - height))
|
|
|
|
# Find the nearest snap point using Euclidean distance
|
|
best_snap_point = None
|
|
best_distance = snap_threshold_px
|
|
|
|
for snap_x, snap_y in snap_points:
|
|
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
|
|
if distance < best_distance:
|
|
best_snap_point = (snap_x, snap_y)
|
|
best_distance = distance
|
|
|
|
# Return snapped position or original position
|
|
if best_snap_point:
|
|
return best_snap_point
|
|
else:
|
|
return (x, 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,
|
|
project=None) -> 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
|
|
project: Optional project for global snapping settings
|
|
|
|
Returns:
|
|
Tuple of (snapped_position, snapped_size) in pixels
|
|
"""
|
|
x, y = position
|
|
width, height = size
|
|
page_width_mm, page_height_mm = page_size
|
|
|
|
# Use project settings if available, otherwise use local settings
|
|
if project:
|
|
snap_threshold_mm = project.snap_threshold_mm
|
|
else:
|
|
snap_threshold_mm = self.snap_threshold_mm
|
|
|
|
# Convert threshold from mm to pixels
|
|
snap_threshold_px = 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
|
|
# Use _snap_edge_to_targets consistently for all edges
|
|
|
|
# Snap left edge (for nw, w, sw handles)
|
|
if resize_handle in ['nw', 'w', 'sw']:
|
|
# Try to snap the left edge
|
|
snapped_left = self._snap_edge_to_targets(
|
|
new_x, page_width_mm, dpi, snap_threshold_px, 'vertical', project
|
|
)
|
|
if snapped_left is not None:
|
|
# 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', project
|
|
)
|
|
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']:
|
|
# Try to snap the top edge
|
|
snapped_top = self._snap_edge_to_targets(
|
|
new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal', project
|
|
)
|
|
if snapped_top is not None:
|
|
# 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', project
|
|
)
|
|
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,
|
|
project=None) -> 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
|
|
project: Optional project for global snapping settings
|
|
|
|
Returns:
|
|
Snapped edge position in pixels, or None if no snap
|
|
"""
|
|
# Use project settings if available, otherwise use local settings
|
|
if project:
|
|
snap_to_grid = project.snap_to_grid
|
|
snap_to_edges = project.snap_to_edges
|
|
snap_to_guides = project.snap_to_guides
|
|
grid_size_mm = project.grid_size_mm
|
|
else:
|
|
snap_to_grid = self.snap_to_grid
|
|
snap_to_edges = self.snap_to_edges
|
|
snap_to_guides = self.snap_to_guides
|
|
grid_size_mm = self.grid_size_mm
|
|
|
|
snap_candidates = []
|
|
|
|
# 1. Page edge snapping
|
|
if 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 snap_to_grid:
|
|
grid_size_px = 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 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))
|