1 """

2 Snapping system for pyPhotoAlbum

3 Provides grid snapping, edge snapping, and custom guide snapping

4 """

5

6 import math

7 from typing import Any, Dict, List, Tuple, Optional

8 from dataclasses import dataclass

9

10

11 @dataclass

12 class Guide:

13 """Represents a snapping guide (vertical or horizontal line)"""

14

15 position: float # Position in mm

16 orientation: str # 'vertical' or 'horizontal'

17

18 def serialize(self) -> dict:

19 """Serialize guide to dictionary"""

20 return {"position": self.position, "orientation": self.orientation}

21

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"))

26

27

28 @dataclass

29 class SnapResizeParams:

30 """Parameters for snap resize operations"""

31

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

40

41

42 class SnappingSystem:

43 """Manages snapping behavior for layout elements"""

44

45 def __init__(self, snap_threshold_mm: float = 5.0):

46 """

47 Initialize snapping system

48

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] = []

58

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

64

65 def remove_guide(self, guide: Guide):

66 """Remove a guide"""

67 if guide in self.guides:

68 self.guides.remove(guide)

69

70 def clear_guides(self):

71 """Remove all guides"""

72 self.guides.clear()

73

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

84

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

91

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

98

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

112

113 # Convert threshold from mm to pixels

114 snap_threshold_px = snap_threshold_mm * dpi / 25.4

115

116 # Collect all potential snap points for both edges of the element

117 snap_points = []

118

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

123

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 )

133

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 )

143

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

149

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)

155

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

168

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"]

173

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))

182

183 # Find the nearest snap point using Euclidean distance

184 best_snap_point = None

185 best_distance = snap_threshold_px

186

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

192

193 # Return snapped position or original position

194 if best_snap_point:

195 return best_snap_point

196 else:

197 return (x, y)

198

199 def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:

200 """

201 Apply snapping during resize operations

202

203 Args:

204 params: SnapResizeParams containing all resize parameters

205

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

212

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

218

219 # Convert threshold from mm to pixels

220 snap_threshold_px = snap_threshold_mm * params.dpi / 25.4

221

222 # Calculate new position and size based on resize handle

223 new_x, new_y = x, y

224 new_width, new_height = width, height

225

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

231

232 if params.resize_handle in ["sw", "s", "se"]:

233 # Bottom edge moving

234 new_height = height + params.dy

235

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

240

241 if params.resize_handle in ["ne", "e", "se"]:

242 # Right edge moving

243 new_width = width + params.dx

244

245 # Now apply snapping to the edges that are being moved

246 # Use _snap_edge_to_targets consistently for all edges

247

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

259

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

270

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

282

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

293

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)

298

299 return ((new_x, new_y), (new_width, new_height))

300

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)

312

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

320

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

335

336 snap_candidates: List[Tuple[float, float]] = []

337

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)))

342

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)))

346

347 # 2. Grid snapping

348 if snap_to_grid:

349 grid_size_px = grid_size_mm * dpi / 25.4

350

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)))

354

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)))

361

362 # Find the best snap candidate within threshold

363 best_snap = None

364 best_distance = snap_threshold_px

365

366 for snap_pos, distance in snap_candidates:

367 if distance < best_distance:

368 best_snap = snap_pos

369 best_distance = distance

370

371 return best_snap

372

373 def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:

374 """

375 Get all snap lines for visualization

376

377 Args:

378 page_size: Page size (width, height) in mm

379 dpi: DPI for conversion

380

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

387

388 result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}

389

390 # Grid lines

391 if self.snap_to_grid:

392 grid_size_px = self.grid_size_mm * dpi / 25.4

393

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

399

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

405

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 )

411

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))

417

418 return result

419

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 }

430

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)

438

439 self.guides = []

440 for guide_data in data.get("guides", []):

441 self.guides.append(Guide.deserialize(guide_data))