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,1120 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from math import hypot, isfinite
6
+ from typing import Sequence
7
+
8
+ import numpy as np
9
+
10
+ from .exact_oracle import DiffFragment, ExactOracleResult, LayerExactDiff
11
+ from .geometry import polygon_bbox, polygon_twice_area
12
+ from .polygon_components import (
13
+ LayerSideMergeStats,
14
+ PolygonComponent,
15
+ _component_sort_key,
16
+ _fragment_sort_key,
17
+ _merge_layer_side_fragments_with_stats,
18
+ )
19
+
20
+ Point = tuple[int, int]
21
+ FloatPoint = tuple[float, float]
22
+ FloatSegment = tuple[FloatPoint, FloatPoint]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SourceEdgeDiffStats:
27
+ component_count: int
28
+ boundary_edge_count: int
29
+ loop_count: int
30
+ dropped_zero_area_loop_count: int
31
+ gpu_loop_component_count: int = 0
32
+ gpu_loop_edge_count: int = 0
33
+ cuda_loop_component_count: int = 0
34
+ cuda_loop_edge_count: int = 0
35
+ metal_loop_component_count: int = 0
36
+ metal_loop_edge_count: int = 0
37
+ native_loop_component_count: int = 0
38
+ native_loop_edge_count: int = 0
39
+ gpu_boundary_component_count: int = 0
40
+ gpu_boundary_edge_count: int = 0
41
+ gpu_split_free_component_count: int = 0
42
+ gpu_mixed_boundary_component_count: int = 0
43
+ gpu_mixed_boundary_edge_count: int = 0
44
+ layer_merge_input_fragment_count: int = 0
45
+ layer_merge_output_fragment_count: int = 0
46
+ layer_merge_group_count: int = 0
47
+ layer_merge_assembled_group_count: int = 0
48
+ layer_merge_cuda_edge_cancel_s: float = 0.0
49
+ layer_merge_cuda_edge_cancel_kernel_count: int = 0
50
+ layer_merge_cuda_edge_cancel_event_elapsed_s: float = 0.0
51
+ layer_merge_cuda_edge_cancel_transferred_bytes: int = 0
52
+ layer_merge_native_edge_cancel_s: float = 0.0
53
+ layer_merge_cuda_loop_trace_s: float = 0.0
54
+ layer_merge_metal_loop_trace_s: float = 0.0
55
+ layer_merge_metal_loop_kernel_count: int = 0
56
+ layer_merge_metal_loop_event_elapsed_s: float = 0.0
57
+ layer_merge_metal_loop_transferred_bytes: int = 0
58
+ layer_merge_native_loop_trace_s: float = 0.0
59
+ layer_merge_python_loop_trace_s: float = 0.0
60
+ layer_merge_hole_stitch_s: float = 0.0
61
+ nested_direction_fragment_component_count: int = 0
62
+
63
+ def to_json(self) -> dict[str, int | float]:
64
+ return {
65
+ "component_count": self.component_count,
66
+ "boundary_edge_count": self.boundary_edge_count,
67
+ "loop_count": self.loop_count,
68
+ "dropped_zero_area_loop_count": self.dropped_zero_area_loop_count,
69
+ "gpu_loop_component_count": self.gpu_loop_component_count,
70
+ "gpu_loop_edge_count": self.gpu_loop_edge_count,
71
+ "cuda_loop_component_count": self.cuda_loop_component_count,
72
+ "cuda_loop_edge_count": self.cuda_loop_edge_count,
73
+ "metal_loop_component_count": self.metal_loop_component_count,
74
+ "metal_loop_edge_count": self.metal_loop_edge_count,
75
+ "native_loop_component_count": self.native_loop_component_count,
76
+ "native_loop_edge_count": self.native_loop_edge_count,
77
+ "gpu_boundary_component_count": self.gpu_boundary_component_count,
78
+ "gpu_boundary_edge_count": self.gpu_boundary_edge_count,
79
+ "gpu_split_free_component_count": self.gpu_split_free_component_count,
80
+ "gpu_mixed_boundary_component_count": self.gpu_mixed_boundary_component_count,
81
+ "gpu_mixed_boundary_edge_count": self.gpu_mixed_boundary_edge_count,
82
+ "layer_merge_input_fragment_count": self.layer_merge_input_fragment_count,
83
+ "layer_merge_output_fragment_count": self.layer_merge_output_fragment_count,
84
+ "layer_merge_group_count": self.layer_merge_group_count,
85
+ "layer_merge_assembled_group_count": self.layer_merge_assembled_group_count,
86
+ "layer_merge_cuda_edge_cancel_s": self.layer_merge_cuda_edge_cancel_s,
87
+ "layer_merge_cuda_edge_cancel_kernel_count": self.layer_merge_cuda_edge_cancel_kernel_count,
88
+ "layer_merge_cuda_edge_cancel_event_elapsed_s": self.layer_merge_cuda_edge_cancel_event_elapsed_s,
89
+ "layer_merge_cuda_edge_cancel_transferred_bytes": self.layer_merge_cuda_edge_cancel_transferred_bytes,
90
+ "layer_merge_native_edge_cancel_s": self.layer_merge_native_edge_cancel_s,
91
+ "layer_merge_cuda_loop_trace_s": self.layer_merge_cuda_loop_trace_s,
92
+ "layer_merge_metal_loop_trace_s": self.layer_merge_metal_loop_trace_s,
93
+ "layer_merge_metal_loop_kernel_count": self.layer_merge_metal_loop_kernel_count,
94
+ "layer_merge_metal_loop_event_elapsed_s": self.layer_merge_metal_loop_event_elapsed_s,
95
+ "layer_merge_metal_loop_transferred_bytes": self.layer_merge_metal_loop_transferred_bytes,
96
+ "layer_merge_native_loop_trace_s": self.layer_merge_native_loop_trace_s,
97
+ "layer_merge_python_loop_trace_s": self.layer_merge_python_loop_trace_s,
98
+ "layer_merge_hole_stitch_s": self.layer_merge_hole_stitch_s,
99
+ "nested_direction_fragment_component_count": self.nested_direction_fragment_component_count,
100
+ }
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class SourceEdgeDiffResult:
105
+ exact_result: ExactOracleResult
106
+ stats: SourceEdgeDiffStats
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class _SegmentRecord:
111
+ side: str
112
+ polygon_index: int
113
+ start: FloatPoint
114
+ stop: FloatPoint
115
+
116
+
117
+ def exact_result_from_source_edges(components: tuple[PolygonComponent, ...]) -> SourceEdgeDiffResult:
118
+ source_components = _merge_contacting_source_components(components)
119
+ by_layer: dict[object, dict[str, list[DiffFragment]]] = {}
120
+ edge_count = 0
121
+ loop_count = 0
122
+ dropped_count = 0
123
+ gpu_component_count = 0
124
+ gpu_edge_count = 0
125
+ gpu_split_free_count = 0
126
+ gpu_mixed_component_count = 0
127
+ gpu_mixed_edge_count = 0
128
+ native_loop_component_count = 0
129
+ native_loop_edge_count = 0
130
+ gpu_loop_component_count = 0
131
+ gpu_loop_edge_count = 0
132
+ cuda_loop_component_count = 0
133
+ cuda_loop_edge_count = 0
134
+ metal_loop_component_count = 0
135
+ metal_loop_edge_count = 0
136
+ nested_direction_fragment_component_count = 0
137
+ layer_merge_stats = LayerSideMergeStats()
138
+ side_cache: dict[tuple[str, tuple[object, ...]], tuple[tuple[DiffFragment, ...], _ComponentSideStats]] = {}
139
+ for component in source_components:
140
+ layer_bucket = by_layer.setdefault(component.layer, {"old": [], "new": []})
141
+ old_minus, old_stats = _cached_component_side_diff(component, direction="old", cache=side_cache)
142
+ new_minus, new_stats = _cached_component_side_diff(component, direction="new", cache=side_cache)
143
+ layer_bucket["old"].extend(old_minus)
144
+ layer_bucket["new"].extend(new_minus)
145
+ edge_count += old_stats.boundary_edge_count + new_stats.boundary_edge_count
146
+ loop_count += old_stats.loop_count + new_stats.loop_count
147
+ dropped_count += old_stats.dropped_zero_area_loop_count + new_stats.dropped_zero_area_loop_count
148
+ gpu_component_count += old_stats.gpu_boundary_component_count + new_stats.gpu_boundary_component_count
149
+ gpu_edge_count += old_stats.gpu_boundary_edge_count + new_stats.gpu_boundary_edge_count
150
+ gpu_split_free_count += old_stats.gpu_split_free_component_count + new_stats.gpu_split_free_component_count
151
+ gpu_mixed_component_count += old_stats.gpu_mixed_boundary_component_count + new_stats.gpu_mixed_boundary_component_count
152
+ gpu_mixed_edge_count += old_stats.gpu_mixed_boundary_edge_count + new_stats.gpu_mixed_boundary_edge_count
153
+ native_loop_component_count += old_stats.native_loop_component_count + new_stats.native_loop_component_count
154
+ native_loop_edge_count += old_stats.native_loop_edge_count + new_stats.native_loop_edge_count
155
+ gpu_loop_component_count += old_stats.gpu_loop_component_count + new_stats.gpu_loop_component_count
156
+ gpu_loop_edge_count += old_stats.gpu_loop_edge_count + new_stats.gpu_loop_edge_count
157
+ cuda_loop_component_count += old_stats.cuda_loop_component_count + new_stats.cuda_loop_component_count
158
+ cuda_loop_edge_count += old_stats.cuda_loop_edge_count + new_stats.cuda_loop_edge_count
159
+ metal_loop_component_count += old_stats.metal_loop_component_count + new_stats.metal_loop_component_count
160
+ metal_loop_edge_count += old_stats.metal_loop_edge_count + new_stats.metal_loop_edge_count
161
+ nested_direction_fragment_component_count += (
162
+ old_stats.nested_direction_fragment_component_count
163
+ + new_stats.nested_direction_fragment_component_count
164
+ )
165
+
166
+ layer_diffs = []
167
+ for layer in sorted(by_layer):
168
+ old_merge = _merge_layer_side_fragments_with_stats(tuple(sorted(by_layer[layer]["old"], key=_fragment_sort_key)))
169
+ new_merge = _merge_layer_side_fragments_with_stats(tuple(sorted(by_layer[layer]["new"], key=_fragment_sort_key)))
170
+ layer_merge_stats = _add_layer_merge_stats(layer_merge_stats, old_merge.stats)
171
+ layer_merge_stats = _add_layer_merge_stats(layer_merge_stats, new_merge.stats)
172
+ old_minus_new = old_merge.fragments
173
+ new_minus_old = new_merge.fragments
174
+ layer_diffs.append(
175
+ LayerExactDiff(
176
+ layer=layer,
177
+ old_minus_new=old_minus_new,
178
+ new_minus_old=new_minus_old,
179
+ xor=tuple(sorted(old_minus_new + new_minus_old, key=_fragment_sort_key)),
180
+ )
181
+ )
182
+ return SourceEdgeDiffResult(
183
+ exact_result=ExactOracleResult(tuple(layer_diffs)),
184
+ stats=SourceEdgeDiffStats(
185
+ component_count=len(source_components),
186
+ boundary_edge_count=edge_count,
187
+ loop_count=loop_count,
188
+ dropped_zero_area_loop_count=dropped_count,
189
+ gpu_loop_component_count=gpu_loop_component_count,
190
+ gpu_loop_edge_count=gpu_loop_edge_count,
191
+ cuda_loop_component_count=cuda_loop_component_count,
192
+ cuda_loop_edge_count=cuda_loop_edge_count,
193
+ metal_loop_component_count=metal_loop_component_count,
194
+ metal_loop_edge_count=metal_loop_edge_count,
195
+ native_loop_component_count=native_loop_component_count,
196
+ native_loop_edge_count=native_loop_edge_count,
197
+ gpu_boundary_component_count=gpu_component_count,
198
+ gpu_boundary_edge_count=gpu_edge_count,
199
+ gpu_split_free_component_count=gpu_split_free_count,
200
+ gpu_mixed_boundary_component_count=gpu_mixed_component_count,
201
+ gpu_mixed_boundary_edge_count=gpu_mixed_edge_count,
202
+ layer_merge_input_fragment_count=layer_merge_stats.input_fragment_count,
203
+ layer_merge_output_fragment_count=layer_merge_stats.output_fragment_count,
204
+ layer_merge_group_count=layer_merge_stats.group_count,
205
+ layer_merge_assembled_group_count=layer_merge_stats.assembled_group_count,
206
+ layer_merge_cuda_edge_cancel_s=layer_merge_stats.cuda_edge_cancel_s,
207
+ layer_merge_cuda_edge_cancel_kernel_count=layer_merge_stats.cuda_edge_cancel_kernel_count,
208
+ layer_merge_cuda_edge_cancel_event_elapsed_s=layer_merge_stats.cuda_edge_cancel_event_elapsed_s,
209
+ layer_merge_cuda_edge_cancel_transferred_bytes=layer_merge_stats.cuda_edge_cancel_transferred_bytes,
210
+ layer_merge_native_edge_cancel_s=layer_merge_stats.native_edge_cancel_s,
211
+ layer_merge_cuda_loop_trace_s=layer_merge_stats.cuda_loop_trace_s,
212
+ layer_merge_metal_loop_trace_s=layer_merge_stats.metal_loop_trace_s,
213
+ layer_merge_metal_loop_kernel_count=layer_merge_stats.metal_loop_kernel_count,
214
+ layer_merge_metal_loop_event_elapsed_s=layer_merge_stats.metal_loop_event_elapsed_s,
215
+ layer_merge_metal_loop_transferred_bytes=layer_merge_stats.metal_loop_transferred_bytes,
216
+ layer_merge_native_loop_trace_s=layer_merge_stats.native_loop_trace_s,
217
+ layer_merge_python_loop_trace_s=layer_merge_stats.python_loop_trace_s,
218
+ layer_merge_hole_stitch_s=layer_merge_stats.hole_stitch_s,
219
+ nested_direction_fragment_component_count=nested_direction_fragment_component_count,
220
+ ),
221
+ )
222
+
223
+
224
+ def _add_layer_merge_stats(left: LayerSideMergeStats, right: LayerSideMergeStats) -> LayerSideMergeStats:
225
+ return LayerSideMergeStats(
226
+ input_fragment_count=left.input_fragment_count + right.input_fragment_count,
227
+ output_fragment_count=left.output_fragment_count + right.output_fragment_count,
228
+ group_count=left.group_count + right.group_count,
229
+ assembled_group_count=left.assembled_group_count + right.assembled_group_count,
230
+ cuda_edge_cancel_s=left.cuda_edge_cancel_s + right.cuda_edge_cancel_s,
231
+ cuda_edge_cancel_kernel_count=left.cuda_edge_cancel_kernel_count + right.cuda_edge_cancel_kernel_count,
232
+ cuda_edge_cancel_event_elapsed_s=left.cuda_edge_cancel_event_elapsed_s + right.cuda_edge_cancel_event_elapsed_s,
233
+ cuda_edge_cancel_transferred_bytes=left.cuda_edge_cancel_transferred_bytes + right.cuda_edge_cancel_transferred_bytes,
234
+ native_edge_cancel_s=left.native_edge_cancel_s + right.native_edge_cancel_s,
235
+ cuda_loop_trace_s=left.cuda_loop_trace_s + right.cuda_loop_trace_s,
236
+ metal_loop_trace_s=left.metal_loop_trace_s + right.metal_loop_trace_s,
237
+ metal_loop_kernel_count=left.metal_loop_kernel_count + right.metal_loop_kernel_count,
238
+ metal_loop_event_elapsed_s=left.metal_loop_event_elapsed_s + right.metal_loop_event_elapsed_s,
239
+ metal_loop_transferred_bytes=left.metal_loop_transferred_bytes + right.metal_loop_transferred_bytes,
240
+ native_loop_trace_s=left.native_loop_trace_s + right.native_loop_trace_s,
241
+ python_loop_trace_s=left.python_loop_trace_s + right.python_loop_trace_s,
242
+ hole_stitch_s=left.hole_stitch_s + right.hole_stitch_s,
243
+ )
244
+
245
+
246
+ def _cached_component_side_diff(
247
+ component: PolygonComponent,
248
+ *,
249
+ direction: str,
250
+ cache: dict[tuple[str, tuple[object, ...]], tuple[tuple[DiffFragment, ...], _ComponentSideStats]],
251
+ ) -> tuple[tuple[DiffFragment, ...], _ComponentSideStats]:
252
+ origin = (component.bbox[0], component.bbox[1])
253
+ key = (direction, _normalized_component_key(component, origin))
254
+ cached = cache.get(key)
255
+ if cached is None:
256
+ fragments, stats = _component_side_diff(component, direction=direction)
257
+ cached = (_translate_fragments(fragments, (-origin[0], -origin[1])), stats)
258
+ cache[key] = cached
259
+ fragments, stats = cached
260
+ return _translate_fragments(fragments, origin), stats
261
+
262
+
263
+ def _merge_contacting_source_components(components: tuple[PolygonComponent, ...]) -> tuple[PolygonComponent, ...]:
264
+ by_layer: dict[object, list[PolygonComponent]] = {}
265
+ for component in components:
266
+ by_layer.setdefault(component.layer, []).append(component)
267
+
268
+ merged: list[PolygonComponent] = []
269
+ for layer in sorted(by_layer):
270
+ layer_components = tuple(sorted(by_layer[layer], key=_component_sort_key))
271
+ for indices in _component_bbox_contact_groups(layer_components):
272
+ polygons = {}
273
+ for index in indices:
274
+ for polygon in layer_components[index].polygons:
275
+ polygons[(polygon.side, polygon.fragment.bbox, polygon.fragment.twice_area, polygon.fragment.points)] = polygon
276
+ merged.append(
277
+ PolygonComponent(
278
+ layer=layer,
279
+ polygons=tuple(sorted(polygons.values(), key=lambda polygon: (polygon.side, polygon.source_index, polygon.role, polygon.fragment.bbox))),
280
+ )
281
+ )
282
+ return tuple(sorted(merged, key=_component_sort_key))
283
+
284
+
285
+ def _component_bbox_contact_groups(components: tuple[PolygonComponent, ...]) -> tuple[tuple[int, ...], ...]:
286
+ remaining = set(range(len(components)))
287
+ groups: list[tuple[int, ...]] = []
288
+ while remaining:
289
+ seed = remaining.pop()
290
+ group = {seed}
291
+ stack = [seed]
292
+ while stack:
293
+ left = stack.pop()
294
+ touched = [right for right in remaining if _bbox_contact(components[left].bbox, components[right].bbox)]
295
+ for right in touched:
296
+ remaining.remove(right)
297
+ group.add(right)
298
+ stack.append(right)
299
+ groups.append(tuple(sorted(group)))
300
+ return tuple(sorted(groups))
301
+
302
+
303
+ def _bbox_contact(left: tuple[int, int, int, int], right: tuple[int, int, int, int]) -> bool:
304
+ return max(left[0], right[0]) <= min(left[2], right[2]) and max(left[1], right[1]) <= min(left[3], right[3])
305
+
306
+
307
+ def _normalized_component_key(component: PolygonComponent, origin: Point) -> tuple[object, ...]:
308
+ ox, oy = origin
309
+ records = []
310
+ for polygon in component.polygons:
311
+ points = tuple((point[0] - ox, point[1] - oy) for point in polygon.fragment.points)
312
+ records.append((polygon.side, points))
313
+ return tuple(sorted(records))
314
+
315
+
316
+ def _translate_fragments(fragments: tuple[DiffFragment, ...], delta: Point) -> tuple[DiffFragment, ...]:
317
+ dx, dy = delta
318
+ if dx == 0 and dy == 0:
319
+ return fragments
320
+ out = []
321
+ for fragment in fragments:
322
+ points = tuple((point[0] + dx, point[1] + dy) for point in fragment.points)
323
+ bbox = (
324
+ fragment.bbox[0] + dx,
325
+ fragment.bbox[1] + dy,
326
+ fragment.bbox[2] + dx,
327
+ fragment.bbox[3] + dy,
328
+ )
329
+ out.append(DiffFragment(points=points, bbox=bbox, twice_area=fragment.twice_area))
330
+ return tuple(out)
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class _ComponentSideStats:
335
+ boundary_edge_count: int
336
+ loop_count: int
337
+ dropped_zero_area_loop_count: int
338
+ gpu_loop_component_count: int = 0
339
+ gpu_loop_edge_count: int = 0
340
+ cuda_loop_component_count: int = 0
341
+ cuda_loop_edge_count: int = 0
342
+ metal_loop_component_count: int = 0
343
+ metal_loop_edge_count: int = 0
344
+ native_loop_component_count: int = 0
345
+ native_loop_edge_count: int = 0
346
+ gpu_boundary_component_count: int = 0
347
+ gpu_boundary_edge_count: int = 0
348
+ gpu_split_free_component_count: int = 0
349
+ gpu_mixed_boundary_component_count: int = 0
350
+ gpu_mixed_boundary_edge_count: int = 0
351
+ nested_direction_fragment_component_count: int = 0
352
+
353
+
354
+ def _component_side_diff(component: PolygonComponent, *, direction: str) -> tuple[tuple[DiffFragment, ...], _ComponentSideStats]:
355
+ if direction not in {"old", "new"}:
356
+ raise ValueError(f"unknown diff direction: {direction!r}")
357
+ target_fragments = tuple(polygon.fragment for polygon in component.polygons if polygon.side == direction)
358
+ opposite = tuple(polygon for polygon in component.polygons if polygon.side != direction)
359
+ if len(target_fragments) == 1 and not opposite:
360
+ return target_fragments, _ComponentSideStats(0, 1, 0)
361
+ if target_fragments and not opposite:
362
+ return _one_sided_union_diff(target_fragments)
363
+ polygons_by_side = {
364
+ "old": tuple(polygon.fragment.points for polygon in component.polygons if polygon.side == "old"),
365
+ "new": tuple(polygon.fragment.points for polygon in component.polygons if polygon.side == "new"),
366
+ }
367
+ bboxes_by_side = {
368
+ side: tuple(polygon_bbox(points) for points in polygons)
369
+ for side, polygons in polygons_by_side.items()
370
+ }
371
+ if not polygons_by_side[direction]:
372
+ return (), _ComponentSideStats(0, 0, 0)
373
+ segments = _build_segments(polygons_by_side)
374
+ if not segments:
375
+ return (), _ComponentSideStats(0, 0, 0)
376
+ splits = _segment_splits(segments)
377
+ boundary_edges, used_gpu = _maybe_cuda_boundary_edges_for_direction(
378
+ segments,
379
+ splits,
380
+ polygons_by_side,
381
+ bboxes_by_side,
382
+ direction=direction,
383
+ )
384
+ fragments, dropped, loop_backend = _fragments_from_boundary_edges(boundary_edges)
385
+ return (
386
+ tuple(sorted(fragments, key=_fragment_sort_key)),
387
+ _ComponentSideStats(
388
+ boundary_edge_count=len(boundary_edges),
389
+ loop_count=len(fragments),
390
+ dropped_zero_area_loop_count=dropped,
391
+ gpu_loop_component_count=1 if loop_backend in {"cuda", "metal"} else 0,
392
+ gpu_loop_edge_count=len(boundary_edges) if loop_backend in {"cuda", "metal"} else 0,
393
+ cuda_loop_component_count=1 if loop_backend == "cuda" else 0,
394
+ cuda_loop_edge_count=len(boundary_edges) if loop_backend == "cuda" else 0,
395
+ metal_loop_component_count=1 if loop_backend == "metal" else 0,
396
+ metal_loop_edge_count=len(boundary_edges) if loop_backend == "metal" else 0,
397
+ native_loop_component_count=1 if loop_backend == "native" else 0,
398
+ native_loop_edge_count=len(boundary_edges) if loop_backend == "native" else 0,
399
+ gpu_boundary_component_count=1 if used_gpu else 0,
400
+ gpu_boundary_edge_count=len(boundary_edges) if used_gpu else 0,
401
+ gpu_mixed_boundary_component_count=1 if used_gpu else 0,
402
+ gpu_mixed_boundary_edge_count=len(boundary_edges) if used_gpu else 0,
403
+ nested_direction_fragment_component_count=1 if _fragments_have_nested_bboxes(fragments) else 0,
404
+ ),
405
+ )
406
+
407
+
408
+ def _one_sided_union_diff(target_fragments: tuple[DiffFragment, ...]) -> tuple[tuple[DiffFragment, ...], _ComponentSideStats]:
409
+ polygons = tuple(fragment.points for fragment in target_fragments)
410
+ bboxes = tuple(fragment.bbox for fragment in target_fragments)
411
+ segments = _build_segments({"target": polygons})
412
+ if not segments:
413
+ return (), _ComponentSideStats(0, 0, 0)
414
+ gpu_split_free = False
415
+ boundary_edges, used_gpu = _maybe_cuda_unsplit_boundary_edges_for_one_sided_union(segments, polygons, bboxes)
416
+ if boundary_edges is not None:
417
+ gpu_split_free = True
418
+ else:
419
+ splits = _segment_splits(segments)
420
+ boundary_edges, used_gpu = _maybe_cuda_boundary_edges_for_one_sided_union(segments, splits, polygons, bboxes)
421
+ fragments, dropped, loop_backend = _fragments_from_boundary_edges(boundary_edges)
422
+ return (
423
+ tuple(sorted(fragments, key=_fragment_sort_key)),
424
+ _ComponentSideStats(
425
+ boundary_edge_count=len(boundary_edges),
426
+ loop_count=len(fragments),
427
+ dropped_zero_area_loop_count=dropped,
428
+ gpu_loop_component_count=1 if loop_backend in {"cuda", "metal"} else 0,
429
+ gpu_loop_edge_count=len(boundary_edges) if loop_backend in {"cuda", "metal"} else 0,
430
+ cuda_loop_component_count=1 if loop_backend == "cuda" else 0,
431
+ cuda_loop_edge_count=len(boundary_edges) if loop_backend == "cuda" else 0,
432
+ metal_loop_component_count=1 if loop_backend == "metal" else 0,
433
+ metal_loop_edge_count=len(boundary_edges) if loop_backend == "metal" else 0,
434
+ native_loop_component_count=1 if loop_backend == "native" else 0,
435
+ native_loop_edge_count=len(boundary_edges) if loop_backend == "native" else 0,
436
+ gpu_boundary_component_count=1 if used_gpu else 0,
437
+ gpu_boundary_edge_count=len(boundary_edges) if used_gpu else 0,
438
+ gpu_split_free_component_count=1 if gpu_split_free else 0,
439
+ nested_direction_fragment_component_count=1 if _fragments_have_nested_bboxes(fragments) else 0,
440
+ ),
441
+ )
442
+
443
+
444
+ def _fragments_have_nested_bboxes(fragments: Sequence[DiffFragment]) -> bool:
445
+ bboxes = tuple(fragment.bbox for fragment in fragments)
446
+ for index, outer in enumerate(bboxes):
447
+ for inner_index, inner in enumerate(bboxes):
448
+ if index != inner_index and _bbox_strictly_contains(outer, inner):
449
+ return True
450
+ return False
451
+
452
+
453
+ def _bbox_strictly_contains(
454
+ outer: tuple[int, int, int, int],
455
+ inner: tuple[int, int, int, int],
456
+ ) -> bool:
457
+ return (
458
+ outer[0] <= inner[0]
459
+ and outer[1] <= inner[1]
460
+ and outer[2] >= inner[2]
461
+ and outer[3] >= inner[3]
462
+ and outer != inner
463
+ )
464
+
465
+
466
+ def _maybe_cuda_unsplit_boundary_edges_for_one_sided_union(
467
+ segments: tuple[_SegmentRecord, ...],
468
+ polygons: tuple[tuple[Point, ...], ...],
469
+ bboxes: tuple[tuple[int, int, int, int], ...],
470
+ ) -> tuple[tuple[FloatSegment, ...] | None, bool]:
471
+ if len(segments) < 256 or len(polygons) < 4:
472
+ return None, False
473
+ try:
474
+ edges, safe = _cuda_unsplit_boundary_edges_for_one_sided_union(segments, polygons, bboxes)
475
+ except Exception:
476
+ return None, False
477
+ if not safe:
478
+ return None, False
479
+ return edges, True
480
+
481
+
482
+ def _cuda_unsplit_boundary_edges_for_one_sided_union(
483
+ segments: tuple[_SegmentRecord, ...],
484
+ polygons: tuple[tuple[Point, ...], ...],
485
+ bboxes: tuple[tuple[int, int, int, int], ...],
486
+ ) -> tuple[tuple[FloatSegment, ...], bool]:
487
+ from .backends.cuda_ctypes import run_cuda_one_sided_boundary_edges_unsplit_if_safe
488
+
489
+ vertices, offsets, signed_areas, segment_points, segment_polygon_indices = _one_sided_cuda_arrays(
490
+ segments,
491
+ polygons,
492
+ )
493
+ edges, safe = run_cuda_one_sided_boundary_edges_unsplit_if_safe(
494
+ vertices,
495
+ offsets,
496
+ np.asarray(bboxes, dtype=np.int64),
497
+ signed_areas,
498
+ segment_points,
499
+ segment_polygon_indices,
500
+ )
501
+ return tuple(sorted(((_point_key((edge[0][0], edge[0][1])), _point_key((edge[1][0], edge[1][1]))) for edge in edges))), safe
502
+
503
+
504
+ def _maybe_cuda_boundary_edges_for_one_sided_union(
505
+ segments: tuple[_SegmentRecord, ...],
506
+ splits: tuple[tuple[float, ...], ...],
507
+ polygons: tuple[tuple[Point, ...], ...],
508
+ bboxes: tuple[tuple[int, int, int, int], ...],
509
+ ) -> tuple[tuple[FloatSegment, ...], bool]:
510
+ piece_count = sum(1 for split in splits for left, right in zip(split, split[1:]) if right - left > 1e-10)
511
+ if piece_count < 256 or len(polygons) < 4:
512
+ return _boundary_edges_for_one_sided_union(segments, splits, polygons, bboxes), False
513
+ try:
514
+ return _cuda_boundary_edges_for_one_sided_union(segments, splits, polygons, bboxes), True
515
+ except Exception:
516
+ pass
517
+ # Keep Metal boundary sampling as a direct backend experiment for now. The
518
+ # production source-edge path uses the CPU reference sampler for parity on
519
+ # compact, overlapping undercut geometry.
520
+ return _boundary_edges_for_one_sided_union(segments, splits, polygons, bboxes), False
521
+
522
+
523
+ def _maybe_cuda_boundary_edges_for_direction(
524
+ segments: tuple[_SegmentRecord, ...],
525
+ splits: tuple[tuple[float, ...], ...],
526
+ polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]],
527
+ bboxes_by_side: dict[str, tuple[tuple[int, int, int, int], ...]],
528
+ *,
529
+ direction: str,
530
+ ) -> tuple[tuple[FloatSegment, ...], bool]:
531
+ piece_count = sum(1 for split in splits for left, right in zip(split, split[1:]) if right - left > 1e-10)
532
+ if piece_count < 128 or not polygons_by_side["old"] or not polygons_by_side["new"]:
533
+ return _boundary_edges_for_direction(segments, splits, polygons_by_side, bboxes_by_side, direction=direction), False
534
+ try:
535
+ return _cuda_boundary_edges_for_direction(segments, splits, polygons_by_side, bboxes_by_side, direction=direction), True
536
+ except Exception:
537
+ pass
538
+ # Keep Metal boundary sampling as a direct backend experiment for now. The
539
+ # production source-edge path uses the CPU reference sampler for parity on
540
+ # compact, overlapping undercut geometry.
541
+ return _boundary_edges_for_direction(segments, splits, polygons_by_side, bboxes_by_side, direction=direction), False
542
+
543
+
544
+ def _cuda_boundary_edges_for_direction(
545
+ segments: tuple[_SegmentRecord, ...],
546
+ splits: tuple[tuple[float, ...], ...],
547
+ polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]],
548
+ bboxes_by_side: dict[str, tuple[tuple[int, int, int, int], ...]],
549
+ *,
550
+ direction: str,
551
+ ) -> tuple[FloatSegment, ...]:
552
+ from .backends.cuda_ctypes import run_cuda_mixed_boundary_edges
553
+
554
+ vertices, offsets, bboxes, polygon_sides = _mixed_cuda_polygon_arrays(polygons_by_side, bboxes_by_side)
555
+ piece_rows: list[tuple[float, float, float, float]] = []
556
+ for segment_index, segment in enumerate(segments):
557
+ for left_t, right_t in zip(splits[segment_index], splits[segment_index][1:]):
558
+ if right_t - left_t <= 1e-10:
559
+ continue
560
+ start = _point_at(segment, left_t)
561
+ stop = _point_at(segment, right_t)
562
+ piece_rows.append((start[0], start[1], stop[0], stop[1]))
563
+ if not piece_rows:
564
+ return ()
565
+ edges = run_cuda_mixed_boundary_edges(
566
+ vertices,
567
+ offsets,
568
+ bboxes,
569
+ polygon_sides,
570
+ np.asarray(piece_rows, dtype=np.float64),
571
+ direction=direction,
572
+ )
573
+ return tuple(sorted(((_point_key((edge[0][0], edge[0][1])), _point_key((edge[1][0], edge[1][1]))) for edge in edges)))
574
+
575
+
576
+ def _metal_boundary_edges_for_direction(
577
+ segments: tuple[_SegmentRecord, ...],
578
+ splits: tuple[tuple[float, ...], ...],
579
+ polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]],
580
+ bboxes_by_side: dict[str, tuple[tuple[int, int, int, int], ...]],
581
+ *,
582
+ direction: str,
583
+ ) -> tuple[FloatSegment, ...]:
584
+ from .backends.metal_ctypes import run_metal_mixed_boundary_edges
585
+
586
+ vertices, offsets, bboxes, polygon_sides = _mixed_cuda_polygon_arrays(polygons_by_side, bboxes_by_side)
587
+ piece_rows: list[tuple[float, float, float, float]] = []
588
+ for segment_index, segment in enumerate(segments):
589
+ for left_t, right_t in zip(splits[segment_index], splits[segment_index][1:]):
590
+ if right_t - left_t <= 1e-10:
591
+ continue
592
+ start = _point_at(segment, left_t)
593
+ stop = _point_at(segment, right_t)
594
+ piece_rows.append((start[0], start[1], stop[0], stop[1]))
595
+ if not piece_rows:
596
+ return ()
597
+ edges = run_metal_mixed_boundary_edges(
598
+ vertices,
599
+ offsets,
600
+ bboxes,
601
+ polygon_sides,
602
+ np.asarray(piece_rows, dtype=np.float64),
603
+ direction=direction,
604
+ )
605
+ return tuple(sorted(((_point_key((edge[0][0], edge[0][1])), _point_key((edge[1][0], edge[1][1]))) for edge in edges)))
606
+
607
+
608
+ def _mixed_cuda_polygon_arrays(
609
+ polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]],
610
+ bboxes_by_side: dict[str, tuple[tuple[int, int, int, int], ...]],
611
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
612
+ vertex_rows: list[Point] = []
613
+ offsets = [0]
614
+ bboxes: list[tuple[int, int, int, int]] = []
615
+ sides: list[int] = []
616
+ for side, side_id in (("old", 1), ("new", 2)):
617
+ for polygon, bbox in zip(polygons_by_side[side], bboxes_by_side[side]):
618
+ vertex_rows.extend(polygon)
619
+ offsets.append(len(vertex_rows))
620
+ bboxes.append(bbox)
621
+ sides.append(side_id)
622
+ return (
623
+ np.asarray(vertex_rows, dtype=np.int64),
624
+ np.asarray(offsets, dtype=np.uint64),
625
+ np.asarray(bboxes, dtype=np.int64),
626
+ np.asarray(sides, dtype=np.uint8),
627
+ )
628
+
629
+
630
+ def _cuda_boundary_edges_for_one_sided_union(
631
+ segments: tuple[_SegmentRecord, ...],
632
+ splits: tuple[tuple[float, ...], ...],
633
+ polygons: tuple[tuple[Point, ...], ...],
634
+ bboxes: tuple[tuple[int, int, int, int], ...],
635
+ ) -> tuple[FloatSegment, ...]:
636
+ from .backends.cuda_ctypes import run_cuda_one_sided_boundary_edges
637
+
638
+ piece_rows: list[tuple[float, float, float, float]] = []
639
+ piece_polygon_indices: list[int] = []
640
+ for segment_index, segment in enumerate(segments):
641
+ for left_t, right_t in zip(splits[segment_index], splits[segment_index][1:]):
642
+ if right_t - left_t <= 1e-10:
643
+ continue
644
+ start = _point_at(segment, left_t)
645
+ stop = _point_at(segment, right_t)
646
+ piece_rows.append((start[0], start[1], stop[0], stop[1]))
647
+ piece_polygon_indices.append(segment.polygon_index)
648
+ if not piece_rows:
649
+ return ()
650
+ vertices, offsets, signed_areas, _segment_points, _segment_polygon_indices = _one_sided_cuda_arrays(
651
+ (),
652
+ polygons,
653
+ )
654
+ edges = run_cuda_one_sided_boundary_edges(
655
+ vertices,
656
+ offsets,
657
+ np.asarray(bboxes, dtype=np.int64),
658
+ signed_areas,
659
+ np.asarray(piece_rows, dtype=np.float64),
660
+ np.asarray(piece_polygon_indices, dtype=np.uint32),
661
+ )
662
+ return tuple(sorted(((_point_key((edge[0][0], edge[0][1])), _point_key((edge[1][0], edge[1][1]))) for edge in edges)))
663
+
664
+
665
+ def _metal_boundary_edges_for_one_sided_union(
666
+ segments: tuple[_SegmentRecord, ...],
667
+ splits: tuple[tuple[float, ...], ...],
668
+ polygons: tuple[tuple[Point, ...], ...],
669
+ bboxes: tuple[tuple[int, int, int, int], ...],
670
+ ) -> tuple[FloatSegment, ...]:
671
+ from .backends.metal_ctypes import run_metal_one_sided_boundary_edges
672
+
673
+ piece_rows: list[tuple[float, float, float, float]] = []
674
+ piece_polygon_indices: list[int] = []
675
+ for segment_index, segment in enumerate(segments):
676
+ for left_t, right_t in zip(splits[segment_index], splits[segment_index][1:]):
677
+ if right_t - left_t <= 1e-10:
678
+ continue
679
+ start = _point_at(segment, left_t)
680
+ stop = _point_at(segment, right_t)
681
+ piece_rows.append((start[0], start[1], stop[0], stop[1]))
682
+ piece_polygon_indices.append(segment.polygon_index)
683
+ if not piece_rows:
684
+ return ()
685
+ vertices, offsets, signed_areas, _segment_points, _segment_polygon_indices = _one_sided_cuda_arrays(
686
+ (),
687
+ polygons,
688
+ )
689
+ edges = run_metal_one_sided_boundary_edges(
690
+ vertices,
691
+ offsets,
692
+ np.asarray(bboxes, dtype=np.int64),
693
+ signed_areas,
694
+ np.asarray(piece_rows, dtype=np.float64),
695
+ np.asarray(piece_polygon_indices, dtype=np.uint32),
696
+ )
697
+ return tuple(sorted(((_point_key((edge[0][0], edge[0][1])), _point_key((edge[1][0], edge[1][1]))) for edge in edges)))
698
+
699
+
700
+ def _one_sided_cuda_arrays(
701
+ segments: tuple[_SegmentRecord, ...],
702
+ polygons: tuple[tuple[Point, ...], ...],
703
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
704
+ vertex_rows: list[Point] = []
705
+ offsets = [0]
706
+ signed_areas: list[int] = []
707
+ for polygon in polygons:
708
+ vertex_rows.extend(polygon)
709
+ offsets.append(len(vertex_rows))
710
+ signed_areas.append(_signed_twice_area(polygon))
711
+ segment_rows = tuple((segment.start[0], segment.start[1], segment.stop[0], segment.stop[1]) for segment in segments)
712
+ segment_polygon_indices = tuple(segment.polygon_index for segment in segments)
713
+ return (
714
+ np.asarray(vertex_rows, dtype=np.int64),
715
+ np.asarray(offsets, dtype=np.uint64),
716
+ np.asarray(signed_areas, dtype=np.int64),
717
+ np.asarray(segment_rows, dtype=np.float64).reshape((-1, 4)),
718
+ np.asarray(segment_polygon_indices, dtype=np.uint32),
719
+ )
720
+
721
+
722
+ def _fragments_from_boundary_edges(boundary_edges: tuple[FloatSegment, ...]) -> tuple[list[DiffFragment], int, str]:
723
+ integer_edges = _integer_edges_array_if_exact(boundary_edges)
724
+ if integer_edges is not None:
725
+ cuda = _try_cuda_fragments_from_integer_boundary_edges(integer_edges)
726
+ if cuda is not None:
727
+ return list(cuda.fragments), cuda.dropped_zero_area_loop_count, "cuda"
728
+ metal = _try_metal_fragments_from_integer_boundary_edges(integer_edges)
729
+ if metal is not None:
730
+ return list(metal.fragments), metal.dropped_zero_area_loop_count, "metal"
731
+ native = _try_native_fragments_from_integer_boundary_edges(integer_edges)
732
+ if native is not None:
733
+ return list(native.fragments), native.dropped_zero_area_loop_count, "native"
734
+ loops = _trace_loops(boundary_edges)
735
+ fragments: list[DiffFragment] = []
736
+ dropped = 0
737
+ for loop in loops:
738
+ points = _clean_integer_loop(_snap_loop(loop))
739
+ if len(points) < 3:
740
+ dropped += 1
741
+ continue
742
+ twice_area = polygon_twice_area(points)
743
+ if twice_area == 0:
744
+ dropped += 1
745
+ continue
746
+ fragments.append(DiffFragment(points=points, bbox=polygon_bbox(points), twice_area=twice_area))
747
+ return fragments, dropped, "python"
748
+
749
+
750
+ def _integer_edges_array_if_exact(boundary_edges: tuple[FloatSegment, ...]) -> np.ndarray | None:
751
+ rows: list[tuple[int, int, int, int]] = []
752
+ for start, stop in boundary_edges:
753
+ snapped = (_snap_coordinate(start[0]), _snap_coordinate(start[1]), _snap_coordinate(stop[0]), _snap_coordinate(stop[1]))
754
+ if snapped[0] is None or snapped[1] is None or snapped[2] is None or snapped[3] is None:
755
+ return None
756
+ rows.append((snapped[0], snapped[1], snapped[2], snapped[3]))
757
+ return np.asarray(rows, dtype=np.int64)
758
+
759
+
760
+ def _try_cuda_fragments_from_integer_boundary_edges(edges: np.ndarray):
761
+ if len(edges) > 4096:
762
+ return None
763
+ try:
764
+ from .backends.cuda_ctypes import run_cuda_boundary_loops_from_edges
765
+
766
+ return run_cuda_boundary_loops_from_edges(edges)
767
+ except Exception:
768
+ return None
769
+
770
+
771
+ def _try_metal_fragments_from_integer_boundary_edges(edges: np.ndarray):
772
+ # The Metal loop tracer is kept as a direct backend experiment, but source-edge
773
+ # evidence needs the native/Python loop assemblers for parity on compact
774
+ # overlapping undercut geometry.
775
+ return None
776
+
777
+
778
+ def _try_native_fragments_from_integer_boundary_edges(edges: np.ndarray):
779
+ if len(edges) < 64:
780
+ return None
781
+ try:
782
+ from .backends.boundary_loop_ctypes import native_boundary_loops_from_edges
783
+
784
+ return native_boundary_loops_from_edges(edges)
785
+ except Exception:
786
+ return None
787
+
788
+
789
+ def _snap_coordinate(value: float) -> int | None:
790
+ if not isfinite(value):
791
+ return None
792
+ rounded = round(value)
793
+ if abs(value - rounded) > 1e-9:
794
+ return None
795
+ return int(rounded)
796
+
797
+
798
+ def _build_segments(polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]]) -> tuple[_SegmentRecord, ...]:
799
+ segments: list[_SegmentRecord] = []
800
+ for side, polygons in polygons_by_side.items():
801
+ for polygon_index, points in enumerate(polygons):
802
+ for index, point in enumerate(points):
803
+ next_point = points[(index + 1) % len(points)]
804
+ if point == next_point:
805
+ continue
806
+ segments.append(
807
+ _SegmentRecord(
808
+ side=side,
809
+ polygon_index=polygon_index,
810
+ start=(float(point[0]), float(point[1])),
811
+ stop=(float(next_point[0]), float(next_point[1])),
812
+ )
813
+ )
814
+ return tuple(segments)
815
+
816
+
817
+ def _segment_splits(segments: tuple[_SegmentRecord, ...]) -> tuple[tuple[float, ...], ...]:
818
+ splits: list[set[float]] = [{0.0, 1.0} for _ in segments]
819
+ bboxes = tuple(_segment_bbox(segment) for segment in segments)
820
+ ordered_indices = tuple(sorted(range(len(segments)), key=lambda index: (bboxes[index][0], bboxes[index][1], index)))
821
+ for left_order_index, left_index in enumerate(ordered_indices):
822
+ left = segments[left_index]
823
+ left_bbox = bboxes[left_index]
824
+ left_dx = left.stop[0] - left.start[0]
825
+ left_dy = left.stop[1] - left.start[1]
826
+ for right_index in ordered_indices[left_order_index + 1 :]:
827
+ right_bbox = bboxes[right_index]
828
+ if right_bbox[0] > left_bbox[2]:
829
+ break
830
+ if not _float_bbox_contact(left_bbox, right_bbox):
831
+ continue
832
+ right = segments[right_index]
833
+ right_dx = right.stop[0] - right.start[0]
834
+ right_dy = right.stop[1] - right.start[1]
835
+ denom = _cross(left_dx, left_dy, right_dx, right_dy)
836
+ cx = right.start[0] - left.start[0]
837
+ cy = right.start[1] - left.start[1]
838
+ if abs(denom) <= 1e-9:
839
+ if abs(_cross(cx, cy, left_dx, left_dy)) <= 1e-6:
840
+ _add_collinear_splits(splits, left_index, right_index, left, right)
841
+ continue
842
+ left_t = _cross(cx, cy, right_dx, right_dy) / denom
843
+ right_t = _cross(cx, cy, left_dx, left_dy) / denom
844
+ if _within_unit_interval(left_t) and _within_unit_interval(right_t):
845
+ _add_split(splits[left_index], left_t)
846
+ _add_split(splits[right_index], right_t)
847
+ return tuple(tuple(sorted(values)) for values in splits)
848
+
849
+
850
+ def _segment_bbox(segment: _SegmentRecord) -> tuple[float, float, float, float]:
851
+ return (
852
+ min(segment.start[0], segment.stop[0]),
853
+ min(segment.start[1], segment.stop[1]),
854
+ max(segment.start[0], segment.stop[0]),
855
+ max(segment.start[1], segment.stop[1]),
856
+ )
857
+
858
+
859
+ def _float_bbox_contact(left: tuple[float, float, float, float], right: tuple[float, float, float, float]) -> bool:
860
+ return max(left[0], right[0]) <= min(left[2], right[2]) and max(left[1], right[1]) <= min(left[3], right[3])
861
+
862
+
863
+ def _add_collinear_splits(
864
+ splits: list[set[float]],
865
+ left_index: int,
866
+ right_index: int,
867
+ left: _SegmentRecord,
868
+ right: _SegmentRecord,
869
+ ) -> None:
870
+ left_dx = left.stop[0] - left.start[0]
871
+ left_dy = left.stop[1] - left.start[1]
872
+ right_dx = right.stop[0] - right.start[0]
873
+ right_dy = right.stop[1] - right.start[1]
874
+ left_len2 = left_dx * left_dx + left_dy * left_dy
875
+ right_len2 = right_dx * right_dx + right_dy * right_dy
876
+ if left_len2:
877
+ for point in (right.start, right.stop):
878
+ _add_split(splits[left_index], ((point[0] - left.start[0]) * left_dx + (point[1] - left.start[1]) * left_dy) / left_len2)
879
+ if right_len2:
880
+ for point in (left.start, left.stop):
881
+ _add_split(splits[right_index], ((point[0] - right.start[0]) * right_dx + (point[1] - right.start[1]) * right_dy) / right_len2)
882
+
883
+
884
+ def _boundary_edges_for_direction(
885
+ segments: tuple[_SegmentRecord, ...],
886
+ splits: tuple[tuple[float, ...], ...],
887
+ polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]],
888
+ bboxes_by_side: dict[str, tuple[tuple[int, int, int, int], ...]],
889
+ *,
890
+ direction: str,
891
+ ) -> tuple[FloatSegment, ...]:
892
+ counts: dict[FloatSegment, int] = defaultdict(int)
893
+ for segment_index, segment in enumerate(segments):
894
+ dx = segment.stop[0] - segment.start[0]
895
+ dy = segment.stop[1] - segment.start[1]
896
+ length = hypot(dx, dy)
897
+ if length <= 1e-12:
898
+ continue
899
+ normal = (-dy / length, dx / length)
900
+ for left_t, right_t in zip(splits[segment_index], splits[segment_index][1:]):
901
+ if right_t - left_t <= 1e-10:
902
+ continue
903
+ start = _point_at(segment, left_t)
904
+ stop = _point_at(segment, right_t)
905
+ midpoint = ((start[0] + stop[0]) * 0.5, (start[1] + stop[1]) * 0.5)
906
+ left_sample = (midpoint[0] + normal[0] * 1e-5, midpoint[1] + normal[1] * 1e-5)
907
+ right_sample = (midpoint[0] - normal[0] * 1e-5, midpoint[1] - normal[1] * 1e-5)
908
+ left_inside = _target_inside(polygons_by_side, bboxes_by_side, left_sample, direction)
909
+ right_inside = _target_inside(polygons_by_side, bboxes_by_side, right_sample, direction)
910
+ if left_inside and not right_inside:
911
+ _add_boundary_edge(counts, (_point_key(start), _point_key(stop)))
912
+ elif right_inside and not left_inside:
913
+ _add_boundary_edge(counts, (_point_key(stop), _point_key(start)))
914
+ edges: list[FloatSegment] = []
915
+ for edge, count in counts.items():
916
+ edges.extend(edge for _ in range(count))
917
+ return tuple(sorted(edges))
918
+
919
+
920
+ def _boundary_edges_for_one_sided_union(
921
+ segments: tuple[_SegmentRecord, ...],
922
+ splits: tuple[tuple[float, ...], ...],
923
+ polygons: tuple[tuple[Point, ...], ...],
924
+ bboxes: tuple[tuple[int, int, int, int], ...],
925
+ ) -> tuple[FloatSegment, ...]:
926
+ signed_areas = tuple(_signed_twice_area(polygon) for polygon in polygons)
927
+ counts: dict[FloatSegment, int] = defaultdict(int)
928
+ for segment_index, segment in enumerate(segments):
929
+ signed_area = signed_areas[segment.polygon_index]
930
+ if signed_area == 0:
931
+ continue
932
+ dx = segment.stop[0] - segment.start[0]
933
+ dy = segment.stop[1] - segment.start[1]
934
+ length = hypot(dx, dy)
935
+ if length <= 1e-12:
936
+ continue
937
+ normal = (-dy / length, dx / length)
938
+ for left_t, right_t in zip(splits[segment_index], splits[segment_index][1:]):
939
+ if right_t - left_t <= 1e-10:
940
+ continue
941
+ start = _point_at(segment, left_t)
942
+ stop = _point_at(segment, right_t)
943
+ midpoint = ((start[0] + stop[0]) * 0.5, (start[1] + stop[1]) * 0.5)
944
+ if signed_area > 0:
945
+ outside_sample = (midpoint[0] - normal[0] * 1e-5, midpoint[1] - normal[1] * 1e-5)
946
+ if not _union_contains(polygons, bboxes, outside_sample):
947
+ _add_boundary_edge(counts, (_point_key(start), _point_key(stop)))
948
+ else:
949
+ outside_sample = (midpoint[0] + normal[0] * 1e-5, midpoint[1] + normal[1] * 1e-5)
950
+ if not _union_contains(polygons, bboxes, outside_sample):
951
+ _add_boundary_edge(counts, (_point_key(stop), _point_key(start)))
952
+ edges: list[FloatSegment] = []
953
+ for edge, count in counts.items():
954
+ edges.extend(edge for _ in range(count))
955
+ return tuple(sorted(edges))
956
+
957
+
958
+ def _add_boundary_edge(counts: dict[FloatSegment, int], edge: FloatSegment) -> None:
959
+ reverse = (edge[1], edge[0])
960
+ if counts.get(reverse, 0):
961
+ counts[reverse] -= 1
962
+ if counts[reverse] == 0:
963
+ del counts[reverse]
964
+ else:
965
+ counts[edge] += 1
966
+
967
+
968
+ def _trace_loops(edges: tuple[FloatSegment, ...]) -> tuple[tuple[FloatPoint, ...], ...]:
969
+ outgoing: dict[FloatPoint, list[FloatPoint]] = defaultdict(list)
970
+ for start, stop in edges:
971
+ outgoing[start].append(stop)
972
+ for targets in outgoing.values():
973
+ targets.sort()
974
+
975
+ remaining: set[FloatSegment] = set(edges)
976
+ loops: list[tuple[FloatPoint, ...]] = []
977
+ while remaining:
978
+ start, stop = min(remaining)
979
+ remaining.remove((start, stop))
980
+ loop = [start, stop]
981
+ previous = start
982
+ current = stop
983
+ while current != start:
984
+ candidates = [point for point in outgoing.get(current, ()) if (current, point) in remaining]
985
+ if not candidates:
986
+ break
987
+ in_vec = (current[0] - previous[0], current[1] - previous[1])
988
+ next_point = min(
989
+ candidates,
990
+ key=lambda point: (_turn_key(in_vec, (point[0] - current[0], point[1] - current[1])), point),
991
+ )
992
+ remaining.remove((current, next_point))
993
+ loop.append(next_point)
994
+ previous, current = current, next_point
995
+ if loop[-1] == start:
996
+ loop.pop()
997
+ loops.append(tuple(loop))
998
+ return tuple(sorted(loops))
999
+
1000
+
1001
+ def _target_inside(
1002
+ polygons_by_side: dict[str, tuple[tuple[Point, ...], ...]],
1003
+ bboxes_by_side: dict[str, tuple[tuple[int, int, int, int], ...]],
1004
+ point: FloatPoint,
1005
+ direction: str,
1006
+ ) -> bool:
1007
+ old_inside = _union_contains(polygons_by_side["old"], bboxes_by_side["old"], point)
1008
+ new_inside = _union_contains(polygons_by_side["new"], bboxes_by_side["new"], point)
1009
+ if direction == "old":
1010
+ return old_inside and not new_inside
1011
+ return new_inside and not old_inside
1012
+
1013
+
1014
+ def _union_contains(
1015
+ polygons: tuple[tuple[Point, ...], ...],
1016
+ bboxes: tuple[tuple[int, int, int, int], ...],
1017
+ point: FloatPoint,
1018
+ ) -> bool:
1019
+ return any(
1020
+ _point_in_bbox(point, bbox) and _point_in_polygon(point, polygon)
1021
+ for polygon, bbox in zip(polygons, bboxes)
1022
+ )
1023
+
1024
+
1025
+ def _point_in_bbox(point: FloatPoint, bbox: tuple[int, int, int, int]) -> bool:
1026
+ return bbox[0] <= point[0] <= bbox[2] and bbox[1] <= point[1] <= bbox[3]
1027
+
1028
+
1029
+ def _point_in_polygon(point: FloatPoint, polygon: tuple[Point, ...]) -> bool:
1030
+ x, y = point
1031
+ inside = False
1032
+ for index, start in enumerate(polygon):
1033
+ stop = polygon[(index + 1) % len(polygon)]
1034
+ x0, y0 = float(start[0]), float(start[1])
1035
+ x1, y1 = float(stop[0]), float(stop[1])
1036
+ if (y0 > y) != (y1 > y):
1037
+ x_intersection = x0 + (y - y0) * (x1 - x0) / (y1 - y0)
1038
+ if x_intersection > x:
1039
+ inside = not inside
1040
+ return inside
1041
+
1042
+
1043
+ def _signed_twice_area(points: tuple[Point, ...]) -> int:
1044
+ area = 0
1045
+ for index, point in enumerate(points):
1046
+ next_point = points[(index + 1) % len(points)]
1047
+ area += point[0] * next_point[1] - next_point[0] * point[1]
1048
+ return area
1049
+
1050
+
1051
+ def _snap_loop(loop: tuple[FloatPoint, ...]) -> tuple[Point, ...]:
1052
+ points: list[Point] = []
1053
+ for point in loop:
1054
+ if not isfinite(point[0]) or not isfinite(point[1]):
1055
+ continue
1056
+ snapped = (round(point[0]), round(point[1]))
1057
+ if not points or points[-1] != snapped:
1058
+ points.append(snapped)
1059
+ if len(points) > 1 and points[0] == points[-1]:
1060
+ points.pop()
1061
+ return tuple(points)
1062
+
1063
+
1064
+ def _clean_integer_loop(points: tuple[Point, ...]) -> tuple[Point, ...]:
1065
+ cleaned = list(points)
1066
+ changed = True
1067
+ while changed and len(cleaned) >= 3:
1068
+ changed = False
1069
+ next_points: list[Point] = []
1070
+ for index, point in enumerate(cleaned):
1071
+ previous = cleaned[index - 1]
1072
+ following = cleaned[(index + 1) % len(cleaned)]
1073
+ if _collinear(previous, point, following):
1074
+ changed = True
1075
+ continue
1076
+ next_points.append(point)
1077
+ cleaned = next_points
1078
+ return tuple(cleaned)
1079
+
1080
+
1081
+ def _point_at(segment: _SegmentRecord, t: float) -> FloatPoint:
1082
+ return (
1083
+ segment.start[0] + (segment.stop[0] - segment.start[0]) * t,
1084
+ segment.start[1] + (segment.stop[1] - segment.start[1]) * t,
1085
+ )
1086
+
1087
+
1088
+ def _point_key(point: FloatPoint) -> FloatPoint:
1089
+ return (round(point[0], 9), round(point[1], 9))
1090
+
1091
+
1092
+ def _add_split(splits: set[float], value: float) -> None:
1093
+ if _within_unit_interval(value):
1094
+ splits.add(min(1.0, max(0.0, value)))
1095
+
1096
+
1097
+ def _within_unit_interval(value: float) -> bool:
1098
+ return -1e-9 <= value <= 1.0 + 1e-9
1099
+
1100
+
1101
+ def _cross(left_x: float, left_y: float, right_x: float, right_y: float) -> float:
1102
+ return left_x * right_y - left_y * right_x
1103
+
1104
+
1105
+ def _turn_key(in_vec: FloatPoint, out_vec: FloatPoint) -> tuple[int, float, float]:
1106
+ cross = in_vec[0] * out_vec[1] - in_vec[1] * out_vec[0]
1107
+ dot = in_vec[0] * out_vec[0] + in_vec[1] * out_vec[1]
1108
+ if cross < 0:
1109
+ turn_class = 0
1110
+ elif cross == 0 and dot > 0:
1111
+ turn_class = 1
1112
+ elif cross > 0:
1113
+ turn_class = 2
1114
+ else:
1115
+ turn_class = 3
1116
+ return turn_class, -abs(cross), -dot
1117
+
1118
+
1119
+ def _collinear(left: Point, middle: Point, right: Point) -> bool:
1120
+ return (middle[0] - left[0]) * (right[1] - middle[1]) == (middle[1] - left[1]) * (right[0] - middle[0])