442 lines
16 KiB
Python
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))
|