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
gdsdiff/tiling.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from .config import LayerSpec
6
+ from .geometry import PolygonBuffer
7
+
8
+
9
+ OLD_SIDE = 1
10
+ NEW_SIDE = 2
11
+ BOTH_SIDES = OLD_SIDE | NEW_SIDE
12
+
13
+
14
+ @dataclass(frozen=True, order=True)
15
+ class TileKey:
16
+ layer: LayerSpec
17
+ tx: int
18
+ ty: int
19
+
20
+ def to_json(self) -> dict[str, int]:
21
+ return {
22
+ "layer": self.layer.layer,
23
+ "datatype": self.layer.datatype,
24
+ "tx": self.tx,
25
+ "ty": self.ty,
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class TileConfig:
31
+ tile_size: int
32
+ max_tiles_per_polygon: int = 4096
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.tile_size <= 0:
36
+ raise ValueError("tile_size must be positive")
37
+ if self.max_tiles_per_polygon <= 0:
38
+ raise ValueError("max_tiles_per_polygon must be positive")
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class OversizedPolygon:
43
+ layer: LayerSpec
44
+ canonical_id: int
45
+ side_mask: int
46
+ tile_bbox: tuple[int, int, int, int]
47
+
48
+ def to_json(self) -> dict[str, object]:
49
+ return {
50
+ "layer": self.layer.to_json(),
51
+ "canonical_id": self.canonical_id,
52
+ "side_mask": self.side_mask,
53
+ "tile_bbox": list(self.tile_bbox),
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CpuTilePrefilterResult:
59
+ candidate_tiles: tuple[TileKey, ...]
60
+ oversized_candidates: tuple[OversizedPolygon, ...] = field(default_factory=tuple)
61
+ assignment_count: int = 0
62
+ collapsed_pair_count: int = 0
63
+ oversized_matched_count: int = 0
64
+
65
+ @property
66
+ def candidate_tile_count(self) -> int:
67
+ return len(self.candidate_tiles)
68
+
69
+ def to_json(self) -> dict[str, object]:
70
+ return {
71
+ "candidate_tiles": [tile.to_json() for tile in self.candidate_tiles],
72
+ "candidate_tile_count": len(self.candidate_tiles),
73
+ "oversized_candidates": [candidate.to_json() for candidate in self.oversized_candidates],
74
+ "assignment_count": self.assignment_count,
75
+ "collapsed_pair_count": self.collapsed_pair_count,
76
+ "oversized_matched_count": self.oversized_matched_count,
77
+ }
78
+
79
+
80
+ def floor_div(value: int, divisor: int) -> int:
81
+ if divisor <= 0:
82
+ raise ValueError("divisor must be positive")
83
+ return value // divisor
84
+
85
+
86
+ def bbox_tile_range(bbox: tuple[int, int, int, int], tile_size: int) -> tuple[int, int, int, int]:
87
+ if tile_size <= 0:
88
+ raise ValueError("tile_size must be positive")
89
+ x0, y0, x1, y1 = bbox
90
+ return (
91
+ floor_div(x0, tile_size),
92
+ floor_div(y0, tile_size),
93
+ floor_div(x1, tile_size),
94
+ floor_div(y1, tile_size),
95
+ )
96
+
97
+
98
+ def run_cpu_tile_prefilter(
99
+ old_buffer: PolygonBuffer,
100
+ old_canonical_ids,
101
+ new_buffer: PolygonBuffer,
102
+ new_canonical_ids,
103
+ config: TileConfig,
104
+ ) -> CpuTilePrefilterResult:
105
+ pair_side_masks: dict[tuple[TileKey, int], int] = {}
106
+ oversized_side_masks: dict[tuple[LayerSpec, int, tuple[int, int, int, int]], int] = {}
107
+ assignment_count = 0
108
+ oversized_matched_count = 0
109
+
110
+ for buffer, ids, side in ((old_buffer, old_canonical_ids, OLD_SIDE), (new_buffer, new_canonical_ids, NEW_SIDE)):
111
+ if len(ids) != buffer.polygon_count:
112
+ raise ValueError("canonical id vector length must match polygon count")
113
+ for index in range(buffer.polygon_count):
114
+ layer = buffer.layer_table.layers[int(buffer.layer_ids[index])]
115
+ canonical_id = int(ids[index])
116
+ tile_bbox = bbox_tile_range(tuple(int(value) for value in buffer.bboxes[index]), config.tile_size)
117
+ tx0, ty0, tx1, ty1 = tile_bbox
118
+ span_x = tx1 - tx0 + 1
119
+ span_y = ty1 - ty0 + 1
120
+ if span_x <= 0 or span_y <= 0:
121
+ raise ValueError("invalid tile span")
122
+ count = span_x * span_y
123
+ if count > config.max_tiles_per_polygon:
124
+ key = (layer, canonical_id, tile_bbox)
125
+ oversized_side_masks[key] = oversized_side_masks.get(key, 0) | side
126
+ continue
127
+ for tx in range(tx0, tx1 + 1):
128
+ for ty in range(ty0, ty1 + 1):
129
+ key = (TileKey(layer, tx, ty), canonical_id)
130
+ pair_side_masks[key] = pair_side_masks.get(key, 0) | side
131
+ assignment_count += 1
132
+
133
+ candidate_tiles = {
134
+ tile_key
135
+ for (tile_key, _canonical_id), side_mask in pair_side_masks.items()
136
+ if side_mask != BOTH_SIDES
137
+ }
138
+ oversized_candidates = []
139
+ for (layer, canonical_id, tile_bbox), side_mask in oversized_side_masks.items():
140
+ if side_mask == BOTH_SIDES:
141
+ oversized_matched_count += 1
142
+ else:
143
+ oversized_candidates.append(
144
+ OversizedPolygon(layer=layer, canonical_id=canonical_id, side_mask=side_mask, tile_bbox=tile_bbox)
145
+ )
146
+
147
+ return CpuTilePrefilterResult(
148
+ candidate_tiles=tuple(sorted(candidate_tiles)),
149
+ oversized_candidates=tuple(sorted(oversized_candidates, key=lambda item: (item.layer, item.canonical_id, item.tile_bbox))),
150
+ assignment_count=assignment_count,
151
+ collapsed_pair_count=len(pair_side_masks),
152
+ oversized_matched_count=oversized_matched_count,
153
+ )
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+
6
+ import gdstk
7
+
8
+ from .config import LayerSpec
9
+ from .exact_oracle import DiffFragment, ExactOracleResult, LayerExactDiff, run_full_exact_oracle
10
+ from .geometry import PolygonBuffer, polygon_bbox, polygon_twice_area
11
+ from .grid import snap_to_grid
12
+ from .multiprocessing_support import fork_context
13
+ from .spatial import BBox, SpatialIndex
14
+ from .windows import CandidateWindow
15
+
16
+ _WORKER_OLD_BUFFER = None
17
+ _WORKER_NEW_BUFFER = None
18
+ _WORKER_PRECISION = 1.0
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WindowedExactFailure:
23
+ window: CandidateWindow
24
+ reason: str
25
+
26
+ def to_json(self) -> dict[str, object]:
27
+ return {"window": self.window.to_json(), "reason": self.reason}
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class WindowedExactResult:
32
+ layers: tuple[LayerExactDiff, ...]
33
+ escalated_layers: tuple[LayerSpec, ...] = field(default_factory=tuple)
34
+ failures: tuple[WindowedExactFailure, ...] = field(default_factory=tuple)
35
+
36
+ @property
37
+ def equivalent(self) -> bool:
38
+ return all(layer.xor_twice_area == 0 for layer in self.layers)
39
+
40
+ @property
41
+ def exact_result(self) -> ExactOracleResult:
42
+ return ExactOracleResult(self.layers)
43
+
44
+
45
+ def run_windowed_exact_oracle(
46
+ old_buffer: PolygonBuffer,
47
+ new_buffer: PolygonBuffer,
48
+ windows: tuple[CandidateWindow, ...] | list[CandidateWindow],
49
+ *,
50
+ old_index: SpatialIndex | None = None,
51
+ new_index: SpatialIndex | None = None,
52
+ precision: float = 1.0,
53
+ max_polygons_per_window: int = 200_000,
54
+ ) -> WindowedExactResult:
55
+ old_index = old_index or SpatialIndex.from_buffer(old_buffer)
56
+ new_index = new_index or SpatialIndex.from_buffer(new_buffer)
57
+
58
+ layers_from_windows = tuple(sorted({window.layer for window in windows}))
59
+ direction_points: dict[LayerSpec, dict[str, list[tuple[tuple[int, int], ...]]]] = {
60
+ layer: {"old_minus_new": [], "new_minus_old": []} for layer in layers_from_windows
61
+ }
62
+ escalated_layers: set[LayerSpec] = set()
63
+ failures: list[WindowedExactFailure] = []
64
+ window_tasks = []
65
+
66
+ for window in windows:
67
+ old_indices = old_index.query(window.layer, window.verification_bbox)
68
+ new_indices = new_index.query(window.layer, window.verification_bbox)
69
+ window_tasks.append((window, old_indices, new_indices, max_polygons_per_window))
70
+
71
+ task_results = _run_window_tasks(old_buffer, new_buffer, window_tasks, precision=precision)
72
+ for window, old_minus, new_minus, failure_reason in task_results:
73
+ if failure_reason is not None:
74
+ escalated_layers.add(window.layer)
75
+ failures.append(WindowedExactFailure(window, failure_reason))
76
+
77
+ for window, old_minus, new_minus, failure_reason in task_results:
78
+ if failure_reason is not None or window.layer in escalated_layers:
79
+ continue
80
+ direction_points[window.layer]["old_minus_new"].extend(old_minus)
81
+ direction_points[window.layer]["new_minus_old"].extend(new_minus)
82
+
83
+ full_layer_diffs = _full_diffs_by_layer(old_buffer, new_buffer, precision=precision) if escalated_layers else {}
84
+ layer_diffs: list[LayerExactDiff] = []
85
+ for layer in layers_from_windows:
86
+ if layer in escalated_layers:
87
+ layer_diffs.append(
88
+ full_layer_diffs.get(
89
+ layer,
90
+ LayerExactDiff(layer=layer),
91
+ )
92
+ )
93
+ continue
94
+ old_minus = _union_fragments(direction_points[layer]["old_minus_new"], layer, precision=precision)
95
+ new_minus = _union_fragments(direction_points[layer]["new_minus_old"], layer, precision=precision)
96
+ xor = _union_fragments([fragment.points for fragment in old_minus] + [fragment.points for fragment in new_minus], layer, precision=precision)
97
+ layer_diffs.append(
98
+ LayerExactDiff(
99
+ layer=layer,
100
+ old_minus_new=old_minus,
101
+ new_minus_old=new_minus,
102
+ xor=xor,
103
+ )
104
+ )
105
+
106
+ return WindowedExactResult(
107
+ layers=tuple(sorted(layer_diffs, key=lambda diff: diff.layer)),
108
+ escalated_layers=tuple(sorted(escalated_layers)),
109
+ failures=tuple(failures),
110
+ )
111
+
112
+
113
+ def _run_window_tasks(
114
+ old_buffer: PolygonBuffer,
115
+ new_buffer: PolygonBuffer,
116
+ window_tasks,
117
+ *,
118
+ precision: float,
119
+ ):
120
+ if not window_tasks:
121
+ return ()
122
+ worker_count = _resolve_window_worker_count(len(window_tasks))
123
+ if worker_count <= 1:
124
+ return tuple(_compute_window_task((old_buffer, new_buffer, precision, *task)) for task in window_tasks)
125
+ context = fork_context()
126
+ if context is None:
127
+ return tuple(_compute_window_task((old_buffer, new_buffer, precision, *task)) for task in window_tasks)
128
+
129
+ global _WORKER_OLD_BUFFER, _WORKER_NEW_BUFFER, _WORKER_PRECISION
130
+ _WORKER_OLD_BUFFER = old_buffer
131
+ _WORKER_NEW_BUFFER = new_buffer
132
+ _WORKER_PRECISION = precision
133
+ try:
134
+ try:
135
+ with context.Pool(processes=worker_count) as pool:
136
+ return tuple(pool.map(_compute_window_task_worker, window_tasks, chunksize=1))
137
+ except OSError:
138
+ return tuple(_compute_window_task((old_buffer, new_buffer, precision, *task)) for task in window_tasks)
139
+ finally:
140
+ _WORKER_OLD_BUFFER = None
141
+ _WORKER_NEW_BUFFER = None
142
+
143
+
144
+ def _resolve_window_worker_count(window_count: int) -> int:
145
+ if window_count <= 1:
146
+ return 1
147
+ return max(1, min(os.cpu_count() or 1, window_count))
148
+
149
+
150
+ def _compute_window_task_worker(task):
151
+ if _WORKER_OLD_BUFFER is None or _WORKER_NEW_BUFFER is None:
152
+ raise RuntimeError("windowed exact worker globals are not initialized")
153
+ return _compute_window_task((_WORKER_OLD_BUFFER, _WORKER_NEW_BUFFER, _WORKER_PRECISION, *task))
154
+
155
+
156
+ def _compute_window_task(task):
157
+ old_buffer, new_buffer, precision, window, old_indices, new_indices, max_polygons_per_window = task
158
+ if len(old_indices) + len(new_indices) > max_polygons_per_window:
159
+ return window, (), (), "window polygon threshold exceeded"
160
+ try:
161
+ old_minus, new_minus = _compute_window_directional_fragments(
162
+ old_buffer,
163
+ old_indices,
164
+ new_buffer,
165
+ new_indices,
166
+ window,
167
+ precision=precision,
168
+ )
169
+ except (RuntimeError, ValueError, TypeError) as exc:
170
+ return window, (), (), f"gdstk boolean failed: {exc}"
171
+ return window, old_minus, new_minus, None
172
+
173
+
174
+ def _compute_window_directional_fragments(
175
+ old_buffer: PolygonBuffer,
176
+ old_indices: tuple[int, ...],
177
+ new_buffer: PolygonBuffer,
178
+ new_indices: tuple[int, ...],
179
+ window: CandidateWindow,
180
+ *,
181
+ precision: float,
182
+ ) -> tuple[tuple[tuple[tuple[int, int], ...], ...], tuple[tuple[tuple[int, int], ...], ...]]:
183
+ origin = window.verification_bbox[0], window.verification_bbox[1]
184
+ old_polys = _buffer_indices_to_gdstk(old_buffer, old_indices, window.layer, origin)
185
+ new_polys = _buffer_indices_to_gdstk(new_buffer, new_indices, window.layer, origin)
186
+ verification_rect = _bbox_rectangle(window.verification_bbox, window.layer, origin)
187
+ core_rect = _bbox_rectangle(window.core_bbox, window.layer, origin)
188
+
189
+ old_clipped = gdstk.boolean(old_polys, [verification_rect], "and", precision=precision) or []
190
+ new_clipped = gdstk.boolean(new_polys, [verification_rect], "and", precision=precision) or []
191
+ old_minus = gdstk.boolean(old_clipped, new_clipped, "not", precision=precision) or []
192
+ new_minus = gdstk.boolean(new_clipped, old_clipped, "not", precision=precision) or []
193
+ old_core = gdstk.boolean(old_minus, [core_rect], "and", precision=precision) or []
194
+ new_core = gdstk.boolean(new_minus, [core_rect], "and", precision=precision) or []
195
+ return _global_points_from_gdstk(old_core, origin), _global_points_from_gdstk(new_core, origin)
196
+
197
+
198
+ def _buffer_indices_to_gdstk(
199
+ buffer: PolygonBuffer,
200
+ indices: tuple[int, ...],
201
+ layer: LayerSpec,
202
+ origin: tuple[int, int],
203
+ ) -> list[gdstk.Polygon]:
204
+ ox, oy = origin
205
+ polygons = []
206
+ for index in indices:
207
+ points = tuple((int(x) - ox, int(y) - oy) for x, y in buffer.polygon_vertices(index))
208
+ if len(points) >= 3:
209
+ polygons.append(gdstk.Polygon(points, layer=layer.layer, datatype=layer.datatype))
210
+ return polygons
211
+
212
+
213
+ def _bbox_rectangle(bbox: BBox, layer: LayerSpec, origin: tuple[int, int]) -> gdstk.Polygon:
214
+ ox, oy = origin
215
+ x0, y0, x1, y1 = bbox
216
+ return gdstk.rectangle(
217
+ (x0 - ox, y0 - oy),
218
+ (x1 + 1 - ox, y1 + 1 - oy),
219
+ layer=layer.layer,
220
+ datatype=layer.datatype,
221
+ )
222
+
223
+
224
+ def _global_points_from_gdstk(polygons: list[gdstk.Polygon], origin: tuple[int, int]) -> tuple[tuple[tuple[int, int], ...], ...]:
225
+ ox, oy = origin
226
+ result = []
227
+ for polygon in polygons:
228
+ points = tuple((snap_to_grid(point[0], 1) + ox, snap_to_grid(point[1], 1) + oy) for point in polygon.points)
229
+ if len(points) >= 3:
230
+ result.append(points)
231
+ return tuple(result)
232
+
233
+
234
+ def _union_fragments(
235
+ polygons: list[tuple[tuple[int, int], ...]],
236
+ layer: LayerSpec,
237
+ *,
238
+ precision: float,
239
+ ) -> tuple[DiffFragment, ...]:
240
+ if not polygons:
241
+ return ()
242
+ origin = _points_origin(polygons)
243
+ ox, oy = origin
244
+ local = [gdstk.Polygon([(x - ox, y - oy) for x, y in points], layer=layer.layer, datatype=layer.datatype) for points in polygons]
245
+ merged = gdstk.boolean(local, [], "or", precision=precision) or []
246
+ return _fragments_from_gdstk(merged, origin)
247
+
248
+
249
+ def _fragments_from_gdstk(polygons: list[gdstk.Polygon], origin: tuple[int, int]) -> tuple[DiffFragment, ...]:
250
+ ox, oy = origin
251
+ fragments = []
252
+ for polygon in polygons:
253
+ points = tuple((snap_to_grid(point[0], 1) + ox, snap_to_grid(point[1], 1) + oy) for point in polygon.points)
254
+ if len(points) < 3:
255
+ continue
256
+ fragments.append(DiffFragment(points=points, bbox=polygon_bbox(points), twice_area=polygon_twice_area(points)))
257
+ return tuple(sorted(fragments, key=lambda fragment: (fragment.bbox, fragment.points)))
258
+
259
+
260
+ def _points_origin(polygons: list[tuple[tuple[int, int], ...]]) -> tuple[int, int]:
261
+ return min(point[0] for polygon in polygons for point in polygon), min(point[1] for polygon in polygons for point in polygon)
262
+
263
+
264
+ def _full_diffs_by_layer(
265
+ old_buffer: PolygonBuffer,
266
+ new_buffer: PolygonBuffer,
267
+ *,
268
+ precision: float,
269
+ ) -> dict[LayerSpec, LayerExactDiff]:
270
+ return {layer_diff.layer: layer_diff for layer_diff in run_full_exact_oracle(old_buffer, new_buffer, precision=precision).layers}
gdsdiff/windows.py ADDED
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .config import LayerSpec
6
+ from .spatial import BBox, SpatialIndex, expand_bbox
7
+ from .tiling import CpuTilePrefilterResult, TileConfig, TileKey
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CandidateWindow:
12
+ layer: LayerSpec
13
+ core_bbox: BBox
14
+ verification_bbox: BBox
15
+ source_candidate_count: int
16
+ estimated_old_polygons: int
17
+ estimated_new_polygons: int
18
+ reason: str = "tile mismatch"
19
+
20
+ def to_json(self) -> dict[str, object]:
21
+ return {
22
+ "layer": self.layer.to_json(),
23
+ "core_bbox": list(self.core_bbox),
24
+ "verification_bbox": list(self.verification_bbox),
25
+ "source_candidate_count": self.source_candidate_count,
26
+ "estimated_old_polygons": self.estimated_old_polygons,
27
+ "estimated_new_polygons": self.estimated_new_polygons,
28
+ "reason": self.reason,
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class WindowConfig:
34
+ halo_dbu: int = 2
35
+ max_tiles_per_window: int = 4096
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.halo_dbu < 0:
39
+ raise ValueError("halo_dbu must be nonnegative")
40
+ if self.max_tiles_per_window <= 0:
41
+ raise ValueError("max_tiles_per_window must be positive")
42
+
43
+
44
+ def build_candidate_windows(
45
+ prefilter: CpuTilePrefilterResult,
46
+ tile_config: TileConfig,
47
+ old_index: SpatialIndex,
48
+ new_index: SpatialIndex,
49
+ window_config: WindowConfig | None = None,
50
+ ) -> tuple[CandidateWindow, ...]:
51
+ window_config = window_config or WindowConfig()
52
+ windows: list[CandidateWindow] = []
53
+ windows.extend(
54
+ _windows_from_tiles(
55
+ prefilter.candidate_tiles,
56
+ tile_config,
57
+ old_index,
58
+ new_index,
59
+ window_config,
60
+ )
61
+ )
62
+ for oversized in prefilter.oversized_candidates:
63
+ tx0, ty0, tx1, ty1 = oversized.tile_bbox
64
+ core = _tile_rect_to_bbox(tx0, ty0, tx1, ty1, tile_config.tile_size)
65
+ windows.append(
66
+ _make_window(
67
+ oversized.layer,
68
+ core,
69
+ source_count=(tx1 - tx0 + 1) * (ty1 - ty0 + 1),
70
+ old_index=old_index,
71
+ new_index=new_index,
72
+ window_config=window_config,
73
+ reason="oversized polygon",
74
+ )
75
+ )
76
+ return tuple(sorted(windows, key=lambda window: (window.layer, window.core_bbox, window.reason)))
77
+
78
+
79
+ def _windows_from_tiles(
80
+ tiles: tuple[TileKey, ...],
81
+ tile_config: TileConfig,
82
+ old_index: SpatialIndex,
83
+ new_index: SpatialIndex,
84
+ window_config: WindowConfig,
85
+ ) -> list[CandidateWindow]:
86
+ grouped: dict[LayerSpec, list[TileKey]] = {}
87
+ for tile in tiles:
88
+ grouped.setdefault(tile.layer, []).append(tile)
89
+ windows: list[CandidateWindow] = []
90
+ for layer, layer_tiles in grouped.items():
91
+ rects = _merge_tiles_to_rectangles(layer_tiles, window_config.max_tiles_per_window)
92
+ for tx0, ty0, tx1, ty1, count in rects:
93
+ core = _tile_rect_to_bbox(tx0, ty0, tx1, ty1, tile_config.tile_size)
94
+ windows.append(
95
+ _make_window(
96
+ layer,
97
+ core,
98
+ source_count=count,
99
+ old_index=old_index,
100
+ new_index=new_index,
101
+ window_config=window_config,
102
+ reason="tile mismatch",
103
+ )
104
+ )
105
+ return windows
106
+
107
+
108
+ def _merge_tiles_to_rectangles(tiles: list[TileKey], max_tiles_per_window: int) -> list[tuple[int, int, int, int, int]]:
109
+ rows: dict[int, list[int]] = {}
110
+ for tile in tiles:
111
+ rows.setdefault(tile.ty, []).append(tile.tx)
112
+ row_runs: list[tuple[int, int, int]] = []
113
+ for ty, txs in rows.items():
114
+ for x0, x1 in _runs(sorted(set(txs))):
115
+ for split_x0, split_x1 in _split_run(x0, x1, max_tiles_per_window):
116
+ row_runs.append((ty, split_x0, split_x1))
117
+ row_runs.sort()
118
+ rectangles: list[tuple[int, int, int, int, int]] = []
119
+ active: dict[tuple[int, int], tuple[int, int, int, int, int]] = {}
120
+ for ty, x0, x1 in row_runs:
121
+ key = (x0, x1)
122
+ prev = active.pop(key, None)
123
+ width = x1 - x0 + 1
124
+ if prev is not None and prev[3] + 1 == ty and prev[4] + width <= max_tiles_per_window:
125
+ active[key] = (x0, prev[1], x1, ty, prev[4] + width)
126
+ else:
127
+ if prev is not None:
128
+ rectangles.append(prev)
129
+ active[key] = (x0, ty, x1, ty, width)
130
+ rectangles.extend(active.values())
131
+ return sorted(rectangles)
132
+
133
+
134
+ def _runs(values: list[int]) -> list[tuple[int, int]]:
135
+ if not values:
136
+ return []
137
+ result = []
138
+ start = previous = values[0]
139
+ for value in values[1:]:
140
+ if value == previous + 1:
141
+ previous = value
142
+ continue
143
+ result.append((start, previous))
144
+ start = previous = value
145
+ result.append((start, previous))
146
+ return result
147
+
148
+
149
+ def _split_run(x0: int, x1: int, max_width: int) -> list[tuple[int, int]]:
150
+ if max_width <= 0:
151
+ raise ValueError("max_width must be positive")
152
+ result = []
153
+ current = x0
154
+ while current <= x1:
155
+ end = min(x1, current + max_width - 1)
156
+ result.append((current, end))
157
+ current = end + 1
158
+ return result
159
+
160
+
161
+ def _tile_rect_to_bbox(tx0: int, ty0: int, tx1: int, ty1: int, tile_size: int) -> BBox:
162
+ return tx0 * tile_size, ty0 * tile_size, (tx1 + 1) * tile_size - 1, (ty1 + 1) * tile_size - 1
163
+
164
+
165
+ def _make_window(
166
+ layer: LayerSpec,
167
+ core: BBox,
168
+ *,
169
+ source_count: int,
170
+ old_index: SpatialIndex,
171
+ new_index: SpatialIndex,
172
+ window_config: WindowConfig,
173
+ reason: str,
174
+ ) -> CandidateWindow:
175
+ verification = expand_bbox(core, window_config.halo_dbu)
176
+ return CandidateWindow(
177
+ layer=layer,
178
+ core_bbox=core,
179
+ verification_bbox=verification,
180
+ source_candidate_count=source_count,
181
+ estimated_old_polygons=len(old_index.query(layer, verification)),
182
+ estimated_new_polygons=len(new_index.query(layer, verification)),
183
+ reason=reason,
184
+ )