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/hierarchy.py ADDED
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass, field
5
+ from typing import Iterable
6
+
7
+ from .config import LayerSpec
8
+ from .geometry import normalize_polygon_points
9
+ from .spatial import BBox, bbox_union
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CellIdentity:
14
+ digest: str
15
+ local_polygon_count: int
16
+ reference_count: int
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ReferenceChange:
21
+ kind: str
22
+ bbox: BBox
23
+ old_child_digest: str | None = None
24
+ new_child_digest: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class HierarchyCompareResult:
29
+ old_top: str
30
+ new_top: str
31
+ old_digest: str
32
+ new_digest: str
33
+ identical: bool
34
+ matched_subtree_count: int
35
+ changed_references: tuple[ReferenceChange, ...] = field(default_factory=tuple)
36
+
37
+
38
+ def compare_hierarchy(
39
+ old_lib: gdstk.Library,
40
+ new_lib: gdstk.Library,
41
+ *,
42
+ old_top: str | None = None,
43
+ new_top: str | None = None,
44
+ ) -> HierarchyCompareResult:
45
+ old_cell = _select_top(old_lib, old_top)
46
+ new_cell = _select_top(new_lib, new_top)
47
+ old_cache: dict[int, CellIdentity] = {}
48
+ new_cache: dict[int, CellIdentity] = {}
49
+ old_identity = canonical_cell_identity(old_cell, old_cache)
50
+ new_identity = canonical_cell_identity(new_cell, new_cache)
51
+ matched: set[tuple[str, str]] = set()
52
+ changes: list[ReferenceChange] = []
53
+ _compare_cells(old_cell, new_cell, old_cache, new_cache, matched, changes)
54
+ return HierarchyCompareResult(
55
+ old_top=old_cell.name,
56
+ new_top=new_cell.name,
57
+ old_digest=old_identity.digest,
58
+ new_digest=new_identity.digest,
59
+ identical=old_identity.digest == new_identity.digest,
60
+ matched_subtree_count=len(matched),
61
+ changed_references=tuple(sorted(changes, key=lambda item: (item.kind, item.bbox, item.old_child_digest or "", item.new_child_digest or ""))),
62
+ )
63
+
64
+
65
+ def canonical_cell_identity(cell: gdstk.Cell, cache: dict[int, CellIdentity] | None = None) -> CellIdentity:
66
+ cache = cache if cache is not None else {}
67
+ return _canonical_cell_identity(cell, cache, set())
68
+
69
+
70
+ def _canonical_cell_identity(cell: gdstk.Cell, cache: dict[int, CellIdentity], visiting: set[int]) -> CellIdentity:
71
+ key = id(cell)
72
+ if key in cache:
73
+ return cache[key]
74
+ if key in visiting:
75
+ raise ValueError(f"hierarchy cycle detected at cell {cell.name!r}")
76
+ visiting.add(key)
77
+ polygon_records = sorted(_polygon_record(polygon) for polygon in cell.polygons)
78
+ reference_records = []
79
+ for reference in cell.references:
80
+ child = _reference_cell(reference)
81
+ child_identity = _canonical_cell_identity(child, cache, visiting)
82
+ reference_records.append((_reference_transform_record(reference), child_identity.digest))
83
+ reference_records.sort()
84
+ digest = _digest_records(("cell", tuple(polygon_records), tuple(reference_records)))
85
+ visiting.remove(key)
86
+ identity = CellIdentity(
87
+ digest=digest,
88
+ local_polygon_count=len(polygon_records),
89
+ reference_count=len(reference_records),
90
+ )
91
+ cache[key] = identity
92
+ return identity
93
+
94
+
95
+ def _compare_cells(
96
+ old_cell: gdstk.Cell,
97
+ new_cell: gdstk.Cell,
98
+ old_cache: dict[int, CellIdentity],
99
+ new_cache: dict[int, CellIdentity],
100
+ matched: set[tuple[str, str]],
101
+ changes: list[ReferenceChange],
102
+ ) -> None:
103
+ old_identity = old_cache[id(old_cell)]
104
+ new_identity = new_cache[id(new_cell)]
105
+ if old_identity.digest == new_identity.digest:
106
+ matched.add((old_identity.digest, new_identity.digest))
107
+ return
108
+ old_refs = _references_by_transform(old_cell, old_cache)
109
+ new_refs = _references_by_transform(new_cell, new_cache)
110
+ for transform in sorted(set(old_refs) | set(new_refs)):
111
+ old_group = old_refs.get(transform, [])
112
+ new_group = new_refs.get(transform, [])
113
+ paired_count = min(len(old_group), len(new_group))
114
+ for index in range(paired_count):
115
+ old_ref, old_digest = old_group[index]
116
+ new_ref, new_digest = new_group[index]
117
+ if old_digest == new_digest:
118
+ matched.add((old_digest, new_digest))
119
+ continue
120
+ changes.append(
121
+ ReferenceChange(
122
+ kind="changed",
123
+ bbox=_reference_bbox_union((old_ref, new_ref)),
124
+ old_child_digest=old_digest,
125
+ new_child_digest=new_digest,
126
+ )
127
+ )
128
+ _compare_cells(_reference_cell(old_ref), _reference_cell(new_ref), old_cache, new_cache, matched, changes)
129
+ for old_ref, old_digest in old_group[paired_count:]:
130
+ changes.append(ReferenceChange(kind="removed", bbox=_reference_bbox(old_ref), old_child_digest=old_digest))
131
+ for new_ref, new_digest in new_group[paired_count:]:
132
+ changes.append(ReferenceChange(kind="added", bbox=_reference_bbox(new_ref), new_child_digest=new_digest))
133
+
134
+
135
+ def _references_by_transform(cell: gdstk.Cell, cache: dict[int, CellIdentity]) -> dict[tuple[object, ...], list[tuple[gdstk.Reference, str]]]:
136
+ grouped: dict[tuple[object, ...], list[tuple[gdstk.Reference, str]]] = {}
137
+ for reference in cell.references:
138
+ child = _reference_cell(reference)
139
+ digest = cache[id(child)].digest
140
+ grouped.setdefault(_reference_transform_record(reference), []).append((reference, digest))
141
+ for group in grouped.values():
142
+ group.sort(key=lambda item: item[1])
143
+ return grouped
144
+
145
+
146
+ def _polygon_record(polygon: gdstk.Polygon) -> tuple[object, ...]:
147
+ points = normalize_polygon_points([(_float_key(point[0]), _float_key(point[1])) for point in polygon.points])
148
+ if points:
149
+ rotations = [points[index:] + points[:index] for index in range(len(points))]
150
+ reverse = tuple(reversed(points))
151
+ rotations.extend(reverse[index:] + reverse[:index] for index in range(len(reverse)))
152
+ points = min(rotations)
153
+ return (LayerSpec(polygon.layer, polygon.datatype), points)
154
+
155
+
156
+ def _reference_transform_record(reference: gdstk.Reference) -> tuple[object, ...]:
157
+ return (
158
+ _float_pair(reference.origin),
159
+ _float_key(reference.rotation or 0),
160
+ _float_key(reference.magnification if reference.magnification is not None else 1),
161
+ bool(reference.x_reflection),
162
+ _repetition_record(reference.repetition),
163
+ )
164
+
165
+
166
+ def _repetition_record(repetition) -> tuple[object, ...] | None:
167
+ if repetition is None or repetition.size == 1:
168
+ return None
169
+ return tuple(sorted(_float_pair(offset) for offset in repetition.get_offsets()))
170
+
171
+
172
+ def _reference_cell(reference: gdstk.Reference) -> gdstk.Cell:
173
+ if reference.cell is None:
174
+ raise ValueError(f"unresolved reference to {reference.cell_name!r}")
175
+ return reference.cell
176
+
177
+
178
+ def _reference_bbox(reference: gdstk.Reference) -> BBox:
179
+ bbox = reference.bounding_box()
180
+ if bbox is None:
181
+ return (0, 0, 0, 0)
182
+ return (
183
+ int(_float_key(bbox[0][0])),
184
+ int(_float_key(bbox[0][1])),
185
+ int(_float_key(bbox[1][0])),
186
+ int(_float_key(bbox[1][1])),
187
+ )
188
+
189
+
190
+ def _reference_bbox_union(references: Iterable[gdstk.Reference]) -> BBox:
191
+ return bbox_union(tuple(_reference_bbox(reference) for reference in references))
192
+
193
+
194
+ def _select_top(lib: gdstk.Library, name: str | None) -> gdstk.Cell:
195
+ if name is not None:
196
+ for cell in lib.cells:
197
+ if cell.name == name:
198
+ return cell
199
+ raise ValueError(f"top cell {name!r} not found")
200
+ tops = lib.top_level()
201
+ if len(tops) != 1:
202
+ raise ValueError("library must have exactly one top cell when top is not supplied")
203
+ return tops[0]
204
+
205
+
206
+ def _float_key(value: float) -> int:
207
+ return int(round(float(value) * 1_000_000_000))
208
+
209
+
210
+ def _float_pair(values) -> tuple[int, int]:
211
+ return (_float_key(values[0]), _float_key(values[1]))
212
+
213
+
214
+ def _digest_records(records: object) -> str:
215
+ return hashlib.sha256(repr(records).encode("utf-8")).hexdigest()
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from decimal import Decimal
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ import gdstk
10
+ import numpy as np
11
+
12
+ from .config import LayerSpec, parse_layer_selector
13
+ from .gds_metadata import decimal_from_number
14
+ from .geometry import LayerTable, PolygonBuffer, normalize_polygon_points, polygon_bbox, polygon_flags
15
+ from .grid import snap_to_grid
16
+ from .inventory import select_top_cell
17
+ from .spatial import BBox, bboxes_intersect
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class WindowedHierarchyStats:
22
+ visited_cell_count: int
23
+ considered_reference_instance_count: int
24
+ pruned_reference_instance_count: int
25
+ emitted_polygon_count: int
26
+ raw_local_polygon_count: int
27
+ max_snap_error_dbu: Decimal
28
+
29
+ def to_json(self) -> dict[str, object]:
30
+ return {
31
+ "visited_cell_count": self.visited_cell_count,
32
+ "considered_reference_instance_count": self.considered_reference_instance_count,
33
+ "pruned_reference_instance_count": self.pruned_reference_instance_count,
34
+ "emitted_polygon_count": self.emitted_polygon_count,
35
+ "raw_local_polygon_count": self.raw_local_polygon_count,
36
+ "max_snap_error_dbu": str(self.max_snap_error_dbu),
37
+ }
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class WindowedHierarchyExtractionResult:
42
+ top_name: str
43
+ window_bbox: BBox
44
+ buffer: PolygonBuffer
45
+ stats: WindowedHierarchyStats
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class _Transform:
50
+ a: float = 1.0
51
+ b: float = 0.0
52
+ c: float = 0.0
53
+ d: float = 1.0
54
+ tx: float = 0.0
55
+ ty: float = 0.0
56
+
57
+ def apply(self, x: float, y: float) -> tuple[float, float]:
58
+ return self.a * x + self.b * y + self.tx, self.c * x + self.d * y + self.ty
59
+
60
+ def compose(self, child: "_Transform") -> "_Transform":
61
+ return _Transform(
62
+ a=self.a * child.a + self.b * child.c,
63
+ b=self.a * child.b + self.b * child.d,
64
+ c=self.c * child.a + self.d * child.c,
65
+ d=self.c * child.b + self.d * child.d,
66
+ tx=self.a * child.tx + self.b * child.ty + self.tx,
67
+ ty=self.c * child.tx + self.d * child.ty + self.ty,
68
+ )
69
+
70
+
71
+ def extract_hierarchy_window_from_gds(
72
+ path: str | Path,
73
+ *,
74
+ window_bbox: BBox,
75
+ top_name: str | None = None,
76
+ layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
77
+ meters_per_dbu: Decimal | str | float | None = None,
78
+ ) -> WindowedHierarchyExtractionResult:
79
+ lib = gdstk.read_gds(str(path))
80
+ grid = decimal_from_number(meters_per_dbu) if meters_per_dbu is not None else decimal_from_number(lib.precision)
81
+ return extract_hierarchy_window(lib, window_bbox=window_bbox, top_name=top_name, layers=layers, meters_per_dbu=grid)
82
+
83
+
84
+ def extract_hierarchy_window(
85
+ lib: gdstk.Library,
86
+ *,
87
+ window_bbox: BBox,
88
+ top_name: str | None = None,
89
+ layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
90
+ meters_per_dbu: Decimal | str | float | None = None,
91
+ ) -> WindowedHierarchyExtractionResult:
92
+ top = select_top_cell(lib, top_name=top_name)
93
+ grid = decimal_from_number(meters_per_dbu) if meters_per_dbu is not None else decimal_from_number(lib.precision)
94
+ unit = decimal_from_number(lib.unit)
95
+ selected_layers = _selected_layers_or_none(layers)
96
+ extractor = _WindowExtractor(unit=unit, grid=grid, selected_layers=selected_layers, window_bbox=window_bbox)
97
+ extractor.traverse_cell(top, _Transform(), set())
98
+ return WindowedHierarchyExtractionResult(
99
+ top_name=top.name,
100
+ window_bbox=window_bbox,
101
+ buffer=extractor.to_buffer(),
102
+ stats=WindowedHierarchyStats(
103
+ visited_cell_count=extractor.visited_cell_count,
104
+ considered_reference_instance_count=extractor.considered_reference_instance_count,
105
+ pruned_reference_instance_count=extractor.pruned_reference_instance_count,
106
+ emitted_polygon_count=len(extractor.polygons),
107
+ raw_local_polygon_count=extractor.raw_local_polygon_count,
108
+ max_snap_error_dbu=extractor.max_snap_error,
109
+ ),
110
+ )
111
+
112
+
113
+ class _WindowExtractor:
114
+ def __init__(self, *, unit: Decimal, grid: Decimal, selected_layers: set[LayerSpec] | None, window_bbox: BBox) -> None:
115
+ self.unit = unit
116
+ self.grid = grid
117
+ self.selected_layers = selected_layers
118
+ self.window_bbox = window_bbox
119
+ self.polygons: list[tuple[LayerSpec, tuple[tuple[int, int], ...]]] = []
120
+ self.max_snap_error = Decimal(0)
121
+ self.visited_cell_count = 0
122
+ self.considered_reference_instance_count = 0
123
+ self.pruned_reference_instance_count = 0
124
+ self.raw_local_polygon_count = 0
125
+
126
+ def traverse_cell(self, cell: gdstk.Cell, transform: _Transform, visiting: set[int]) -> None:
127
+ key = id(cell)
128
+ if key in visiting:
129
+ raise ValueError(f"hierarchy cycle detected at cell {cell.name!r}")
130
+ visiting.add(key)
131
+ self.visited_cell_count += 1
132
+ for polygon in cell.get_polygons(depth=0, include_paths=True):
133
+ self.raw_local_polygon_count += 1
134
+ spec = LayerSpec(polygon.layer, polygon.datatype)
135
+ if self.selected_layers is not None and spec not in self.selected_layers:
136
+ continue
137
+ points = tuple(transform.apply(float(point[0]), float(point[1])) for point in polygon.points)
138
+ if not _float_bbox_intersects_window(points, self.window_bbox, self.unit, self.grid):
139
+ continue
140
+ snapped = self._snap_points(points)
141
+ if len(snapped) >= 3 and bboxes_intersect(polygon_bbox(snapped), self.window_bbox):
142
+ self.polygons.append((spec, snapped))
143
+ for reference in cell.references:
144
+ child = _reference_cell(reference)
145
+ if id(child) in visiting:
146
+ raise ValueError(f"hierarchy cycle detected at cell {child.name!r}")
147
+ child_bbox = child.bounding_box()
148
+ if child_bbox is None:
149
+ continue
150
+ for offset in _reference_offsets(reference):
151
+ self.considered_reference_instance_count += 1
152
+ reference_transform = _reference_transform(reference, offset)
153
+ child_transform = transform.compose(reference_transform)
154
+ transformed_bbox = _transform_bbox(child_bbox, child_transform, self.unit, self.grid)
155
+ if not bboxes_intersect(transformed_bbox, self.window_bbox):
156
+ self.pruned_reference_instance_count += 1
157
+ continue
158
+ self.traverse_cell(child, child_transform, visiting)
159
+ visiting.remove(key)
160
+
161
+ def _snap_points(self, points: tuple[tuple[float, float], ...]) -> tuple[tuple[int, int], ...]:
162
+ snapped_points: list[tuple[int, int]] = []
163
+ for x, y in points:
164
+ x_physical = decimal_from_number(x) * self.unit
165
+ y_physical = decimal_from_number(y) * self.unit
166
+ sx = snap_to_grid(x_physical, self.grid)
167
+ sy = snap_to_grid(y_physical, self.grid)
168
+ self.max_snap_error = max(
169
+ self.max_snap_error,
170
+ abs(x_physical / self.grid - Decimal(sx)),
171
+ abs(y_physical / self.grid - Decimal(sy)),
172
+ )
173
+ snapped_points.append((sx, sy))
174
+ return normalize_polygon_points(snapped_points)
175
+
176
+ def to_buffer(self) -> PolygonBuffer:
177
+ layer_table = LayerTable(tuple(sorted({layer for layer, _ in self.polygons})))
178
+ layer_index = layer_table.index_by_layer
179
+ vertices: list[tuple[int, int]] = []
180
+ offsets = [0]
181
+ layer_ids = []
182
+ bboxes = []
183
+ flags = []
184
+ source_ids = []
185
+ for source_id, (layer, points) in enumerate(self.polygons):
186
+ vertices.extend(points)
187
+ offsets.append(len(vertices))
188
+ layer_ids.append(layer_index[layer])
189
+ bboxes.append(polygon_bbox(points))
190
+ flags.append(polygon_flags(points))
191
+ source_ids.append(source_id)
192
+ return PolygonBuffer(
193
+ vertices_xy=np.asarray(vertices, dtype=np.int64).reshape((-1, 2)),
194
+ polygon_offsets=np.asarray(offsets, dtype=np.uint64),
195
+ layer_ids=np.asarray(layer_ids, dtype=np.uint32),
196
+ bboxes=np.asarray(bboxes, dtype=np.int64).reshape((-1, 4)),
197
+ flags=np.asarray(flags, dtype=np.uint32),
198
+ source_ids=np.asarray(source_ids, dtype=np.uint64),
199
+ layer_table=layer_table,
200
+ )
201
+
202
+
203
+ def _reference_cell(reference: gdstk.Reference) -> gdstk.Cell:
204
+ if reference.cell is None:
205
+ raise ValueError(f"unresolved reference to {reference.cell_name!r}")
206
+ return reference.cell
207
+
208
+
209
+ def _reference_offsets(reference: gdstk.Reference) -> tuple[tuple[float, float], ...]:
210
+ repetition = reference.repetition
211
+ if repetition is None or repetition.size <= 1:
212
+ return ((0.0, 0.0),)
213
+ return tuple((float(x), float(y)) for x, y in repetition.get_offsets())
214
+
215
+
216
+ def _reference_transform(reference: gdstk.Reference, offset: tuple[float, float]) -> _Transform:
217
+ rotation = float(reference.rotation or 0.0)
218
+ magnification = float(reference.magnification if reference.magnification is not None else 1.0)
219
+ cos_r = math.cos(rotation)
220
+ sin_r = math.sin(rotation)
221
+ if reference.x_reflection:
222
+ a = magnification * cos_r
223
+ b = magnification * sin_r
224
+ c = magnification * sin_r
225
+ d = -magnification * cos_r
226
+ else:
227
+ a = magnification * cos_r
228
+ b = -magnification * sin_r
229
+ c = magnification * sin_r
230
+ d = magnification * cos_r
231
+ return _Transform(
232
+ a=a,
233
+ b=b,
234
+ c=c,
235
+ d=d,
236
+ tx=float(reference.origin[0]) + offset[0],
237
+ ty=float(reference.origin[1]) + offset[1],
238
+ )
239
+
240
+
241
+ def _transform_bbox(bbox, transform: _Transform, unit: Decimal, grid: Decimal) -> BBox:
242
+ corners = (
243
+ transform.apply(float(bbox[0][0]), float(bbox[0][1])),
244
+ transform.apply(float(bbox[0][0]), float(bbox[1][1])),
245
+ transform.apply(float(bbox[1][0]), float(bbox[0][1])),
246
+ transform.apply(float(bbox[1][0]), float(bbox[1][1])),
247
+ )
248
+ snapped = [(snap_to_grid(decimal_from_number(x) * unit, grid), snap_to_grid(decimal_from_number(y) * unit, grid)) for x, y in corners]
249
+ return polygon_bbox(tuple(snapped))
250
+
251
+
252
+ def _float_bbox_intersects_window(points: tuple[tuple[float, float], ...], window: BBox, unit: Decimal, grid: Decimal) -> bool:
253
+ if not points:
254
+ return False
255
+ snapped = [(snap_to_grid(decimal_from_number(x) * unit, grid), snap_to_grid(decimal_from_number(y) * unit, grid)) for x, y in points]
256
+ return bboxes_intersect(polygon_bbox(tuple(snapped)), window)
257
+
258
+
259
+ def _selected_layers_or_none(layers) -> set[LayerSpec] | None:
260
+ parsed = parse_layer_selector(layers)
261
+ if parsed == "all":
262
+ return None
263
+ return set(parsed)
gdsdiff/inventory.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ import gdstk
8
+
9
+ from .config import LayerSpec, parse_layer_selector
10
+ from .gds_metadata import GdsMetadata, decimal_from_number
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RecordInventory:
15
+ polygons: int = 0
16
+ paths: int = 0
17
+ labels: int = 0
18
+ references: int = 0
19
+ cells: int = 0
20
+
21
+ def to_json(self) -> dict[str, int]:
22
+ return {
23
+ "polygons": self.polygons,
24
+ "paths": self.paths,
25
+ "labels": self.labels,
26
+ "references": self.references,
27
+ "cells": self.cells,
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class GdsInventory:
33
+ metadata: GdsMetadata
34
+ selected_top: str
35
+ reachable_cells: tuple[str, ...]
36
+ layers: tuple[LayerSpec, ...]
37
+ records: RecordInventory
38
+ ignored_record_counts: dict[str, int]
39
+
40
+ def to_json(self) -> dict[str, object]:
41
+ return {
42
+ "metadata": self.metadata.to_json(),
43
+ "selected_top": self.selected_top,
44
+ "reachable_cells": list(self.reachable_cells),
45
+ "layers": [layer.to_json() for layer in self.layers],
46
+ "records": self.records.to_json(),
47
+ "ignored_record_counts": dict(self.ignored_record_counts),
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SelectedLayerPresence:
53
+ layer: LayerSpec
54
+ present_old: bool
55
+ present_new: bool
56
+
57
+ def to_json(self) -> dict[str, object]:
58
+ return {
59
+ "layer": self.layer.layer,
60
+ "datatype": self.layer.datatype,
61
+ "present_old": self.present_old,
62
+ "present_new": self.present_new,
63
+ }
64
+
65
+
66
+ def inventory_gds(path: str | Path, *, top_name: str | None = None) -> GdsInventory:
67
+ path = Path(path)
68
+ lib = gdstk.read_gds(str(path))
69
+ top = select_top_cell(lib, top_name=top_name)
70
+ reachable = _reachable_cells(top)
71
+ final_layers = tuple(sorted({LayerSpec(poly.layer, poly.datatype) for poly in top.get_polygons()}))
72
+ records = _record_inventory(reachable)
73
+ metadata = GdsMetadata(
74
+ path=path,
75
+ unit_m=decimal_from_number(lib.unit),
76
+ precision_m=decimal_from_number(lib.precision),
77
+ cell_count=len(lib.cells),
78
+ top_names=tuple(cell.name for cell in lib.top_level()),
79
+ )
80
+ return GdsInventory(
81
+ metadata=metadata,
82
+ selected_top=top.name,
83
+ reachable_cells=tuple(sorted(cell.name for cell in reachable)),
84
+ layers=final_layers,
85
+ records=records,
86
+ ignored_record_counts={"labels": records.labels},
87
+ )
88
+
89
+
90
+ def select_top_cell(lib: gdstk.Library, *, top_name: str | None = None) -> gdstk.Cell:
91
+ if top_name is not None:
92
+ for cell in lib.cells:
93
+ if cell.name == top_name:
94
+ return cell
95
+ raise ValueError(f"top cell {top_name!r} not found")
96
+ tops = lib.top_level()
97
+ if len(tops) != 1:
98
+ names = ", ".join(cell.name for cell in tops) or "<none>"
99
+ raise ValueError(f"expected exactly one top-level cell, found {len(tops)}: {names}")
100
+ return tops[0]
101
+
102
+
103
+ def resolve_selected_layers(
104
+ old_inventory: GdsInventory,
105
+ new_inventory: GdsInventory,
106
+ selector: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
107
+ *,
108
+ allow_empty_selected_layer: bool = False,
109
+ ) -> tuple[SelectedLayerPresence, ...]:
110
+ old_layers = set(old_inventory.layers)
111
+ new_layers = set(new_inventory.layers)
112
+ if selector == "all":
113
+ selected = tuple(sorted(old_layers | new_layers))
114
+ else:
115
+ parsed = parse_layer_selector(selector)
116
+ if parsed == "all":
117
+ selected = tuple(sorted(old_layers | new_layers))
118
+ else:
119
+ selected = parsed
120
+ result = tuple(
121
+ SelectedLayerPresence(layer=layer, present_old=layer in old_layers, present_new=layer in new_layers)
122
+ for layer in selected
123
+ )
124
+ absent_both = [presence.layer for presence in result if not presence.present_old and not presence.present_new]
125
+ if absent_both and not allow_empty_selected_layer:
126
+ missing = ", ".join(f"{layer.layer}:{layer.datatype}" for layer in absent_both)
127
+ raise ValueError(f"selected layer(s) absent in both inputs: {missing}")
128
+ return result
129
+
130
+
131
+ def _reachable_cells(top: gdstk.Cell) -> tuple[gdstk.Cell, ...]:
132
+ seen: set[str] = set()
133
+ out: list[gdstk.Cell] = []
134
+
135
+ def visit(cell: gdstk.Cell) -> None:
136
+ if cell.name in seen:
137
+ return
138
+ seen.add(cell.name)
139
+ out.append(cell)
140
+ for ref in cell.references:
141
+ if isinstance(ref.cell, gdstk.Cell):
142
+ visit(ref.cell)
143
+
144
+ visit(top)
145
+ return tuple(out)
146
+
147
+
148
+ def _record_inventory(cells: tuple[gdstk.Cell, ...]) -> RecordInventory:
149
+ polygons = sum(len(cell.polygons) for cell in cells)
150
+ paths = sum(len(cell.paths) for cell in cells)
151
+ labels = sum(len(cell.labels) for cell in cells)
152
+ references = sum(len(cell.references) for cell in cells)
153
+ return RecordInventory(polygons=polygons, paths=paths, labels=labels, references=references, cells=len(cells))
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import multiprocessing as mp
4
+ import sys
5
+ import threading
6
+ from multiprocessing.context import BaseContext
7
+
8
+ _IS_PORTABLE_PROCESS_WORKER = False
9
+
10
+
11
+ def mark_portable_process_worker() -> None:
12
+ """Mark a spawned pair-extraction worker so it never forks again."""
13
+
14
+ global _IS_PORTABLE_PROCESS_WORKER
15
+ _IS_PORTABLE_PROCESS_WORKER = True
16
+
17
+
18
+ def fork_context() -> BaseContext | None:
19
+ """Return a fork context when the platform supports one.
20
+
21
+ Several hot paths share large, read-only geometry buffers through forked
22
+ worker globals. Windows does not provide fork, so callers must retain a
23
+ serial path instead of failing while attempting to create a pool.
24
+ """
25
+
26
+ if (
27
+ _IS_PORTABLE_PROCESS_WORKER
28
+ or sys.version_info >= (3, 14)
29
+ or "fork" not in mp.get_all_start_methods()
30
+ or threading.active_count() != 1
31
+ ):
32
+ return None
33
+ return mp.get_context("fork")
34
+
35
+
36
+ def portable_process_context() -> BaseContext:
37
+ """Return the portable context for independent, pickle-safe tasks."""
38
+
39
+ return mp.get_context("spawn")