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.
- gdsdiff/__init__.py +148 -0
- gdsdiff/__main__.py +7 -0
- gdsdiff/_environment.py +12 -0
- gdsdiff/_version.py +34 -0
- gdsdiff/acceptance_evidence.py +530 -0
- gdsdiff/api.py +2955 -0
- gdsdiff/backends/__init__.py +26 -0
- gdsdiff/backends/base.py +77 -0
- gdsdiff/backends/boundary_loop_ctypes.py +191 -0
- gdsdiff/backends/cpu.py +20 -0
- gdsdiff/backends/cpu_ctypes.py +255 -0
- gdsdiff/backends/cuda.py +40 -0
- gdsdiff/backends/cuda_ctypes.py +1297 -0
- gdsdiff/backends/exact.py +424 -0
- gdsdiff/backends/geometry_ctypes.py +252 -0
- gdsdiff/backends/identity.py +53 -0
- gdsdiff/backends/metal.py +198 -0
- gdsdiff/backends/metal_ctypes.py +866 -0
- gdsdiff/backends/polygon_extract_ctypes.py +233 -0
- gdsdiff/backends/structural_ctypes.py +130 -0
- gdsdiff/boundary_loops.py +616 -0
- gdsdiff/cache.py +544 -0
- gdsdiff/canonicalize.py +176 -0
- gdsdiff/cli.py +352 -0
- gdsdiff/config.py +257 -0
- gdsdiff/cuda_setup.py +174 -0
- gdsdiff/design_history.py +65 -0
- gdsdiff/diagnostics.py +42 -0
- gdsdiff/diff_gds.py +66 -0
- gdsdiff/exact_diff_markdown.py +979 -0
- gdsdiff/exact_oracle.py +649 -0
- gdsdiff/extract.py +376 -0
- gdsdiff/gds_metadata.py +43 -0
- gdsdiff/geometry.py +112 -0
- gdsdiff/grid.py +143 -0
- gdsdiff/hierarchy.py +215 -0
- gdsdiff/hierarchy_extract.py +263 -0
- gdsdiff/inventory.py +153 -0
- gdsdiff/multiprocessing_support.py +39 -0
- gdsdiff/native.py +135 -0
- gdsdiff/native_cache.py +265 -0
- gdsdiff/native_src/cpu_prefilter.cpp +349 -0
- gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
- gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
- gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
- gdsdiff/native_src/gds_structural_scan.cpp +335 -0
- gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
- gdsdiff/native_src/gdsdiff/core.cpp +191 -0
- gdsdiff/native_src/gdsdiff/core.hpp +96 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
- gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
- gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
- gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
- gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
- gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
- gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
- gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
- gdsdiff/policy.py +305 -0
- gdsdiff/polygon_components.py +860 -0
- gdsdiff/profiles.py +152 -0
- gdsdiff/py.typed +1 -0
- gdsdiff/rect_coverage.py +384 -0
- gdsdiff/report.py +354 -0
- gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
- gdsdiff/semantics.py +75 -0
- gdsdiff/source_edge_diff.py +1120 -0
- gdsdiff/spatial.py +71 -0
- gdsdiff/structural_guard.py +161 -0
- gdsdiff/surplus_coverage.py +255 -0
- gdsdiff/synthetic_suite.py +1643 -0
- gdsdiff/templates.py +709 -0
- gdsdiff/tiling.py +153 -0
- gdsdiff/windowed_exact.py +270 -0
- gdsdiff/windows.py +184 -0
- gdsdiff-0.1.0.dist-info/METADATA +135 -0
- gdsdiff-0.1.0.dist-info/RECORD +85 -0
- gdsdiff-0.1.0.dist-info/WHEEL +5 -0
- gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
- gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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
|