gerberdiff 0.29.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.
- gerberdiff/__init__.py +86 -0
- gerberdiff/cli.py +527 -0
- gerberdiff/diff/__init__.py +0 -0
- gerberdiff/diff/diff_engine.py +409 -0
- gerberdiff/diff/layer_matcher.py +202 -0
- gerberdiff/export/__init__.py +0 -0
- gerberdiff/export/json_report.py +184 -0
- gerberdiff/export/png_export.py +92 -0
- gerberdiff/export/svg_export.py +187 -0
- gerberdiff/geometry/__init__.py +37 -0
- gerberdiff/geometry/attribute.py +203 -0
- gerberdiff/geometry/driver.py +227 -0
- gerberdiff/geometry/expand.py +232 -0
- gerberdiff/geometry/geom_diff.py +153 -0
- gerberdiff/geometry/layer_geometry.py +665 -0
- gerberdiff/geometry/macro_geom.py +215 -0
- gerberdiff/geometry/primitives.py +108 -0
- gerberdiff/geometry/types.py +85 -0
- gerberdiff/parse/__init__.py +0 -0
- gerberdiff/parse/arc_math.py +162 -0
- gerberdiff/parse/excellon_parser.py +338 -0
- gerberdiff/parse/gerber_parser.py +244 -0
- gerberdiff/parse/gerber_state.py +780 -0
- gerberdiff/parse/macro_parser.py +604 -0
- gerberdiff/parse/tokenizer.py +153 -0
- gerberdiff/py.typed +0 -0
- gerberdiff/render/__init__.py +0 -0
- gerberdiff/render/compiled_render.py +240 -0
- gerberdiff/render/draw_ops.py +205 -0
- gerberdiff/render/macro_renderer.py +343 -0
- gerberdiff/render/renderer.py +283 -0
- gerberdiff/render/viewport.py +85 -0
- gerberdiff/types.py +360 -0
- gerberdiff-0.29.0.dist-info/METADATA +105 -0
- gerberdiff-0.29.0.dist-info/RECORD +38 -0
- gerberdiff-0.29.0.dist-info/WHEEL +4 -0
- gerberdiff-0.29.0.dist-info/entry_points.txt +2 -0
- gerberdiff-0.29.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Assemble a ParsedImage into ordered, world-space expanded operations.
|
|
2
|
+
|
|
3
|
+
This is the geometry engine's equivalent of the renderer's compile+render
|
|
4
|
+
pass. Each draw operation becomes an :class:`ExpandedOp` carrying:
|
|
5
|
+
|
|
6
|
+
- a **lazy** world-space geometry (layer transforms and step-and-repeat
|
|
7
|
+
applied on first access),
|
|
8
|
+
- effective polarity (dark adds material, clear subtracts),
|
|
9
|
+
- a **source-based content signature** for exact-cancellation matching
|
|
10
|
+
between revisions, computed without expanding any geometry,
|
|
11
|
+
- a conservative analytic bounding box (also computed without expansion),
|
|
12
|
+
- provenance (source op, aperture identity, net name) for attribution.
|
|
13
|
+
|
|
14
|
+
Laziness is the engine's core performance property: ops whose signatures
|
|
15
|
+
match between revisions (typically the vast majority) never pay for shapely
|
|
16
|
+
geometry construction at all. Only changed ops -- and unchanged ops whose
|
|
17
|
+
bounding boxes interact with changed material -- are ever expanded.
|
|
18
|
+
|
|
19
|
+
Transform semantics mirror the renderer's CTM derivation exactly
|
|
20
|
+
(``renderer.py::_render_layer``): coordinates are transformed
|
|
21
|
+
``SR-translate -> scale -> rotation -> mirror``.
|
|
22
|
+
|
|
23
|
+
Block apertures **flatten into the outer replay sequence** with the flash
|
|
24
|
+
translation composed in. A clear layer inside a block erases previously
|
|
25
|
+
drawn content globally (verified renderer behaviour), so effective polarity
|
|
26
|
+
is Clear when *any* enclosing context or the op's own layer is Clear.
|
|
27
|
+
|
|
28
|
+
Macro flashes are expanded eagerly: their evaluation can fail, and the
|
|
29
|
+
resulting Warning diagnostic must surface deterministically rather than
|
|
30
|
+
depending on which ops happen to be expanded.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import math
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
|
|
40
|
+
from shapely import affinity
|
|
41
|
+
from shapely.geometry import Polygon
|
|
42
|
+
from shapely.geometry.base import BaseGeometry
|
|
43
|
+
from shapely.ops import unary_union
|
|
44
|
+
|
|
45
|
+
from gerberdiff.geometry.expand import flash_geometry, region_geometry, stroke_geometry
|
|
46
|
+
from gerberdiff.parse.arc_math import arc_bounding_box
|
|
47
|
+
from gerberdiff.types import (
|
|
48
|
+
Aperture,
|
|
49
|
+
ApertureState,
|
|
50
|
+
BlockAperture,
|
|
51
|
+
CircleAperture,
|
|
52
|
+
Diagnostic,
|
|
53
|
+
DiagnosticSeverity,
|
|
54
|
+
DrawOp,
|
|
55
|
+
LayerState,
|
|
56
|
+
MacroAperture,
|
|
57
|
+
MirrorState,
|
|
58
|
+
ObroundAperture,
|
|
59
|
+
ParsedImage,
|
|
60
|
+
Polarity,
|
|
61
|
+
PolygonAperture,
|
|
62
|
+
RectangleAperture,
|
|
63
|
+
RegionFill,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Maximum block-aperture nesting depth (matches renderer and parser limits).
|
|
67
|
+
_MAX_BLOCK_DEPTH = 10
|
|
68
|
+
|
|
69
|
+
# Affine transform: world = M @ p + t, stored as (a, b, d, e) and (tx, ty)
|
|
70
|
+
# in shapely's affine_transform convention (x' = a*x + b*y + tx, ...).
|
|
71
|
+
_Matrix = tuple[float, float, float, float]
|
|
72
|
+
_Offset = tuple[float, float]
|
|
73
|
+
_Bounds = tuple[float, float, float, float] # (min_x, min_y, max_x, max_y)
|
|
74
|
+
_IDENTITY_M: _Matrix = (1.0, 0.0, 0.0, 1.0)
|
|
75
|
+
_ZERO_T: _Offset = (0.0, 0.0)
|
|
76
|
+
|
|
77
|
+
_EMPTY: BaseGeometry = Polygon()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Result types
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ExpandedOp:
|
|
87
|
+
"""One draw operation with lazy world-space geometry and provenance."""
|
|
88
|
+
|
|
89
|
+
polarity: Polarity
|
|
90
|
+
kind: str # "flash" | "stroke" | "region"
|
|
91
|
+
signature: str # content hash: cancels exactly across revisions
|
|
92
|
+
ap_signature: str # aperture identity (dims), for moved/resized logic
|
|
93
|
+
dims_signature: str # like ap_signature but orientation-normalised
|
|
94
|
+
bounds: _Bounds # conservative world-space bbox (no expansion needed)
|
|
95
|
+
net_name: str | None
|
|
96
|
+
source: DrawOp | RegionFill
|
|
97
|
+
# Lazy expansion machinery (shared thunk across SR tiles).
|
|
98
|
+
_expand: Callable[[], BaseGeometry] = field(repr=False)
|
|
99
|
+
_m: _Matrix = field(repr=False)
|
|
100
|
+
_t: _Offset = field(repr=False)
|
|
101
|
+
_geom: BaseGeometry | None = field(default=None, repr=False)
|
|
102
|
+
_centroid: tuple[float, float] | None = field(default=None, repr=False)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def geom(self) -> BaseGeometry:
|
|
106
|
+
"""World-space geometry (expanded and transformed on first access)."""
|
|
107
|
+
if self._geom is None:
|
|
108
|
+
self._geom = _apply_affine(self._expand(), self._m, self._t)
|
|
109
|
+
return self._geom
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def centroid_x(self) -> float:
|
|
113
|
+
return self._centroid_xy()[0]
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def centroid_y(self) -> float:
|
|
117
|
+
return self._centroid_xy()[1]
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def area(self) -> float:
|
|
121
|
+
"""Geometry area in square inches."""
|
|
122
|
+
return self.geom.area
|
|
123
|
+
|
|
124
|
+
def _centroid_xy(self) -> tuple[float, float]:
|
|
125
|
+
if self._centroid is None:
|
|
126
|
+
c = self.geom.centroid
|
|
127
|
+
if c.is_empty: # degenerate geometry: fall back to bbox centre
|
|
128
|
+
self._centroid = (
|
|
129
|
+
(self.bounds[0] + self.bounds[2]) / 2.0,
|
|
130
|
+
(self.bounds[1] + self.bounds[3]) / 2.0,
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
self._centroid = (c.x, c.y)
|
|
134
|
+
return self._centroid
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class LayerGeometry:
|
|
139
|
+
"""All expanded operations of one parsed file, in replay order."""
|
|
140
|
+
|
|
141
|
+
ops: list[ExpandedOp] = field(default_factory=list)
|
|
142
|
+
has_clear: bool = False
|
|
143
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Public API
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_layer_geometry(parsed: ParsedImage) -> LayerGeometry:
|
|
152
|
+
"""Build the lazy expanded-op list for every draw operation of *parsed*."""
|
|
153
|
+
result = LayerGeometry()
|
|
154
|
+
_walk(
|
|
155
|
+
draw_ops=parsed.draw_ops,
|
|
156
|
+
apertures=parsed.apertures,
|
|
157
|
+
layers=parsed.layers,
|
|
158
|
+
outer_m=_IDENTITY_M,
|
|
159
|
+
outer_t=_ZERO_T,
|
|
160
|
+
outer_clear=False,
|
|
161
|
+
depth=0,
|
|
162
|
+
result=result,
|
|
163
|
+
)
|
|
164
|
+
result.has_clear = any(op.polarity == Polarity.Clear for op in result.ops)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def resolve_geometry(ops: list[ExpandedOp]) -> BaseGeometry:
|
|
169
|
+
"""Ordered polarity replay: dark runs union, clear runs subtract.
|
|
170
|
+
|
|
171
|
+
Consecutive same-polarity ops are unioned in one call (associative), so
|
|
172
|
+
the replay cost scales with the number of polarity *transitions*, not ops.
|
|
173
|
+
Forces expansion of every op.
|
|
174
|
+
"""
|
|
175
|
+
acc: BaseGeometry = _EMPTY
|
|
176
|
+
i = 0
|
|
177
|
+
n = len(ops)
|
|
178
|
+
while i < n:
|
|
179
|
+
j = i
|
|
180
|
+
polarity = ops[i].polarity
|
|
181
|
+
while j < n and ops[j].polarity == polarity:
|
|
182
|
+
j += 1
|
|
183
|
+
run = unary_union([op.geom for op in ops[i:j]])
|
|
184
|
+
if polarity == Polarity.Dark:
|
|
185
|
+
acc = run if acc.is_empty else acc.union(run)
|
|
186
|
+
elif not acc.is_empty:
|
|
187
|
+
acc = acc.difference(run)
|
|
188
|
+
i = j
|
|
189
|
+
return acc
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Replay walk (recursive over block apertures)
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _walk(
|
|
198
|
+
draw_ops: list[DrawOp | RegionFill],
|
|
199
|
+
apertures: dict[int, Aperture],
|
|
200
|
+
layers: list[LayerState],
|
|
201
|
+
outer_m: _Matrix,
|
|
202
|
+
outer_t: _Offset,
|
|
203
|
+
outer_clear: bool,
|
|
204
|
+
depth: int,
|
|
205
|
+
result: LayerGeometry,
|
|
206
|
+
) -> None:
|
|
207
|
+
if depth >= _MAX_BLOCK_DEPTH:
|
|
208
|
+
return # matches renderer: silently skip over-deep nesting
|
|
209
|
+
|
|
210
|
+
# Per-layer transform/tile cache.
|
|
211
|
+
layer_cache: dict[int, tuple[_Matrix, list[_Offset], bool]] = {}
|
|
212
|
+
|
|
213
|
+
def _layer_info(index: int) -> tuple[_Matrix, list[_Offset], bool] | None:
|
|
214
|
+
if index < 0 or index >= len(layers):
|
|
215
|
+
return None
|
|
216
|
+
cached = layer_cache.get(index)
|
|
217
|
+
if cached is not None:
|
|
218
|
+
return cached
|
|
219
|
+
ls = layers[index]
|
|
220
|
+
info = (_layer_matrix(ls), _sr_tiles(ls), ls.polarity == Polarity.Clear)
|
|
221
|
+
layer_cache[index] = info
|
|
222
|
+
return info
|
|
223
|
+
|
|
224
|
+
for item in draw_ops:
|
|
225
|
+
info = _layer_info(item.layer_index)
|
|
226
|
+
if info is None:
|
|
227
|
+
continue
|
|
228
|
+
layer_m, tiles, layer_clear = info
|
|
229
|
+
polarity = Polarity.Clear if (outer_clear or layer_clear) else Polarity.Dark
|
|
230
|
+
|
|
231
|
+
if isinstance(item, RegionFill):
|
|
232
|
+
_emit_region(result, item, outer_m, outer_t, layer_m, tiles, polarity)
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
op = item
|
|
236
|
+
if op.aperture_state == ApertureState.Off:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
ap = apertures.get(op.aperture_index)
|
|
240
|
+
|
|
241
|
+
if op.aperture_state == ApertureState.Flash and isinstance(ap, BlockAperture):
|
|
242
|
+
# Flatten block content into this replay, per tile.
|
|
243
|
+
for tile in tiles:
|
|
244
|
+
m, t = _compose(outer_m, outer_t, layer_m, _mat_vec(layer_m, tile))
|
|
245
|
+
# Block flash position translates in the (transformed) op space.
|
|
246
|
+
bt = _vec_add(_mat_vec(m, (op.stop_x, op.stop_y)), t)
|
|
247
|
+
_walk(
|
|
248
|
+
draw_ops=ap.draw_ops,
|
|
249
|
+
apertures=ap.apertures,
|
|
250
|
+
layers=ap.layers,
|
|
251
|
+
outer_m=m,
|
|
252
|
+
outer_t=bt,
|
|
253
|
+
outer_clear=polarity == Polarity.Clear,
|
|
254
|
+
depth=depth + 1,
|
|
255
|
+
result=result,
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if op.aperture_state == ApertureState.Flash:
|
|
260
|
+
_emit_flash(result, op, ap, outer_m, outer_t, layer_m, tiles, polarity)
|
|
261
|
+
else: # ApertureState.On
|
|
262
|
+
_emit_stroke(result, op, ap, outer_m, outer_t, layer_m, tiles, polarity)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Per-kind emit helpers (descriptor construction, no geometry expansion)
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _emit_flash(
|
|
271
|
+
result: LayerGeometry,
|
|
272
|
+
op: DrawOp,
|
|
273
|
+
ap: Aperture | None,
|
|
274
|
+
outer_m: _Matrix,
|
|
275
|
+
outer_t: _Offset,
|
|
276
|
+
layer_m: _Matrix,
|
|
277
|
+
tiles: list[_Offset],
|
|
278
|
+
polarity: Polarity,
|
|
279
|
+
) -> None:
|
|
280
|
+
if ap is None:
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if isinstance(ap, MacroAperture):
|
|
284
|
+
# Eager: evaluation can fail and must warn deterministically.
|
|
285
|
+
geom, diags = flash_geometry(op, ap)
|
|
286
|
+
result.diagnostics.extend(diags)
|
|
287
|
+
if geom.is_empty:
|
|
288
|
+
return
|
|
289
|
+
thunk = _const_thunk(geom)
|
|
290
|
+
op_bounds: _Bounds = geom.bounds
|
|
291
|
+
else:
|
|
292
|
+
extents = _aperture_half_extents(ap)
|
|
293
|
+
if extents is None:
|
|
294
|
+
return
|
|
295
|
+
hx, hy = extents
|
|
296
|
+
x, y = op.stop_x, op.stop_y
|
|
297
|
+
thunk = _memo_thunk(lambda: flash_geometry(op, ap)[0])
|
|
298
|
+
op_bounds = (x - hx, y - hy, x + hx, y + hy)
|
|
299
|
+
|
|
300
|
+
ap_sig, dims_sig = _aperture_signature(ap)
|
|
301
|
+
base_sig = f"flash|{polarity.value}|{ap_sig}|{op.stop_x!r},{op.stop_y!r}"
|
|
302
|
+
_emit_tiles(
|
|
303
|
+
result,
|
|
304
|
+
op,
|
|
305
|
+
"flash",
|
|
306
|
+
ap_sig,
|
|
307
|
+
dims_sig,
|
|
308
|
+
base_sig,
|
|
309
|
+
op_bounds,
|
|
310
|
+
thunk,
|
|
311
|
+
op.attributes.get("N") if op.attributes else None,
|
|
312
|
+
outer_m,
|
|
313
|
+
outer_t,
|
|
314
|
+
layer_m,
|
|
315
|
+
tiles,
|
|
316
|
+
polarity,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _emit_stroke(
|
|
321
|
+
result: LayerGeometry,
|
|
322
|
+
op: DrawOp,
|
|
323
|
+
ap: Aperture | None,
|
|
324
|
+
outer_m: _Matrix,
|
|
325
|
+
outer_t: _Offset,
|
|
326
|
+
layer_m: _Matrix,
|
|
327
|
+
tiles: list[_Offset],
|
|
328
|
+
polarity: Polarity,
|
|
329
|
+
) -> None:
|
|
330
|
+
if ap is None or isinstance(ap, (BlockAperture, MacroAperture)):
|
|
331
|
+
# Strokes with macro/block apertures are not meaningful; the raster
|
|
332
|
+
# engine draws them with a hairline -- skip in the geometry engine.
|
|
333
|
+
return
|
|
334
|
+
extents = _aperture_half_extents(ap)
|
|
335
|
+
if extents is None:
|
|
336
|
+
return
|
|
337
|
+
hx, hy = extents
|
|
338
|
+
brush = max(hx, hy) # conservative half-extent in any direction
|
|
339
|
+
|
|
340
|
+
arc = op.arc_segment
|
|
341
|
+
if arc is not None:
|
|
342
|
+
bb = arc_bounding_box(arc, brush)
|
|
343
|
+
op_bounds: _Bounds = (bb.min_x, bb.min_y, bb.max_x, bb.max_y)
|
|
344
|
+
arc_sig = (
|
|
345
|
+
f"|arc:{arc.center_x!r},{arc.center_y!r},{arc.radius!r},"
|
|
346
|
+
f"{arc.start_angle_deg!r},{arc.end_angle_deg!r}"
|
|
347
|
+
)
|
|
348
|
+
if not isinstance(ap, CircleAperture):
|
|
349
|
+
# Static decision: non-round arc strokes use the round-brush
|
|
350
|
+
# approximation (see expand._arc_stroke).
|
|
351
|
+
result.diagnostics.append(
|
|
352
|
+
Diagnostic(
|
|
353
|
+
severity=DiagnosticSeverity.Info,
|
|
354
|
+
message=(
|
|
355
|
+
f"arc stroke with {type(ap).__name__} approximated by "
|
|
356
|
+
f"a round brush of diameter {2.0 * brush:.6f} in"
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
op_bounds = (
|
|
362
|
+
min(op.start_x, op.stop_x) - brush,
|
|
363
|
+
min(op.start_y, op.stop_y) - brush,
|
|
364
|
+
max(op.start_x, op.stop_x) + brush,
|
|
365
|
+
max(op.start_y, op.stop_y) + brush,
|
|
366
|
+
)
|
|
367
|
+
arc_sig = ""
|
|
368
|
+
|
|
369
|
+
ap_sig, dims_sig = _aperture_signature(ap)
|
|
370
|
+
base_sig = (
|
|
371
|
+
f"stroke|{polarity.value}|{ap_sig}"
|
|
372
|
+
f"|{op.start_x!r},{op.start_y!r},{op.stop_x!r},{op.stop_y!r}{arc_sig}"
|
|
373
|
+
)
|
|
374
|
+
_emit_tiles(
|
|
375
|
+
result,
|
|
376
|
+
op,
|
|
377
|
+
"stroke",
|
|
378
|
+
ap_sig,
|
|
379
|
+
dims_sig,
|
|
380
|
+
base_sig,
|
|
381
|
+
op_bounds,
|
|
382
|
+
_memo_thunk(lambda: stroke_geometry(op, ap)[0]),
|
|
383
|
+
op.attributes.get("N") if op.attributes else None,
|
|
384
|
+
outer_m,
|
|
385
|
+
outer_t,
|
|
386
|
+
layer_m,
|
|
387
|
+
tiles,
|
|
388
|
+
polarity,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _emit_region(
|
|
393
|
+
result: LayerGeometry,
|
|
394
|
+
region: RegionFill,
|
|
395
|
+
outer_m: _Matrix,
|
|
396
|
+
outer_t: _Offset,
|
|
397
|
+
layer_m: _Matrix,
|
|
398
|
+
tiles: list[_Offset],
|
|
399
|
+
polarity: Polarity,
|
|
400
|
+
) -> None:
|
|
401
|
+
bounds = _region_bounds(region)
|
|
402
|
+
if bounds is None:
|
|
403
|
+
return # degenerate region (no drawable contour)
|
|
404
|
+
sig_parts = [f"region|{polarity.value}"]
|
|
405
|
+
for seg in region.segments:
|
|
406
|
+
arc = seg.arc_segment
|
|
407
|
+
arc_sig = (
|
|
408
|
+
f";{arc.center_x!r},{arc.center_y!r},{arc.radius!r},"
|
|
409
|
+
f"{arc.start_angle_deg!r},{arc.end_angle_deg!r}"
|
|
410
|
+
if arc is not None
|
|
411
|
+
else ""
|
|
412
|
+
)
|
|
413
|
+
sig_parts.append(
|
|
414
|
+
f"{seg.aperture_state.value}:{seg.start_x!r},{seg.start_y!r},"
|
|
415
|
+
f"{seg.stop_x!r},{seg.stop_y!r}{arc_sig}"
|
|
416
|
+
)
|
|
417
|
+
_emit_tiles(
|
|
418
|
+
result,
|
|
419
|
+
region,
|
|
420
|
+
"region",
|
|
421
|
+
"region",
|
|
422
|
+
"region",
|
|
423
|
+
"|".join(sig_parts),
|
|
424
|
+
bounds,
|
|
425
|
+
_memo_thunk(lambda: region_geometry(region)[0]),
|
|
426
|
+
None,
|
|
427
|
+
outer_m,
|
|
428
|
+
outer_t,
|
|
429
|
+
layer_m,
|
|
430
|
+
tiles,
|
|
431
|
+
polarity,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _emit_tiles(
|
|
436
|
+
result: LayerGeometry,
|
|
437
|
+
source: DrawOp | RegionFill,
|
|
438
|
+
kind: str,
|
|
439
|
+
ap_signature: str,
|
|
440
|
+
dims_signature: str,
|
|
441
|
+
base_signature: str,
|
|
442
|
+
op_bounds: _Bounds,
|
|
443
|
+
thunk: Callable[[], BaseGeometry],
|
|
444
|
+
net_name: str | None,
|
|
445
|
+
outer_m: _Matrix,
|
|
446
|
+
outer_t: _Offset,
|
|
447
|
+
layer_m: _Matrix,
|
|
448
|
+
tiles: list[_Offset],
|
|
449
|
+
polarity: Polarity,
|
|
450
|
+
) -> None:
|
|
451
|
+
for tile in tiles:
|
|
452
|
+
m, t = _compose(outer_m, outer_t, layer_m, _mat_vec(layer_m, tile))
|
|
453
|
+
signature = _hash_signature(f"{base_signature}|affine:{m!r},{t!r}")
|
|
454
|
+
result.ops.append(
|
|
455
|
+
ExpandedOp(
|
|
456
|
+
polarity=polarity,
|
|
457
|
+
kind=kind,
|
|
458
|
+
signature=signature,
|
|
459
|
+
ap_signature=ap_signature,
|
|
460
|
+
dims_signature=dims_signature,
|
|
461
|
+
bounds=_transform_bounds(op_bounds, m, t),
|
|
462
|
+
net_name=net_name,
|
|
463
|
+
source=source,
|
|
464
|
+
_expand=thunk,
|
|
465
|
+
_m=m,
|
|
466
|
+
_t=t,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Lazy-expansion helpers
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _memo_thunk(fn: Callable[[], BaseGeometry]) -> Callable[[], BaseGeometry]:
|
|
477
|
+
"""Memoise an expansion function so SR tiles share one op-space geometry."""
|
|
478
|
+
cache: list[BaseGeometry] = []
|
|
479
|
+
|
|
480
|
+
def thunk() -> BaseGeometry:
|
|
481
|
+
if not cache:
|
|
482
|
+
cache.append(fn())
|
|
483
|
+
return cache[0]
|
|
484
|
+
|
|
485
|
+
return thunk
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _const_thunk(geom: BaseGeometry) -> Callable[[], BaseGeometry]:
|
|
489
|
+
return lambda: geom
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _aperture_half_extents(ap: Aperture) -> tuple[float, float] | None:
|
|
493
|
+
"""Analytic half-extents of a simple aperture, or None if degenerate.
|
|
494
|
+
|
|
495
|
+
Mirrors the validity checks in ``expand._aperture_outline``.
|
|
496
|
+
"""
|
|
497
|
+
match ap:
|
|
498
|
+
case CircleAperture():
|
|
499
|
+
if ap.diameter <= 0.0:
|
|
500
|
+
return None
|
|
501
|
+
r = ap.diameter / 2.0
|
|
502
|
+
return (r, r)
|
|
503
|
+
case RectangleAperture() | ObroundAperture():
|
|
504
|
+
if ap.width <= 0.0 or ap.height <= 0.0:
|
|
505
|
+
return None
|
|
506
|
+
return (ap.width / 2.0, ap.height / 2.0)
|
|
507
|
+
case PolygonAperture():
|
|
508
|
+
if ap.outer_diameter <= 0.0 or ap.num_vertices < 3:
|
|
509
|
+
return None
|
|
510
|
+
r = ap.outer_diameter / 2.0
|
|
511
|
+
return (r, r)
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _region_bounds(region: RegionFill) -> _Bounds | None:
|
|
516
|
+
"""Conservative bbox of a region fill, or None when degenerate."""
|
|
517
|
+
min_x = math.inf
|
|
518
|
+
min_y = math.inf
|
|
519
|
+
max_x = -math.inf
|
|
520
|
+
max_y = -math.inf
|
|
521
|
+
on_segments = 0
|
|
522
|
+
for seg in region.segments:
|
|
523
|
+
if seg.aperture_state != ApertureState.Off:
|
|
524
|
+
on_segments += 1
|
|
525
|
+
arc = seg.arc_segment
|
|
526
|
+
if arc is not None:
|
|
527
|
+
bb = arc_bounding_box(arc)
|
|
528
|
+
min_x = min(min_x, bb.min_x)
|
|
529
|
+
min_y = min(min_y, bb.min_y)
|
|
530
|
+
max_x = max(max_x, bb.max_x)
|
|
531
|
+
max_y = max(max_y, bb.max_y)
|
|
532
|
+
else:
|
|
533
|
+
min_x = min(min_x, seg.start_x, seg.stop_x)
|
|
534
|
+
min_y = min(min_y, seg.start_y, seg.stop_y)
|
|
535
|
+
max_x = max(max_x, seg.start_x, seg.stop_x)
|
|
536
|
+
max_y = max(max_y, seg.start_y, seg.stop_y)
|
|
537
|
+
if on_segments < 2 or not math.isfinite(min_x):
|
|
538
|
+
return None
|
|
539
|
+
return (min_x, min_y, max_x, max_y)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _transform_bounds(b: _Bounds, m: _Matrix, t: _Offset) -> _Bounds:
|
|
543
|
+
"""Map a bbox through an affine; the corner hull stays conservative."""
|
|
544
|
+
if m == _IDENTITY_M and t == _ZERO_T:
|
|
545
|
+
return b
|
|
546
|
+
corners = (
|
|
547
|
+
_vec_add(_mat_vec(m, (b[0], b[1])), t),
|
|
548
|
+
_vec_add(_mat_vec(m, (b[2], b[1])), t),
|
|
549
|
+
_vec_add(_mat_vec(m, (b[2], b[3])), t),
|
|
550
|
+
_vec_add(_mat_vec(m, (b[0], b[3])), t),
|
|
551
|
+
)
|
|
552
|
+
xs = [c[0] for c in corners]
|
|
553
|
+
ys = [c[1] for c in corners]
|
|
554
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
# Signatures
|
|
559
|
+
# ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _hash_signature(content: str) -> str:
|
|
563
|
+
"""Hash a source-content signature string.
|
|
564
|
+
|
|
565
|
+
Identical source text parses to identical floats, whose ``repr`` is
|
|
566
|
+
exact, so an unchanged op yields a bit-identical signature across
|
|
567
|
+
revisions -- without constructing any geometry. Aperture identity is by
|
|
568
|
+
*content*, so D-code renumbering between files does not break matching.
|
|
569
|
+
"""
|
|
570
|
+
return hashlib.sha1(content.encode()).hexdigest()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _aperture_signature(ap: Aperture | None) -> tuple[str, str]:
|
|
574
|
+
"""Stable identity of an aperture's shape parameters (not its D-code).
|
|
575
|
+
|
|
576
|
+
Returns ``(exact, orientation_normalised)``. The normalised form treats
|
|
577
|
+
a 90-degree-rotated rect/obround (W and H swapped) and a re-phased
|
|
578
|
+
regular polygon as the *same dimensions*, so a rotated footprint
|
|
579
|
+
classifies as ``moved`` rather than ``resized``.
|
|
580
|
+
"""
|
|
581
|
+
match ap:
|
|
582
|
+
case CircleAperture():
|
|
583
|
+
sig = f"circle:{ap.diameter!r}:{ap.hole_diameter!r}"
|
|
584
|
+
return sig, sig
|
|
585
|
+
case RectangleAperture():
|
|
586
|
+
lo, hi = sorted((ap.width, ap.height))
|
|
587
|
+
return (
|
|
588
|
+
f"rect:{ap.width!r}x{ap.height!r}:{ap.hole_diameter!r}",
|
|
589
|
+
f"rect:{lo!r}x{hi!r}:{ap.hole_diameter!r}",
|
|
590
|
+
)
|
|
591
|
+
case ObroundAperture():
|
|
592
|
+
lo, hi = sorted((ap.width, ap.height))
|
|
593
|
+
return (
|
|
594
|
+
f"obround:{ap.width!r}x{ap.height!r}:{ap.hole_diameter!r}",
|
|
595
|
+
f"obround:{lo!r}x{hi!r}:{ap.hole_diameter!r}",
|
|
596
|
+
)
|
|
597
|
+
case PolygonAperture():
|
|
598
|
+
return (
|
|
599
|
+
f"polygon:{ap.outer_diameter!r}:{ap.num_vertices}"
|
|
600
|
+
f":{ap.rotation!r}:{ap.hole_diameter!r}",
|
|
601
|
+
f"polygon:{ap.outer_diameter!r}:{ap.num_vertices}:{ap.hole_diameter!r}",
|
|
602
|
+
)
|
|
603
|
+
case MacroAperture():
|
|
604
|
+
name = ap.macro_def.name if ap.macro_def is not None else "?"
|
|
605
|
+
params = ",".join(repr(p) for p in ap.params)
|
|
606
|
+
sig = f"macro:{name}:{params}:{ap.unit_scale!r}"
|
|
607
|
+
return sig, sig
|
|
608
|
+
case _:
|
|
609
|
+
return "none", "none"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# ---------------------------------------------------------------------------
|
|
613
|
+
# Affine helpers
|
|
614
|
+
# ---------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _layer_matrix(ls: LayerState) -> _Matrix:
|
|
618
|
+
"""Layer transform M = Mirror @ Rotation @ Scale (renderer CTM order)."""
|
|
619
|
+
s = ls.scale
|
|
620
|
+
theta = math.radians(ls.rotation)
|
|
621
|
+
c, sn = math.cos(theta), math.sin(theta)
|
|
622
|
+
sx = -1.0 if ls.mirror in (MirrorState.FlipA, MirrorState.FlipAB) else 1.0
|
|
623
|
+
sy = -1.0 if ls.mirror in (MirrorState.FlipB, MirrorState.FlipAB) else 1.0
|
|
624
|
+
# Mir @ Rot @ Scale, row-major (a, b, d, e).
|
|
625
|
+
return (sx * c * s, -sx * sn * s, sy * sn * s, sy * c * s)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _sr_tiles(ls: LayerState) -> list[_Offset]:
|
|
629
|
+
"""Step-and-repeat tile offsets (in pre-transform op space)."""
|
|
630
|
+
sr = ls.step_and_repeat
|
|
631
|
+
if sr.x <= 1 and sr.y <= 1:
|
|
632
|
+
return [_ZERO_T]
|
|
633
|
+
return [
|
|
634
|
+
(ix * sr.dist_x, iy * sr.dist_y) for ix in range(max(1, sr.x)) for iy in range(max(1, sr.y))
|
|
635
|
+
]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _compose(m1: _Matrix, t1: _Offset, m2: _Matrix, t2: _Offset) -> tuple[_Matrix, _Offset]:
|
|
639
|
+
"""Compose two affines: apply (m2, t2) first, then (m1, t1)."""
|
|
640
|
+
a1, b1, d1, e1 = m1
|
|
641
|
+
a2, b2, d2, e2 = m2
|
|
642
|
+
m = (
|
|
643
|
+
a1 * a2 + b1 * d2,
|
|
644
|
+
a1 * b2 + b1 * e2,
|
|
645
|
+
d1 * a2 + e1 * d2,
|
|
646
|
+
d1 * b2 + e1 * e2,
|
|
647
|
+
)
|
|
648
|
+
t = _vec_add(_mat_vec(m1, t2), t1)
|
|
649
|
+
return m, t
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _mat_vec(m: _Matrix, v: _Offset) -> _Offset:
|
|
653
|
+
a, b, d, e = m
|
|
654
|
+
return (a * v[0] + b * v[1], d * v[0] + e * v[1])
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _vec_add(u: _Offset, v: _Offset) -> _Offset:
|
|
658
|
+
return (u[0] + v[0], u[1] + v[1])
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _apply_affine(geom: BaseGeometry, m: _Matrix, t: _Offset) -> BaseGeometry:
|
|
662
|
+
if m == _IDENTITY_M and t == _ZERO_T:
|
|
663
|
+
return geom
|
|
664
|
+
a, b, d, e = m
|
|
665
|
+
return affinity.affine_transform(geom, [a, b, d, e, t[0], t[1]])
|