2026-04-09 21:08:39 +00:00

442 lines
16 KiB
Python

"""
Snapping system for pyPhotoAlbum
Provides grid snapping, edge snapping, and custom guide snapping
"""
import math
from typing import Any, Dict, 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"))
@dataclass
class SnapResizeParams:
"""Parameters for snap resize operations"""
position: Tuple[float, float]
size: Tuple[float, float]
dx: float
dy: float
resize_handle: str
page_size: Tuple[float, float]
dpi: int = 300
project: Optional[Any] = None
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
"""
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, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""
Apply snapping during resize operations
Args:
params: SnapResizeParams containing all resize parameters
Returns:
Tuple of (snapped_position, snapped_size) in pixels
"""
x, y = params.position
width, height = params.size
page_width_mm, page_height_mm = params.page_size
# Use project settings if available, otherwise use local settings
if params.project:
snap_threshold_mm = params.project.snap_threshold_mm
else:
snap_threshold_mm = self.snap_threshold_mm
# Convert threshold from mm to pixels
snap_threshold_px = snap_threshold_mm * params.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 params.resize_handle in ["nw", "n", "ne"]:
# Top edge moving
new_y = y + params.dy
new_height = height - params.dy
if params.resize_handle in ["sw", "s", "se"]:
# Bottom edge moving
new_height = height + params.dy
if params.resize_handle in ["nw", "w", "sw"]:
# Left edge moving
new_x = x + params.dx
new_width = width - params.dx
if params.resize_handle in ["ne", "e", "se"]:
# Right edge moving
new_width = width + params.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 params.resize_handle in ["nw", "w", "sw"]:
# Try to snap the left edge
snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.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 params.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, params.dpi, snap_threshold_px, "vertical", params.project
)
if snapped_right is not None:
new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles)
if params.resize_handle in ["nw", "n", "ne"]:
# Try to snap the top edge
snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.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 params.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, params.dpi, snap_threshold_px, "horizontal", params.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: List[Tuple[float, float]] = []
# 1. Page edge snapping
if snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0.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 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: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}
# Grid lines
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Vertical grid lines
x: float = 0
while x <= page_width_px:
result["grid"].append(("vertical", x))
x += grid_size_px
# Horizontal grid lines
y: float = 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))