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.
Files changed (38) hide show
  1. gerberdiff/__init__.py +86 -0
  2. gerberdiff/cli.py +527 -0
  3. gerberdiff/diff/__init__.py +0 -0
  4. gerberdiff/diff/diff_engine.py +409 -0
  5. gerberdiff/diff/layer_matcher.py +202 -0
  6. gerberdiff/export/__init__.py +0 -0
  7. gerberdiff/export/json_report.py +184 -0
  8. gerberdiff/export/png_export.py +92 -0
  9. gerberdiff/export/svg_export.py +187 -0
  10. gerberdiff/geometry/__init__.py +37 -0
  11. gerberdiff/geometry/attribute.py +203 -0
  12. gerberdiff/geometry/driver.py +227 -0
  13. gerberdiff/geometry/expand.py +232 -0
  14. gerberdiff/geometry/geom_diff.py +153 -0
  15. gerberdiff/geometry/layer_geometry.py +665 -0
  16. gerberdiff/geometry/macro_geom.py +215 -0
  17. gerberdiff/geometry/primitives.py +108 -0
  18. gerberdiff/geometry/types.py +85 -0
  19. gerberdiff/parse/__init__.py +0 -0
  20. gerberdiff/parse/arc_math.py +162 -0
  21. gerberdiff/parse/excellon_parser.py +338 -0
  22. gerberdiff/parse/gerber_parser.py +244 -0
  23. gerberdiff/parse/gerber_state.py +780 -0
  24. gerberdiff/parse/macro_parser.py +604 -0
  25. gerberdiff/parse/tokenizer.py +153 -0
  26. gerberdiff/py.typed +0 -0
  27. gerberdiff/render/__init__.py +0 -0
  28. gerberdiff/render/compiled_render.py +240 -0
  29. gerberdiff/render/draw_ops.py +205 -0
  30. gerberdiff/render/macro_renderer.py +343 -0
  31. gerberdiff/render/renderer.py +283 -0
  32. gerberdiff/render/viewport.py +85 -0
  33. gerberdiff/types.py +360 -0
  34. gerberdiff-0.29.0.dist-info/METADATA +105 -0
  35. gerberdiff-0.29.0.dist-info/RECORD +38 -0
  36. gerberdiff-0.29.0.dist-info/WHEEL +4 -0
  37. gerberdiff-0.29.0.dist-info/entry_points.txt +2 -0
  38. 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]])