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
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")
|