gdsdiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. gdsdiff/__init__.py +148 -0
  2. gdsdiff/__main__.py +7 -0
  3. gdsdiff/_environment.py +12 -0
  4. gdsdiff/_version.py +34 -0
  5. gdsdiff/acceptance_evidence.py +530 -0
  6. gdsdiff/api.py +2955 -0
  7. gdsdiff/backends/__init__.py +26 -0
  8. gdsdiff/backends/base.py +77 -0
  9. gdsdiff/backends/boundary_loop_ctypes.py +191 -0
  10. gdsdiff/backends/cpu.py +20 -0
  11. gdsdiff/backends/cpu_ctypes.py +255 -0
  12. gdsdiff/backends/cuda.py +40 -0
  13. gdsdiff/backends/cuda_ctypes.py +1297 -0
  14. gdsdiff/backends/exact.py +424 -0
  15. gdsdiff/backends/geometry_ctypes.py +252 -0
  16. gdsdiff/backends/identity.py +53 -0
  17. gdsdiff/backends/metal.py +198 -0
  18. gdsdiff/backends/metal_ctypes.py +866 -0
  19. gdsdiff/backends/polygon_extract_ctypes.py +233 -0
  20. gdsdiff/backends/structural_ctypes.py +130 -0
  21. gdsdiff/boundary_loops.py +616 -0
  22. gdsdiff/cache.py +544 -0
  23. gdsdiff/canonicalize.py +176 -0
  24. gdsdiff/cli.py +352 -0
  25. gdsdiff/config.py +257 -0
  26. gdsdiff/cuda_setup.py +174 -0
  27. gdsdiff/design_history.py +65 -0
  28. gdsdiff/diagnostics.py +42 -0
  29. gdsdiff/diff_gds.py +66 -0
  30. gdsdiff/exact_diff_markdown.py +979 -0
  31. gdsdiff/exact_oracle.py +649 -0
  32. gdsdiff/extract.py +376 -0
  33. gdsdiff/gds_metadata.py +43 -0
  34. gdsdiff/geometry.py +112 -0
  35. gdsdiff/grid.py +143 -0
  36. gdsdiff/hierarchy.py +215 -0
  37. gdsdiff/hierarchy_extract.py +263 -0
  38. gdsdiff/inventory.py +153 -0
  39. gdsdiff/multiprocessing_support.py +39 -0
  40. gdsdiff/native.py +135 -0
  41. gdsdiff/native_cache.py +265 -0
  42. gdsdiff/native_src/cpu_prefilter.cpp +349 -0
  43. gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
  44. gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
  45. gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
  46. gdsdiff/native_src/gds_structural_scan.cpp +335 -0
  47. gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
  48. gdsdiff/native_src/gdsdiff/core.cpp +191 -0
  49. gdsdiff/native_src/gdsdiff/core.hpp +96 -0
  50. gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
  51. gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
  52. gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
  53. gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
  54. gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
  55. gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
  56. gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
  57. gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
  58. gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
  59. gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
  60. gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
  61. gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
  62. gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
  63. gdsdiff/policy.py +305 -0
  64. gdsdiff/polygon_components.py +860 -0
  65. gdsdiff/profiles.py +152 -0
  66. gdsdiff/py.typed +1 -0
  67. gdsdiff/rect_coverage.py +384 -0
  68. gdsdiff/report.py +354 -0
  69. gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
  70. gdsdiff/semantics.py +75 -0
  71. gdsdiff/source_edge_diff.py +1120 -0
  72. gdsdiff/spatial.py +71 -0
  73. gdsdiff/structural_guard.py +161 -0
  74. gdsdiff/surplus_coverage.py +255 -0
  75. gdsdiff/synthetic_suite.py +1643 -0
  76. gdsdiff/templates.py +709 -0
  77. gdsdiff/tiling.py +153 -0
  78. gdsdiff/windowed_exact.py +270 -0
  79. gdsdiff/windows.py +184 -0
  80. gdsdiff-0.1.0.dist-info/METADATA +135 -0
  81. gdsdiff-0.1.0.dist-info/RECORD +85 -0
  82. gdsdiff-0.1.0.dist-info/WHEEL +5 -0
  83. gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
  84. gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
  85. gdsdiff-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,616 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from math import gcd
6
+ from time import perf_counter
7
+ from typing import Any
8
+
9
+ from .exact_oracle import DiffFragment
10
+ from .geometry import polygon_bbox, polygon_twice_area
11
+
12
+ Point = tuple[int, int]
13
+ Segment = tuple[Point, Point]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BoundaryLoopAssemblyStats:
18
+ input_fragment_count: int
19
+ boundary_edge_count: int
20
+ loop_count: int
21
+ dropped_zero_area_loop_count: int
22
+ cuda_edge_cancel_s: float = 0.0
23
+ cuda_edge_cancel_kernel_count: int = 0
24
+ cuda_edge_cancel_event_elapsed_s: float = 0.0
25
+ cuda_edge_cancel_transferred_bytes: int = 0
26
+ native_edge_cancel_s: float = 0.0
27
+ cuda_loop_trace_s: float = 0.0
28
+ metal_loop_trace_s: float = 0.0
29
+ metal_loop_kernel_count: int = 0
30
+ metal_loop_event_elapsed_s: float = 0.0
31
+ metal_loop_transferred_bytes: int = 0
32
+ native_loop_trace_s: float = 0.0
33
+ python_loop_trace_s: float = 0.0
34
+ hole_stitch_s: float = 0.0
35
+
36
+ def to_json(self) -> dict[str, int | float]:
37
+ return {
38
+ "input_fragment_count": self.input_fragment_count,
39
+ "boundary_edge_count": self.boundary_edge_count,
40
+ "loop_count": self.loop_count,
41
+ "dropped_zero_area_loop_count": self.dropped_zero_area_loop_count,
42
+ "cuda_edge_cancel_s": self.cuda_edge_cancel_s,
43
+ "cuda_edge_cancel_kernel_count": self.cuda_edge_cancel_kernel_count,
44
+ "cuda_edge_cancel_event_elapsed_s": self.cuda_edge_cancel_event_elapsed_s,
45
+ "cuda_edge_cancel_transferred_bytes": self.cuda_edge_cancel_transferred_bytes,
46
+ "native_edge_cancel_s": self.native_edge_cancel_s,
47
+ "cuda_loop_trace_s": self.cuda_loop_trace_s,
48
+ "metal_loop_trace_s": self.metal_loop_trace_s,
49
+ "metal_loop_kernel_count": self.metal_loop_kernel_count,
50
+ "metal_loop_event_elapsed_s": self.metal_loop_event_elapsed_s,
51
+ "metal_loop_transferred_bytes": self.metal_loop_transferred_bytes,
52
+ "native_loop_trace_s": self.native_loop_trace_s,
53
+ "python_loop_trace_s": self.python_loop_trace_s,
54
+ "hole_stitch_s": self.hole_stitch_s,
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class BoundaryLoopAssemblyResult:
60
+ fragments: tuple[DiffFragment, ...]
61
+ stats: BoundaryLoopAssemblyStats
62
+
63
+
64
+ def assemble_boundary_loops(fragments: tuple[DiffFragment, ...]) -> BoundaryLoopAssemblyResult:
65
+ """Cancel shared directed fragment edges and trace remaining boundary loops.
66
+
67
+ This is a deterministic integer post-process for GPU-emitted trapezoid
68
+ fragments. It deliberately avoids polygon Boolean operations; the input is
69
+ assumed to be already decomposed into non-overlapping coverage cells.
70
+ """
71
+ cuda_edge_cancel_s = 0.0
72
+ cuda_edge_cancel_kernel_count = 0
73
+ cuda_edge_cancel_event_elapsed_s = 0.0
74
+ cuda_edge_cancel_transferred_bytes = 0
75
+ native_edge_cancel_s = 0.0
76
+ cuda_loop_trace_s = 0.0
77
+ metal_loop_trace_s = 0.0
78
+ metal_loop_kernel_count = 0
79
+ metal_loop_event_elapsed_s = 0.0
80
+ metal_loop_transferred_bytes = 0
81
+ native_loop_trace_s = 0.0
82
+ python_loop_trace_s = 0.0
83
+ edge_backend_result = _try_cuda_boundary_edges_from_fragments(fragments)
84
+ if edge_backend_result is None:
85
+ edge_backend_result = _try_native_boundary_edges_from_fragments(fragments)
86
+ edge_array = edge_backend_result[0] if edge_backend_result is not None else None
87
+ if edge_backend_result is not None:
88
+ if edge_backend_result[2] == "cuda":
89
+ cuda_edge_cancel_s = edge_backend_result[1]
90
+ cuda_edge_cancel_kernel_count = edge_backend_result[3]
91
+ cuda_edge_cancel_event_elapsed_s = edge_backend_result[4]
92
+ cuda_edge_cancel_transferred_bytes = edge_backend_result[5]
93
+ else:
94
+ native_edge_cancel_s = edge_backend_result[1]
95
+ if edge_array is not None:
96
+ boundary_edge_count = int(len(edge_array))
97
+ cuda_start = perf_counter()
98
+ cuda_result = _try_cuda_boundary_loop_fragments_array(edge_array)
99
+ cuda_loop_trace_s = perf_counter() - cuda_start
100
+ metal_result = None
101
+ if cuda_result is None:
102
+ metal_start = perf_counter()
103
+ metal_result = _try_metal_boundary_loop_fragments_array(edge_array)
104
+ metal_loop_trace_s = perf_counter() - metal_start
105
+ native_loop_result = None
106
+ if cuda_result is None and metal_result is None:
107
+ native_start = perf_counter()
108
+ native_loop_result = _try_native_boundary_loop_fragments_array(edge_array)
109
+ native_loop_trace_s = perf_counter() - native_start
110
+ if cuda_result is not None:
111
+ result = list(cuda_result.fragments)
112
+ dropped = cuda_result.dropped_zero_area_loop_count
113
+ elif metal_result is not None:
114
+ result = list(metal_result.fragments)
115
+ dropped = metal_result.dropped_zero_area_loop_count
116
+ metal_loop_kernel_count = metal_result.metal_kernel_count
117
+ metal_loop_event_elapsed_s = metal_result.metal_event_elapsed_s
118
+ metal_loop_transferred_bytes = metal_result.transferred_bytes
119
+ elif native_loop_result is not None:
120
+ result = list(native_loop_result.fragments)
121
+ dropped = native_loop_result.dropped_zero_area_loop_count
122
+ else:
123
+ boundary_edges = _segments_from_edges_array(edge_array)
124
+ python_start = perf_counter()
125
+ result, dropped = _fragments_from_python_boundary_edges(boundary_edges)
126
+ python_loop_trace_s = perf_counter() - python_start
127
+ else:
128
+ boundary_edges = _boundary_edges(fragments)
129
+ boundary_edge_count = len(boundary_edges)
130
+ cuda_start = perf_counter()
131
+ cuda_result = _try_cuda_boundary_loop_fragments(boundary_edges)
132
+ cuda_loop_trace_s = perf_counter() - cuda_start
133
+ if cuda_result is not None:
134
+ result = list(cuda_result.fragments)
135
+ dropped = cuda_result.dropped_zero_area_loop_count
136
+ else:
137
+ metal_start = perf_counter()
138
+ metal_result = _try_metal_boundary_loop_fragments(boundary_edges)
139
+ metal_loop_trace_s = perf_counter() - metal_start
140
+ if metal_result is not None:
141
+ result = list(metal_result.fragments)
142
+ dropped = metal_result.dropped_zero_area_loop_count
143
+ metal_loop_kernel_count = metal_result.metal_kernel_count
144
+ metal_loop_event_elapsed_s = metal_result.metal_event_elapsed_s
145
+ metal_loop_transferred_bytes = metal_result.transferred_bytes
146
+ else:
147
+ python_start = perf_counter()
148
+ result, dropped = _fragments_from_python_boundary_edges(boundary_edges)
149
+ python_loop_trace_s = perf_counter() - python_start
150
+ stitch_start = perf_counter()
151
+ result_tuple = tuple(sorted(_stitch_nested_holes(tuple(result)), key=_fragment_sort_key))
152
+ hole_stitch_s = perf_counter() - stitch_start
153
+ return BoundaryLoopAssemblyResult(
154
+ fragments=result_tuple,
155
+ stats=BoundaryLoopAssemblyStats(
156
+ input_fragment_count=len(fragments),
157
+ boundary_edge_count=boundary_edge_count,
158
+ loop_count=len(result_tuple),
159
+ dropped_zero_area_loop_count=dropped,
160
+ cuda_edge_cancel_s=cuda_edge_cancel_s,
161
+ cuda_edge_cancel_kernel_count=cuda_edge_cancel_kernel_count,
162
+ cuda_edge_cancel_event_elapsed_s=cuda_edge_cancel_event_elapsed_s,
163
+ cuda_edge_cancel_transferred_bytes=cuda_edge_cancel_transferred_bytes,
164
+ native_edge_cancel_s=native_edge_cancel_s,
165
+ cuda_loop_trace_s=cuda_loop_trace_s,
166
+ metal_loop_trace_s=metal_loop_trace_s,
167
+ metal_loop_kernel_count=metal_loop_kernel_count,
168
+ metal_loop_event_elapsed_s=metal_loop_event_elapsed_s,
169
+ metal_loop_transferred_bytes=metal_loop_transferred_bytes,
170
+ native_loop_trace_s=native_loop_trace_s,
171
+ python_loop_trace_s=python_loop_trace_s,
172
+ hole_stitch_s=hole_stitch_s,
173
+ ),
174
+ )
175
+
176
+
177
+ def _fragments_from_python_boundary_edges(boundary_edges: tuple[Segment, ...]) -> tuple[list[DiffFragment], int]:
178
+ loops = _trace_loops(boundary_edges)
179
+ result = []
180
+ dropped = 0
181
+ for loop in loops:
182
+ points = _clean_loop(loop)
183
+ if len(points) < 3:
184
+ dropped += 1
185
+ continue
186
+ twice_area = polygon_twice_area(points)
187
+ if twice_area == 0:
188
+ dropped += 1
189
+ continue
190
+ result.append(DiffFragment(points=points, bbox=polygon_bbox(points), twice_area=twice_area))
191
+ return result, dropped
192
+
193
+
194
+ def _try_cuda_boundary_edges_from_fragments(fragments: tuple[DiffFragment, ...]) -> tuple[Any, float, str, int, float, int] | None:
195
+ if len(fragments) < 2 or sum(len(fragment.points) for fragment in fragments) < 512:
196
+ return None
197
+ try:
198
+ from .backends.cuda_ctypes import (
199
+ run_cuda_axis_aligned_boundary_edges_from_fragments,
200
+ run_cuda_split_free_boundary_edges_from_fragments,
201
+ )
202
+
203
+ start = perf_counter()
204
+ if _all_edges_axis_aligned(fragments):
205
+ result = run_cuda_axis_aligned_boundary_edges_from_fragments(fragments)
206
+ else:
207
+ result = run_cuda_split_free_boundary_edges_from_fragments(fragments)
208
+ return (
209
+ result.edges,
210
+ perf_counter() - start,
211
+ "cuda",
212
+ result.cuda_kernel_count,
213
+ result.cuda_event_elapsed_s,
214
+ result.transferred_bytes,
215
+ )
216
+ except Exception:
217
+ return None
218
+
219
+
220
+ def _try_native_boundary_edges_from_fragments(fragments: tuple[DiffFragment, ...]) -> tuple[Any, float, str, int, float, int] | None:
221
+ if len(fragments) < 2 or sum(len(fragment.points) for fragment in fragments) < 64:
222
+ return None
223
+ try:
224
+ from .backends.boundary_loop_ctypes import native_boundary_edges_from_fragments
225
+
226
+ start = perf_counter()
227
+ edges = native_boundary_edges_from_fragments(fragments).edges
228
+ return edges, perf_counter() - start, "native", 0, 0.0, 0
229
+ except Exception:
230
+ return None
231
+
232
+
233
+ def _all_edges_axis_aligned(fragments: tuple[DiffFragment, ...]) -> bool:
234
+ for fragment in fragments:
235
+ points = _clean_polygon(fragment.points)
236
+ if len(points) < 3:
237
+ continue
238
+ for start, stop in _edges(points):
239
+ if start[0] != stop[0] and start[1] != stop[1]:
240
+ return False
241
+ return True
242
+
243
+
244
+ def _try_cuda_boundary_loop_fragments_array(edges: Any):
245
+ if len(edges) < 512:
246
+ return None
247
+ try:
248
+ from .backends.cuda_ctypes import run_cuda_boundary_loops_from_edges
249
+
250
+ return run_cuda_boundary_loops_from_edges(edges)
251
+ except Exception:
252
+ return None
253
+
254
+
255
+ def _try_metal_boundary_loop_fragments_array(edges: Any):
256
+ if len(edges) < 512:
257
+ return None
258
+ try:
259
+ from .backends.metal_ctypes import run_metal_boundary_loops_from_edges
260
+
261
+ return run_metal_boundary_loops_from_edges(edges)
262
+ except Exception:
263
+ return None
264
+
265
+
266
+ def _try_native_boundary_loop_fragments_array(edges: Any):
267
+ if len(edges) < 64:
268
+ return None
269
+ try:
270
+ from .backends.boundary_loop_ctypes import native_boundary_loops_from_edges
271
+
272
+ return native_boundary_loops_from_edges(edges)
273
+ except Exception:
274
+ return None
275
+
276
+
277
+ def _segments_from_edges_array(edges: Any) -> tuple[Segment, ...]:
278
+ return tuple(
279
+ ((int(edge[0]), int(edge[1])), (int(edge[2]), int(edge[3])))
280
+ for edge in edges
281
+ )
282
+
283
+
284
+ def _try_cuda_boundary_loop_fragments(edges: tuple[Segment, ...]):
285
+ if len(edges) < 512:
286
+ return None
287
+ try:
288
+ import numpy as np
289
+
290
+ from .backends.cuda_ctypes import run_cuda_boundary_loops_from_edges
291
+
292
+ return run_cuda_boundary_loops_from_edges(np.asarray([(a[0], a[1], b[0], b[1]) for a, b in edges], dtype=np.int64))
293
+ except Exception:
294
+ return None
295
+
296
+
297
+ def _try_metal_boundary_loop_fragments(edges: tuple[Segment, ...]):
298
+ if len(edges) < 512:
299
+ return None
300
+ try:
301
+ import numpy as np
302
+
303
+ from .backends.metal_ctypes import run_metal_boundary_loops_from_edges
304
+
305
+ return run_metal_boundary_loops_from_edges(np.asarray([(a[0], a[1], b[0], b[1]) for a, b in edges], dtype=np.int64))
306
+ except Exception:
307
+ return None
308
+
309
+
310
+ def _boundary_edges(fragments: tuple[DiffFragment, ...]) -> tuple[Segment, ...]:
311
+ counts: dict[Segment, int] = defaultdict(int)
312
+ raw_edges: list[Segment] = []
313
+ for fragment in fragments:
314
+ points = _clean_polygon(fragment.points)
315
+ if len(points) < 3:
316
+ continue
317
+ for edge in _edges(points):
318
+ if edge[0] == edge[1]:
319
+ continue
320
+ raw_edges.append(edge)
321
+ for edge in _split_collinear_edges(tuple(raw_edges)):
322
+ reverse = (edge[1], edge[0])
323
+ if counts.get(reverse, 0):
324
+ counts[reverse] -= 1
325
+ if counts[reverse] == 0:
326
+ del counts[reverse]
327
+ else:
328
+ counts[edge] += 1
329
+ boundary: list[Segment] = []
330
+ for edge, count in counts.items():
331
+ boundary.extend(edge for _ in range(count))
332
+ return tuple(sorted(boundary))
333
+
334
+
335
+ def _stitch_nested_holes(fragments: tuple[DiffFragment, ...]) -> tuple[DiffFragment, ...]:
336
+ if len(fragments) < 2:
337
+ return fragments
338
+ parents: dict[int, int | None] = {}
339
+ for index, fragment in enumerate(fragments):
340
+ parent_candidates = [
341
+ other_index
342
+ for other_index, other in enumerate(fragments)
343
+ if other_index != index and _strictly_contains_fragment(other, fragment)
344
+ ]
345
+ parents[index] = (
346
+ min(parent_candidates, key=lambda candidate: fragments[candidate].twice_area)
347
+ if parent_candidates
348
+ else None
349
+ )
350
+ depths = {index: _containment_depth(index, parents) for index in range(len(fragments))}
351
+ holes_by_outer: dict[int, list[DiffFragment]] = defaultdict(list)
352
+ output_indices: list[int] = []
353
+ for index, depth in depths.items():
354
+ if depth % 2 == 0:
355
+ output_indices.append(index)
356
+ else:
357
+ parent = parents[index]
358
+ if parent is not None:
359
+ holes_by_outer[parent].append(fragments[index])
360
+
361
+ stitched: list[DiffFragment] = []
362
+ for index in output_indices:
363
+ fragment = fragments[index]
364
+ points = fragment.points
365
+ unstitched_holes: list[DiffFragment] = []
366
+ for hole in sorted(holes_by_outer.get(index, ()), key=_fragment_sort_key):
367
+ try:
368
+ points = _stitch_hole(points, hole.points)
369
+ except ValueError:
370
+ unstitched_holes.append(hole)
371
+ points = _clean_polygon(points)
372
+ stitched.append(DiffFragment(points=points, bbox=polygon_bbox(points), twice_area=polygon_twice_area(points)))
373
+ stitched.extend(unstitched_holes)
374
+ return tuple(stitched)
375
+
376
+
377
+ def _strictly_contains_fragment(outer: DiffFragment, inner: DiffFragment) -> bool:
378
+ if not (
379
+ outer.bbox[0] <= inner.bbox[0]
380
+ and outer.bbox[1] <= inner.bbox[1]
381
+ and outer.bbox[2] >= inner.bbox[2]
382
+ and outer.bbox[3] >= inner.bbox[3]
383
+ ):
384
+ return False
385
+ if outer.bbox == inner.bbox:
386
+ return False
387
+ return _point_in_polygon(inner.points[0], outer.points)
388
+
389
+
390
+ def _containment_depth(index: int, parents: dict[int, int | None]) -> int:
391
+ depth = 0
392
+ current = parents[index]
393
+ while current is not None:
394
+ depth += 1
395
+ current = parents[current]
396
+ return depth
397
+
398
+
399
+ def _stitch_hole(outer: tuple[Point, ...], hole: tuple[Point, ...]) -> tuple[Point, ...]:
400
+ target_area = abs(polygon_twice_area(outer) - polygon_twice_area(hole))
401
+ candidates = []
402
+ for anchor in sorted(hole, key=lambda point: (point[0], point[1])):
403
+ for bridge in _horizontal_bridge_points(outer, anchor):
404
+ outer_cycle = _cycle_outer_with_bridge(outer, bridge)
405
+ for sequence in (hole, tuple(reversed(hole))):
406
+ sequence_anchor_index = sequence.index(anchor)
407
+ hole_cycle = sequence[sequence_anchor_index:] + sequence[:sequence_anchor_index]
408
+ points = _clean_polygon(outer_cycle + (bridge, anchor) + hole_cycle + (anchor,))
409
+ candidates.append(points)
410
+ if not candidates:
411
+ raise ValueError("could not find a bridge point for nested hole")
412
+ return min(candidates, key=lambda points: (abs(polygon_twice_area(points) - target_area), len(points), points))
413
+
414
+
415
+ def _horizontal_bridge_points(outer: tuple[Point, ...], anchor: Point) -> tuple[Point, ...]:
416
+ anchor_x, anchor_y = anchor
417
+ left_candidates: list[tuple[float, Point]] = []
418
+ right_candidates: list[tuple[float, Point]] = []
419
+ for start, stop in _edges(outer):
420
+ y0, y1 = start[1], stop[1]
421
+ x0, x1 = start[0], stop[0]
422
+ if y0 == y1:
423
+ if y0 == anchor_y:
424
+ for x in (x0, x1):
425
+ if x < anchor_x:
426
+ left_candidates.append((float(x), (x, anchor_y)))
427
+ elif x > anchor_x:
428
+ right_candidates.append((float(x), (x, anchor_y)))
429
+ continue
430
+ if min(y0, y1) <= anchor_y <= max(y0, y1):
431
+ x = x0 + (anchor_y - y0) * (x1 - x0) / (y1 - y0)
432
+ if x < anchor_x:
433
+ left_candidates.append((x, (round(x), anchor_y)))
434
+ elif x > anchor_x:
435
+ right_candidates.append((x, (round(x), anchor_y)))
436
+ points = []
437
+ if left_candidates:
438
+ points.append(max(left_candidates, key=lambda item: item[0])[1])
439
+ if right_candidates:
440
+ points.append(min(right_candidates, key=lambda item: item[0])[1])
441
+ return tuple(dict.fromkeys(points))
442
+
443
+
444
+ def _cycle_outer_with_bridge(outer: tuple[Point, ...], bridge: Point) -> tuple[Point, ...]:
445
+ for index, point in enumerate(outer):
446
+ if point == bridge:
447
+ return outer[index:] + outer[:index]
448
+ for index, (start, stop) in enumerate(_edges(outer)):
449
+ if _point_on_segment(bridge, start, stop):
450
+ return (bridge,) + outer[(index + 1) :] + outer[: index + 1]
451
+ raise ValueError("bridge point is not on the outer loop")
452
+
453
+
454
+ def _point_on_segment(point: Point, start: Point, stop: Point) -> bool:
455
+ if not (
456
+ min(start[0], stop[0]) <= point[0] <= max(start[0], stop[0])
457
+ and min(start[1], stop[1]) <= point[1] <= max(start[1], stop[1])
458
+ ):
459
+ return False
460
+ return _collinear(start, point, stop)
461
+
462
+
463
+ def _point_in_polygon(point: Point, polygon: tuple[Point, ...]) -> bool:
464
+ x, y = point
465
+ inside = False
466
+ for start, stop in _edges(polygon):
467
+ if (start[1] > y) != (stop[1] > y):
468
+ x_intersection = start[0] + (y - start[1]) * (stop[0] - start[0]) / (stop[1] - start[1])
469
+ if x_intersection > x:
470
+ inside = not inside
471
+ return inside
472
+
473
+
474
+ def _split_collinear_edges(edges: tuple[Segment, ...]) -> tuple[Segment, ...]:
475
+ by_line: dict[tuple[int, int, int], list[Segment]] = defaultdict(list)
476
+ points_by_line: dict[tuple[int, int, int], dict[int, Point]] = defaultdict(dict)
477
+ for edge in edges:
478
+ key = _line_key(edge)
479
+ by_line[key].append(edge)
480
+ points_by_line[key][_projection(edge[0], key)] = edge[0]
481
+ points_by_line[key][_projection(edge[1], key)] = edge[1]
482
+
483
+ split_edges: list[Segment] = []
484
+ for key, line_edges in by_line.items():
485
+ line_points = sorted(points_by_line[key])
486
+ for start, end in line_edges:
487
+ start_t = _projection(start, key)
488
+ end_t = _projection(end, key)
489
+ low = min(start_t, end_t)
490
+ high = max(start_t, end_t)
491
+ breakpoints = [value for value in line_points if low <= value <= high]
492
+ if start_t > end_t:
493
+ breakpoints.reverse()
494
+ for left_t, right_t in zip(breakpoints, breakpoints[1:]):
495
+ if left_t == right_t:
496
+ continue
497
+ split_edges.append((points_by_line[key][left_t], points_by_line[key][right_t]))
498
+ return tuple(split_edges)
499
+
500
+
501
+ def _line_key(edge: Segment) -> tuple[int, int, int]:
502
+ (x0, y0), (x1, y1) = edge
503
+ dx = x1 - x0
504
+ dy = y1 - y0
505
+ scale = gcd(abs(dx), abs(dy))
506
+ if scale == 0:
507
+ raise ValueError("zero-length segment has no line key")
508
+ ux = dx // scale
509
+ uy = dy // scale
510
+ if ux < 0 or (ux == 0 and uy < 0):
511
+ ux = -ux
512
+ uy = -uy
513
+ c = -uy * x0 + ux * y0
514
+ return ux, uy, c
515
+
516
+
517
+ def _projection(point: Point, key: tuple[int, int, int]) -> int:
518
+ ux, uy, _ = key
519
+ return point[0] * ux + point[1] * uy
520
+
521
+
522
+ def _trace_loops(edges: tuple[Segment, ...]) -> tuple[tuple[Point, ...], ...]:
523
+ outgoing: dict[Point, list[Point]] = defaultdict(list)
524
+ for start, end in edges:
525
+ outgoing[start].append(end)
526
+ for ends in outgoing.values():
527
+ ends.sort()
528
+
529
+ remaining: set[Segment] = set(edges)
530
+ loops: list[tuple[Point, ...]] = []
531
+ while remaining:
532
+ start, end = min(remaining)
533
+ remaining.remove((start, end))
534
+ loop = [start, end]
535
+ previous = start
536
+ current = end
537
+ while current != start:
538
+ next_point = _select_next(previous, current, outgoing.get(current, ()), remaining)
539
+ if next_point is None:
540
+ break
541
+ remaining.remove((current, next_point))
542
+ loop.append(next_point)
543
+ previous, current = current, next_point
544
+ if loop[-1] == start:
545
+ loop.pop()
546
+ loops.append(tuple(loop))
547
+ return tuple(sorted(loops))
548
+
549
+
550
+ def _select_next(
551
+ previous: Point,
552
+ current: Point,
553
+ candidates: list[Point] | tuple[Point, ...],
554
+ remaining: set[Segment],
555
+ ) -> Point | None:
556
+ available = [candidate for candidate in candidates if (current, candidate) in remaining]
557
+ if not available:
558
+ return None
559
+ if len(available) == 1:
560
+ return available[0]
561
+ in_vec = (current[0] - previous[0], current[1] - previous[1])
562
+ return min(available, key=lambda candidate: (_turn_key(in_vec, (candidate[0] - current[0], candidate[1] - current[1])), candidate))
563
+
564
+
565
+ def _turn_key(in_vec: Point, out_vec: Point) -> tuple[int, int, int]:
566
+ # Prefer continuing the same contour with the smallest right-hand turn.
567
+ cross = in_vec[0] * out_vec[1] - in_vec[1] * out_vec[0]
568
+ dot = in_vec[0] * out_vec[0] + in_vec[1] * out_vec[1]
569
+ if cross < 0:
570
+ turn_class = 0
571
+ elif cross == 0 and dot > 0:
572
+ turn_class = 1
573
+ elif cross > 0:
574
+ turn_class = 2
575
+ else:
576
+ turn_class = 3
577
+ return turn_class, -abs(cross), -dot
578
+
579
+
580
+ def _edges(points: tuple[Point, ...]) -> tuple[Segment, ...]:
581
+ return tuple((point, points[(index + 1) % len(points)]) for index, point in enumerate(points))
582
+
583
+
584
+ def _clean_polygon(points: tuple[Point, ...]) -> tuple[Point, ...]:
585
+ cleaned: list[Point] = []
586
+ for point in points:
587
+ if not cleaned or cleaned[-1] != point:
588
+ cleaned.append(point)
589
+ if len(cleaned) > 1 and cleaned[0] == cleaned[-1]:
590
+ cleaned.pop()
591
+ return tuple(cleaned)
592
+
593
+
594
+ def _clean_loop(points: tuple[Point, ...]) -> tuple[Point, ...]:
595
+ cleaned = list(_clean_polygon(points))
596
+ changed = True
597
+ while changed and len(cleaned) >= 3:
598
+ changed = False
599
+ next_points: list[Point] = []
600
+ for index, point in enumerate(cleaned):
601
+ previous = cleaned[index - 1]
602
+ following = cleaned[(index + 1) % len(cleaned)]
603
+ if _collinear(previous, point, following):
604
+ changed = True
605
+ continue
606
+ next_points.append(point)
607
+ cleaned = next_points
608
+ return tuple(cleaned)
609
+
610
+
611
+ def _collinear(left: Point, middle: Point, right: Point) -> bool:
612
+ return (middle[0] - left[0]) * (right[1] - middle[1]) == (middle[1] - left[1]) * (right[0] - middle[0])
613
+
614
+
615
+ def _fragment_sort_key(fragment: DiffFragment) -> tuple[tuple[int, int, int, int], int, tuple[Point, ...]]:
616
+ return fragment.bbox, fragment.twice_area, fragment.points