Coverage for pyPhotoAlbum/snapping.py: 95%
209 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Snapping system for pyPhotoAlbum
3Provides grid snapping, edge snapping, and custom guide snapping
4"""
6import math
7from typing import Any, Dict, List, Tuple, Optional
8from dataclasses import dataclass
11@dataclass
12class Guide:
13 """Represents a snapping guide (vertical or horizontal line)"""
15 position: float # Position in mm
16 orientation: str # 'vertical' or 'horizontal'
18 def serialize(self) -> dict:
19 """Serialize guide to dictionary"""
20 return {"position": self.position, "orientation": self.orientation}
22 @staticmethod
23 def deserialize(data: dict) -> "Guide":
24 """Deserialize guide from dictionary"""
25 return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
28@dataclass
29class SnapResizeParams:
30 """Parameters for snap resize operations"""
32 position: Tuple[float, float]
33 size: Tuple[float, float]
34 dx: float
35 dy: float
36 resize_handle: str
37 page_size: Tuple[float, float]
38 dpi: int = 300
39 project: Optional[Any] = None
42class SnappingSystem:
43 """Manages snapping behavior for layout elements"""
45 def __init__(self, snap_threshold_mm: float = 5.0):
46 """
47 Initialize snapping system
49 Args:
50 snap_threshold_mm: Distance in mm within which snapping occurs
51 """
52 self.snap_threshold_mm = snap_threshold_mm
53 self.grid_size_mm = 10.0 # Grid spacing in mm
54 self.snap_to_grid = False
55 self.snap_to_edges = True
56 self.snap_to_guides = True
57 self.guides: List[Guide] = []
59 def add_guide(self, position: float, orientation: str):
60 """Add a new guide"""
61 guide = Guide(position=position, orientation=orientation)
62 self.guides.append(guide)
63 return guide
65 def remove_guide(self, guide: Guide):
66 """Remove a guide"""
67 if guide in self.guides:
68 self.guides.remove(guide)
70 def clear_guides(self):
71 """Remove all guides"""
72 self.guides.clear()
74 def snap_position(
75 self,
76 position: Tuple[float, float],
77 size: Tuple[float, float],
78 page_size: Tuple[float, float],
79 dpi: int = 300,
80 project=None,
81 ) -> Tuple[float, float]:
82 """
83 Apply snapping to a position using combined distance threshold
85 Args:
86 position: Current position (x, y) in pixels
87 size: Element size (width, height) in pixels
88 page_size: Page size (width, height) in mm
89 dpi: DPI for conversion
90 project: Optional project for global snapping settings
92 Returns:
93 Snapped position (x, y) in pixels
94 """
95 x, y = position
96 width, height = size
97 page_width_mm, page_height_mm = page_size
99 # Use project settings if available, otherwise use local settings
100 if project:
101 snap_to_grid = project.snap_to_grid
102 snap_to_edges = project.snap_to_edges
103 snap_to_guides = project.snap_to_guides
104 grid_size_mm = project.grid_size_mm
105 snap_threshold_mm = project.snap_threshold_mm
106 else:
107 snap_to_grid = self.snap_to_grid
108 snap_to_edges = self.snap_to_edges
109 snap_to_guides = self.snap_to_guides
110 grid_size_mm = self.grid_size_mm
111 snap_threshold_mm = self.snap_threshold_mm
113 # Convert threshold from mm to pixels
114 snap_threshold_px = snap_threshold_mm * dpi / 25.4
116 # Collect all potential snap points for both edges of the element
117 snap_points = []
119 # 1. Page edge snap points
120 if snap_to_edges:
121 page_width_px = page_width_mm * dpi / 25.4
122 page_height_px = page_height_mm * dpi / 25.4
124 # Corners where element's top-left can snap
125 snap_points.extend(
126 [
127 (0, 0), # Top-left corner
128 (page_width_px - width, 0), # Top-right corner
129 (0, page_height_px - height), # Bottom-left corner
130 (page_width_px - width, page_height_px - height), # Bottom-right corner
131 ]
132 )
134 # Edge positions (element aligned to edge on one axis)
135 snap_points.extend(
136 [
137 (0, y), # Left edge
138 (page_width_px - width, y), # Right edge
139 (x, 0), # Top edge
140 (x, page_height_px - height), # Bottom edge
141 ]
142 )
144 # 2. Grid snap points
145 if snap_to_grid:
146 grid_size_px = grid_size_mm * dpi / 25.4
147 page_width_px = page_width_mm * dpi / 25.4
148 page_height_px = page_height_mm * dpi / 25.4
150 # Calculate grid intersection points within range
151 x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
152 x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
153 y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
154 y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
156 grid_x = x_start
157 while grid_x <= x_end:
158 grid_y = y_start
159 while grid_y <= y_end:
160 snap_points.append((grid_x, grid_y))
161 # Also snap element's far edge to grid
162 if grid_x >= width:
163 snap_points.append((grid_x - width, grid_y))
164 if grid_y >= height:
165 snap_points.append((grid_x, grid_y - height))
166 grid_y += grid_size_px
167 grid_x += grid_size_px
169 # 3. Guide snap points
170 if snap_to_guides:
171 vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"]
172 horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"]
174 # Guide intersections (when both vertical and horizontal guides exist)
175 for vg in vertical_guides:
176 for hg in horizontal_guides:
177 snap_points.append((vg, hg))
178 # Also snap element's far edge to intersections
179 snap_points.append((vg - width, hg))
180 snap_points.append((vg, hg - height))
181 snap_points.append((vg - width, hg - height))
183 # Find the nearest snap point using Euclidean distance
184 best_snap_point = None
185 best_distance = snap_threshold_px
187 for snap_x, snap_y in snap_points:
188 distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
189 if distance < best_distance:
190 best_snap_point = (snap_x, snap_y)
191 best_distance = distance
193 # Return snapped position or original position
194 if best_snap_point:
195 return best_snap_point
196 else:
197 return (x, y)
199 def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
200 """
201 Apply snapping during resize operations
203 Args:
204 params: SnapResizeParams containing all resize parameters
206 Returns:
207 Tuple of (snapped_position, snapped_size) in pixels
208 """
209 x, y = params.position
210 width, height = params.size
211 page_width_mm, page_height_mm = params.page_size
213 # Use project settings if available, otherwise use local settings
214 if params.project:
215 snap_threshold_mm = params.project.snap_threshold_mm
216 else:
217 snap_threshold_mm = self.snap_threshold_mm
219 # Convert threshold from mm to pixels
220 snap_threshold_px = snap_threshold_mm * params.dpi / 25.4
222 # Calculate new position and size based on resize handle
223 new_x, new_y = x, y
224 new_width, new_height = width, height
226 # Apply resize based on handle
227 if params.resize_handle in ["nw", "n", "ne"]:
228 # Top edge moving
229 new_y = y + params.dy
230 new_height = height - params.dy
232 if params.resize_handle in ["sw", "s", "se"]:
233 # Bottom edge moving
234 new_height = height + params.dy
236 if params.resize_handle in ["nw", "w", "sw"]:
237 # Left edge moving
238 new_x = x + params.dx
239 new_width = width - params.dx
241 if params.resize_handle in ["ne", "e", "se"]:
242 # Right edge moving
243 new_width = width + params.dx
245 # Now apply snapping to the edges that are being moved
246 # Use _snap_edge_to_targets consistently for all edges
248 # Snap left edge (for nw, w, sw handles)
249 if params.resize_handle in ["nw", "w", "sw"]:
250 # Try to snap the left edge
251 snapped_left = self._snap_edge_to_targets(
252 new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
253 )
254 if snapped_left is not None:
255 # Adjust width to compensate for position change
256 width_adjustment = new_x - snapped_left
257 new_x = snapped_left
258 new_width += width_adjustment
260 # Snap right edge (for ne, e, se handles)
261 if params.resize_handle in ["ne", "e", "se"]:
262 # Calculate right edge position
263 right_edge = new_x + new_width
264 # Try to snap the right edge
265 snapped_right = self._snap_edge_to_targets(
266 right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
267 )
268 if snapped_right is not None:
269 new_width = snapped_right - new_x
271 # Snap top edge (for nw, n, ne handles)
272 if params.resize_handle in ["nw", "n", "ne"]:
273 # Try to snap the top edge
274 snapped_top = self._snap_edge_to_targets(
275 new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
276 )
277 if snapped_top is not None:
278 # Adjust height to compensate for position change
279 height_adjustment = new_y - snapped_top
280 new_y = snapped_top
281 new_height += height_adjustment
283 # Snap bottom edge (for sw, s, se handles)
284 if params.resize_handle in ["sw", "s", "se"]:
285 # Calculate bottom edge position
286 bottom_edge = new_y + new_height
287 # Try to snap the bottom edge
288 snapped_bottom = self._snap_edge_to_targets(
289 bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
290 )
291 if snapped_bottom is not None:
292 new_height = snapped_bottom - new_y
294 # Ensure minimum size
295 min_size = 10 # Minimum 10 pixels
296 new_width = max(new_width, min_size)
297 new_height = max(new_height, min_size)
299 return ((new_x, new_y), (new_width, new_height))
301 def _snap_edge_to_targets(
302 self,
303 edge_position: float,
304 page_size_mm: float,
305 dpi: int,
306 snap_threshold_px: float,
307 orientation: str,
308 project=None,
309 ) -> Optional[float]:
310 """
311 Snap an edge position to available targets (grid, edges, guides)
313 Args:
314 edge_position: Current edge position in pixels
315 page_size_mm: Page size along axis in mm
316 dpi: DPI for conversion
317 snap_threshold_px: Snap threshold in pixels
318 orientation: 'vertical' for x-axis, 'horizontal' for y-axis
319 project: Optional project for global snapping settings
321 Returns:
322 Snapped edge position in pixels, or None if no snap
323 """
324 # Use project settings if available, otherwise use local settings
325 if project:
326 snap_to_grid = project.snap_to_grid
327 snap_to_edges = project.snap_to_edges
328 snap_to_guides = project.snap_to_guides
329 grid_size_mm = project.grid_size_mm
330 else:
331 snap_to_grid = self.snap_to_grid
332 snap_to_edges = self.snap_to_edges
333 snap_to_guides = self.snap_to_guides
334 grid_size_mm = self.grid_size_mm
336 snap_candidates: List[Tuple[float, float]] = []
338 # 1. Page edge snapping
339 if snap_to_edges:
340 # Snap to start edge (0)
341 snap_candidates.append((0.0, abs(edge_position - 0)))
343 # Snap to end edge
344 page_size_px = page_size_mm * dpi / 25.4
345 snap_candidates.append((page_size_px, abs(edge_position - page_size_px)))
347 # 2. Grid snapping
348 if snap_to_grid:
349 grid_size_px = grid_size_mm * dpi / 25.4
351 # Snap to nearest grid line
352 nearest_grid = round(edge_position / grid_size_px) * grid_size_px
353 snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid)))
355 # 3. Guide snapping
356 if snap_to_guides:
357 for guide in self.guides:
358 if guide.orientation == orientation:
359 guide_pos_px = guide.position * dpi / 25.4
360 snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
362 # Find the best snap candidate within threshold
363 best_snap = None
364 best_distance = snap_threshold_px
366 for snap_pos, distance in snap_candidates:
367 if distance < best_distance:
368 best_snap = snap_pos
369 best_distance = distance
371 return best_snap
373 def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
374 """
375 Get all snap lines for visualization
377 Args:
378 page_size: Page size (width, height) in mm
379 dpi: DPI for conversion
381 Returns:
382 Dictionary with 'grid', 'edges', and 'guides' lists
383 """
384 page_width_mm, page_height_mm = page_size
385 page_width_px = page_width_mm * dpi / 25.4
386 page_height_px = page_height_mm * dpi / 25.4
388 result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}
390 # Grid lines
391 if self.snap_to_grid:
392 grid_size_px = self.grid_size_mm * dpi / 25.4
394 # Vertical grid lines
395 x: float = 0
396 while x <= page_width_px:
397 result["grid"].append(("vertical", x))
398 x += grid_size_px
400 # Horizontal grid lines
401 y: float = 0
402 while y <= page_height_px:
403 result["grid"].append(("horizontal", y))
404 y += grid_size_px
406 # Edge lines
407 if self.snap_to_edges:
408 result["edges"].extend(
409 [("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
410 )
412 # Guide lines
413 if self.snap_to_guides:
414 for guide in self.guides:
415 guide_pos_px = guide.position * dpi / 25.4
416 result["guides"].append((guide.orientation, guide_pos_px))
418 return result
420 def serialize(self) -> dict:
421 """Serialize snapping system to dictionary"""
422 return {
423 "snap_threshold_mm": self.snap_threshold_mm,
424 "grid_size_mm": self.grid_size_mm,
425 "snap_to_grid": self.snap_to_grid,
426 "snap_to_edges": self.snap_to_edges,
427 "snap_to_guides": self.snap_to_guides,
428 "guides": [guide.serialize() for guide in self.guides],
429 }
431 def deserialize(self, data: dict):
432 """Deserialize from dictionary"""
433 self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
434 self.grid_size_mm = data.get("grid_size_mm", 10.0)
435 self.snap_to_grid = data.get("snap_to_grid", False)
436 self.snap_to_edges = data.get("snap_to_edges", True)
437 self.snap_to_guides = data.get("snap_to_guides", True)
439 self.guides = []
440 for guide_data in data.get("guides", []):
441 self.guides.append(Guide.deserialize(guide_data))