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,203 @@
1
+ """Change attribution: match expanded ops between revisions and classify.
2
+
3
+ Three-stage algorithm:
4
+
5
+ 1. **Exact cancellation** -- ops with identical content signatures (same
6
+ world geometry, polarity, kind) are unchanged. This typically covers
7
+ the vast majority of ops and costs only hashing.
8
+ 2. **Gated geometric matching** -- remaining ops are pooled by (kind,
9
+ polarity) and matched A->B by centroid proximity within *gate_radius*
10
+ (KD-tree candidates, globally-greedy by distance for determinism).
11
+ 3. **Classification** of each matched pair:
12
+
13
+ - same dims, offset > move_tol -> ``moved``
14
+ - same dims, offset <= move_tol -> unchanged (float noise)
15
+ - dims changed -> ``resized`` (dx/dy still recorded)
16
+
17
+ "Same dims" = identical aperture signature *and* relative area delta
18
+ within *area_tol* (the area check distinguishes a stretched stroke from
19
+ a translated one).
20
+
21
+ Unmatched A-ops are ``removed``; unmatched B-ops are ``added``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ from collections import Counter
28
+ from dataclasses import dataclass
29
+
30
+ import numpy as np
31
+ from scipy.spatial import cKDTree
32
+
33
+ from gerberdiff.geometry.layer_geometry import ExpandedOp
34
+ from gerberdiff.geometry.types import ChangeKind
35
+
36
+ # Classification kinds (public result vocabulary).
37
+ KIND_ADDED: ChangeKind = "added"
38
+ KIND_REMOVED: ChangeKind = "removed"
39
+ KIND_MOVED: ChangeKind = "moved"
40
+ KIND_RESIZED: ChangeKind = "resized"
41
+
42
+
43
+ @dataclass
44
+ class OpChange:
45
+ """One attributed change between revisions (engine-internal record)."""
46
+
47
+ kind: ChangeKind
48
+ before: ExpandedOp | None
49
+ after: ExpandedOp | None
50
+ dx: float = 0.0 # inches (after - before), 0 when one side is absent
51
+ dy: float = 0.0
52
+
53
+
54
+ @dataclass
55
+ class Partition:
56
+ """Result of exact-cancellation matching between two op lists."""
57
+
58
+ unchanged_a: list[ExpandedOp]
59
+ unchanged_b: list[ExpandedOp]
60
+ a_only: list[ExpandedOp]
61
+ b_only: list[ExpandedOp]
62
+
63
+
64
+ def partition_unchanged(
65
+ a_ops: list[ExpandedOp],
66
+ b_ops: list[ExpandedOp],
67
+ ) -> Partition:
68
+ """Multiset-match ops by content signature (stage 1)."""
69
+ counts_a = Counter(op.signature for op in a_ops)
70
+ counts_b = Counter(op.signature for op in b_ops)
71
+ shared = counts_a & counts_b
72
+
73
+ def _split(
74
+ ops: list[ExpandedOp], budget: Counter[str]
75
+ ) -> tuple[list[ExpandedOp], list[ExpandedOp]]:
76
+ unchanged: list[ExpandedOp] = []
77
+ only: list[ExpandedOp] = []
78
+ for op in ops:
79
+ if budget[op.signature] > 0:
80
+ budget[op.signature] -= 1
81
+ unchanged.append(op)
82
+ else:
83
+ only.append(op)
84
+ return unchanged, only
85
+
86
+ unchanged_a, a_only = _split(a_ops, Counter(shared))
87
+ unchanged_b, b_only = _split(b_ops, Counter(shared))
88
+ return Partition(unchanged_a=unchanged_a, unchanged_b=unchanged_b, a_only=a_only, b_only=b_only)
89
+
90
+
91
+ def attribute_changes(
92
+ parts: Partition,
93
+ *,
94
+ move_tol: float,
95
+ gate_radius: float,
96
+ area_tol: float,
97
+ ) -> tuple[list[OpChange], int]:
98
+ """Stages 2+3: match and classify the non-identical ops.
99
+
100
+ All distances in inches. Returns ``(changes, unchanged_count)`` where
101
+ *unchanged_count* includes both exact-signature matches and matched
102
+ pairs whose offset fell below *move_tol*.
103
+ """
104
+ unchanged_count = len(parts.unchanged_a)
105
+ changes: list[OpChange] = []
106
+
107
+ matched_a: set[int] = set()
108
+ matched_b: set[int] = set()
109
+
110
+ # Pool by (kind, polarity): a flash never matches a stroke, dark never
111
+ # matches clear.
112
+ pools: dict[tuple[str, str], tuple[list[int], list[int]]] = {}
113
+ for i, op in enumerate(parts.a_only):
114
+ pools.setdefault((op.kind, op.polarity.value), ([], []))[0].append(i)
115
+ for j, op in enumerate(parts.b_only):
116
+ pools.setdefault((op.kind, op.polarity.value), ([], []))[1].append(j)
117
+
118
+ for a_idx, b_idx in pools.values():
119
+ if not a_idx or not b_idx:
120
+ continue
121
+ pairs = _match_pool(parts, a_idx, b_idx, gate_radius, matched_a, matched_b)
122
+ for a_op, b_op in pairs:
123
+ dx = b_op.centroid_x - a_op.centroid_x
124
+ dy = b_op.centroid_y - a_op.centroid_y
125
+ offset = math.hypot(dx, dy)
126
+ if _dims_same(a_op, b_op, area_tol):
127
+ if offset <= move_tol:
128
+ unchanged_count += 1
129
+ else:
130
+ changes.append(OpChange(kind=KIND_MOVED, before=a_op, after=b_op, dx=dx, dy=dy))
131
+ elif offset <= move_tol and a_op.geom.equals(b_op.geom):
132
+ # Different aperture description, identical geometry (e.g. a
133
+ # square obround re-declared as a circle): not a change.
134
+ unchanged_count += 1
135
+ else:
136
+ changes.append(OpChange(kind=KIND_RESIZED, before=a_op, after=b_op, dx=dx, dy=dy))
137
+
138
+ for i, op in enumerate(parts.a_only):
139
+ if i not in matched_a:
140
+ changes.append(OpChange(kind=KIND_REMOVED, before=op, after=None))
141
+ for j, op in enumerate(parts.b_only):
142
+ if j not in matched_b:
143
+ changes.append(OpChange(kind=KIND_ADDED, before=None, after=op))
144
+
145
+ return changes, unchanged_count
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Internal helpers
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def _match_pool(
154
+ parts: Partition,
155
+ a_indices: list[int],
156
+ b_indices: list[int],
157
+ gate_radius: float,
158
+ matched_a: set[int],
159
+ matched_b: set[int],
160
+ ) -> list[tuple[ExpandedOp, ExpandedOp]]:
161
+ """Greedy global matching by centroid distance within *gate_radius*."""
162
+ pairs_out: list[tuple[ExpandedOp, ExpandedOp]] = []
163
+ a_pts = [(parts.a_only[i].centroid_x, parts.a_only[i].centroid_y) for i in a_indices]
164
+ b_pts = [(parts.b_only[j].centroid_x, parts.b_only[j].centroid_y) for j in b_indices]
165
+ tree = cKDTree(b_pts)
166
+
167
+ k = min(8, len(b_pts))
168
+ candidates: list[tuple[float, int, int]] = []
169
+ for ai_local, pt in enumerate(a_pts):
170
+ d_raw, i_raw = tree.query(pt, k=k, distance_upper_bound=gate_radius)
171
+ # query on a single point returns scalars for k=1, 1-D arrays otherwise.
172
+ d_arr = np.atleast_1d(np.asarray(d_raw, dtype=float))
173
+ i_arr = np.atleast_1d(np.asarray(i_raw, dtype=int))
174
+ for d, j_local in zip(d_arr, i_arr, strict=True):
175
+ if math.isinf(float(d)) or int(j_local) >= len(b_pts):
176
+ continue
177
+ candidates.append((float(d), ai_local, int(j_local)))
178
+
179
+ # Sort by distance (deterministic index tie-break), accept greedily.
180
+ candidates.sort(key=lambda c: (c[0], c[1], c[2]))
181
+ used_a_local: set[int] = set()
182
+ used_b_local: set[int] = set()
183
+ for _d, ai_local, bj_local in candidates:
184
+ if ai_local in used_a_local or bj_local in used_b_local:
185
+ continue
186
+ used_a_local.add(ai_local)
187
+ used_b_local.add(bj_local)
188
+ ai = a_indices[ai_local]
189
+ bj = b_indices[bj_local]
190
+ matched_a.add(ai)
191
+ matched_b.add(bj)
192
+ pairs_out.append((parts.a_only[ai], parts.b_only[bj]))
193
+ return pairs_out
194
+
195
+
196
+ def _dims_same(a: ExpandedOp, b: ExpandedOp, area_tol: float) -> bool:
197
+ """Same dimensions modulo orientation (a rotated pad is still 'moved')."""
198
+ if a.dims_signature != b.dims_signature:
199
+ return False
200
+ biggest = max(a.area, b.area)
201
+ if biggest <= 0.0:
202
+ return True
203
+ return abs(a.area - b.area) / biggest <= area_tol
@@ -0,0 +1,227 @@
1
+ """Directory-level geometry diff driver.
2
+
3
+ ``compute_geometry_diff`` is the geometry-engine counterpart of
4
+ ``diff_engine.compute_full_diff``: it pairs layer files with
5
+ ``match_layers``, parses both revisions, expands geometry, runs the boolean
6
+ diff and attribution per layer, and assembles a
7
+ :class:`~gerberdiff.geometry.types.GeometryDiffResult`.
8
+
9
+ The geometry pipeline is Cairo-free.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable, Sequence
15
+ from pathlib import Path
16
+
17
+ from gerberdiff.geometry.attribute import OpChange, attribute_changes, partition_unchanged
18
+ from gerberdiff.geometry.geom_diff import boolean_layer_diff
19
+ from gerberdiff.geometry.layer_geometry import (
20
+ LayerGeometry,
21
+ build_layer_geometry,
22
+ resolve_geometry,
23
+ )
24
+ from gerberdiff.geometry.types import (
25
+ MM2_PER_IN2,
26
+ GeometryChange,
27
+ GeometryDiffResult,
28
+ LayerGeometryDiff,
29
+ )
30
+ from gerberdiff.types import (
31
+ Diagnostic,
32
+ DiagnosticSeverity,
33
+ GerberParseError,
34
+ LayerStatus,
35
+ LayerType,
36
+ ParsedImage,
37
+ )
38
+
39
+ # Default tolerances (user-facing units; see docs/geometry-diff.md).
40
+ DEFAULT_MOVE_TOL_MM = 0.005 # 5 um: below this a matched pair is "unchanged"
41
+ DEFAULT_GATE_RADIUS_MM = 0.2 # max centroid distance to pair two ops
42
+ DEFAULT_AREA_TOL = 0.01 # 1% relative area delta still counts as same dims
43
+ DEFAULT_DUST_AREA_MM2 = 1e-6 # boolean-diff components below this are noise
44
+
45
+ _MM_PER_IN = 25.4
46
+
47
+
48
+ def compute_geometry_diff(
49
+ before_dir: Path,
50
+ after_dir: Path,
51
+ *,
52
+ layers: Sequence[str] | None = None,
53
+ move_tol_mm: float = DEFAULT_MOVE_TOL_MM,
54
+ gate_radius_mm: float = DEFAULT_GATE_RADIUS_MM,
55
+ area_tol: float = DEFAULT_AREA_TOL,
56
+ dust_area_mm2: float = DEFAULT_DUST_AREA_MM2,
57
+ on_diagnostic: Callable[[Path, Diagnostic], None] | None = None,
58
+ ) -> GeometryDiffResult:
59
+ """Geometry-diff two directories of Gerber/Excellon layer files.
60
+
61
+ Parameters
62
+ ----------
63
+ before_dir, after_dir:
64
+ Directories containing the before and after layer files.
65
+ layers:
66
+ If given, only layers whose names appear in this sequence are diffed.
67
+ move_tol_mm:
68
+ Minimum centroid displacement (mm) for a matched pair to be reported
69
+ as ``moved`` rather than unchanged.
70
+ gate_radius_mm:
71
+ Maximum centroid distance (mm) at which two ops can be considered
72
+ the same object.
73
+ area_tol:
74
+ Relative area delta within which two matched ops still count as the
75
+ same dimensions (distinguishes ``moved`` from ``resized``).
76
+ dust_area_mm2:
77
+ Boolean-difference components smaller than this (mm^2) are dropped
78
+ as numeric noise.
79
+ on_diagnostic:
80
+ Called with ``(path, diagnostic)`` for every non-fatal parse or
81
+ expansion diagnostic.
82
+
83
+ Raises
84
+ ------
85
+ GerberParseError
86
+ When a file contains a fatal (``Error``-severity) parse diagnostic.
87
+ OSError
88
+ When a layer file cannot be read.
89
+ """
90
+ from gerberdiff.diff.layer_matcher import EXCELLON_SUFFIXES, match_layers
91
+ from gerberdiff.parse.excellon_parser import parse_excellon
92
+ from gerberdiff.parse.gerber_state import parse_gerber
93
+
94
+ move_tol_in = move_tol_mm / _MM_PER_IN
95
+ gate_radius_in = gate_radius_mm / _MM_PER_IN
96
+ dust_area_in2 = dust_area_mm2 / MM2_PER_IN2
97
+
98
+ def _parse(path: Path) -> ParsedImage:
99
+ content = path.read_text(errors="replace")
100
+ if path.suffix.lower() in EXCELLON_SUFFIXES:
101
+ img = parse_excellon(content, source_path=path)
102
+ else:
103
+ img = parse_gerber(content, source_path=path)
104
+ for diag in img.diagnostics:
105
+ if diag.severity == DiagnosticSeverity.Error:
106
+ raise GerberParseError(path, diag.message, diag.line)
107
+ if on_diagnostic is not None:
108
+ on_diagnostic(path, diag)
109
+ return img
110
+
111
+ def _build(path: Path) -> LayerGeometry:
112
+ geometry = build_layer_geometry(_parse(path))
113
+ if on_diagnostic is not None:
114
+ for diag in geometry.diagnostics:
115
+ on_diagnostic(path, diag)
116
+ return geometry
117
+
118
+ pairs = match_layers(before_dir, after_dir)
119
+ if layers is not None:
120
+ pairs = [p for p in pairs if p.name in layers]
121
+
122
+ result = GeometryDiffResult()
123
+ for pair in pairs:
124
+ if pair.status in (LayerStatus.Added, LayerStatus.Removed):
125
+ src_path = pair.after_path if pair.status == LayerStatus.Added else pair.before_path
126
+ assert src_path is not None # invariant guaranteed by match_layers
127
+ geometry = _build(src_path)
128
+ total_mm2 = resolve_geometry(geometry.ops).area * MM2_PER_IN2
129
+ result.layers.append(
130
+ LayerGeometryDiff(
131
+ name=pair.name,
132
+ layer_type=pair.layer_type,
133
+ status=pair.status,
134
+ added_area_mm2=total_mm2 if pair.status == LayerStatus.Added else 0.0,
135
+ removed_area_mm2=total_mm2 if pair.status == LayerStatus.Removed else 0.0,
136
+ )
137
+ )
138
+ continue
139
+
140
+ assert pair.before_path is not None and pair.after_path is not None
141
+ geom_a = _build(pair.before_path)
142
+ geom_b = _build(pair.after_path)
143
+ result.layers.append(
144
+ _diff_layer_pair(
145
+ pair.name,
146
+ pair.layer_type,
147
+ geom_a,
148
+ geom_b,
149
+ move_tol_in=move_tol_in,
150
+ gate_radius_in=gate_radius_in,
151
+ area_tol=area_tol,
152
+ dust_area_in2=dust_area_in2,
153
+ )
154
+ )
155
+
156
+ return result
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Internal helpers
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def _diff_layer_pair(
165
+ name: str,
166
+ layer_type: LayerType,
167
+ geom_a: LayerGeometry,
168
+ geom_b: LayerGeometry,
169
+ *,
170
+ move_tol_in: float,
171
+ gate_radius_in: float,
172
+ area_tol: float,
173
+ dust_area_in2: float,
174
+ ) -> LayerGeometryDiff:
175
+ parts = partition_unchanged(geom_a.ops, geom_b.ops)
176
+
177
+ added_geom, removed_geom = boolean_layer_diff(
178
+ geom_a,
179
+ geom_b,
180
+ parts.a_only,
181
+ parts.b_only,
182
+ parts.unchanged_a,
183
+ dust_area=dust_area_in2,
184
+ )
185
+
186
+ op_changes, unchanged_count = attribute_changes(
187
+ parts,
188
+ move_tol=move_tol_in,
189
+ gate_radius=gate_radius_in,
190
+ area_tol=area_tol,
191
+ )
192
+
193
+ changes = [_to_public_change(c) for c in op_changes]
194
+ # Top-of-board first (descending Y), then left-to-right -- matches the
195
+ # raster engine's region ordering.
196
+ changes.sort(key=lambda c: (-c.centroid_y, c.centroid_x))
197
+
198
+ return LayerGeometryDiff(
199
+ name=name,
200
+ layer_type=layer_type,
201
+ status=LayerStatus.Matched,
202
+ changes=changes,
203
+ unchanged_count=unchanged_count,
204
+ added_area_mm2=added_geom.area * MM2_PER_IN2,
205
+ removed_area_mm2=removed_geom.area * MM2_PER_IN2,
206
+ )
207
+
208
+
209
+ def _to_public_change(change: OpChange) -> GeometryChange:
210
+ """Convert an engine-internal OpChange to the public GeometryChange."""
211
+ primary = change.after if change.after is not None else change.before
212
+ assert primary is not None # every OpChange has at least one side
213
+ moved_or_resized = change.before is not None and change.after is not None
214
+ return GeometryChange(
215
+ kind=change.kind,
216
+ op_kind=primary.kind,
217
+ centroid_x=primary.centroid_x,
218
+ centroid_y=primary.centroid_y,
219
+ area_mm2=primary.area * MM2_PER_IN2,
220
+ dx_mm=change.dx * _MM_PER_IN if moved_or_resized else None,
221
+ dy_mm=change.dy * _MM_PER_IN if moved_or_resized else None,
222
+ net_name=primary.net_name,
223
+ before_op=change.before.source if change.before is not None else None,
224
+ after_op=change.after.source if change.after is not None else None,
225
+ before_geom=change.before.geom if change.before is not None else None,
226
+ after_geom=change.after.geom if change.after is not None else None,
227
+ )
@@ -0,0 +1,232 @@
1
+ """Expand individual IR draw operations into shapely geometry.
2
+
3
+ Each function returns ``(geometry, diagnostics)``. Geometry is in the
4
+ operation's own coordinate space (inches); layer transforms and
5
+ step-and-repeat are applied later by ``layer_geometry``.
6
+
7
+ Fidelity notes
8
+ --------------
9
+ - Round-aperture strokes are exact (capsule = ``LineString.buffer``).
10
+ - Non-round convex apertures on **linear** strokes are exact: the Minkowski
11
+ sum of a segment with a convex shape is the convex hull of the shape placed
12
+ at both endpoints.
13
+ - Non-round apertures on **arc** strokes fall back to a round brush of
14
+ radius ``max(w, h) / 2`` with an Info diagnostic (matches the raster
15
+ engine's documented approximation for that case).
16
+ - Aperture holes are subtracted from the flash shape only. They do *not*
17
+ erase underlying image content (Gerber spec semantics; the raster engine's
18
+ ``DEST_OUT`` punch is a known compositing shortcut).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from shapely.geometry import LineString, Polygon
24
+ from shapely.geometry.base import BaseGeometry
25
+ from shapely.ops import unary_union
26
+ from shapely.validation import make_valid
27
+
28
+ from gerberdiff.geometry.macro_geom import macro_flash_geometry
29
+ from gerberdiff.geometry.primitives import (
30
+ arc_points,
31
+ circle,
32
+ obround,
33
+ rectangle,
34
+ regular_polygon,
35
+ )
36
+ from gerberdiff.types import (
37
+ Aperture,
38
+ ApertureState,
39
+ BlockAperture,
40
+ CircleAperture,
41
+ Diagnostic,
42
+ DiagnosticSeverity,
43
+ DrawOp,
44
+ MacroAperture,
45
+ ObroundAperture,
46
+ PolygonAperture,
47
+ RectangleAperture,
48
+ RegionFill,
49
+ )
50
+
51
+ _EMPTY: BaseGeometry = Polygon()
52
+ _NO_DIAGS: list[Diagnostic] = []
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Simple aperture outline (shared by flash and stroke expansion)
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def _aperture_outline(ap: Aperture, x: float, y: float) -> BaseGeometry | None:
61
+ """The filled outline of a simple aperture centred at (x, y), no hole."""
62
+ match ap:
63
+ case CircleAperture():
64
+ if ap.diameter <= 0.0:
65
+ return None
66
+ return circle(x, y, ap.diameter / 2.0)
67
+ case RectangleAperture():
68
+ if ap.width <= 0.0 or ap.height <= 0.0:
69
+ return None
70
+ return rectangle(x, y, ap.width, ap.height)
71
+ case ObroundAperture():
72
+ if ap.width <= 0.0 or ap.height <= 0.0:
73
+ return None
74
+ return obround(x, y, ap.width, ap.height)
75
+ case PolygonAperture():
76
+ if ap.outer_diameter <= 0.0 or ap.num_vertices < 3:
77
+ return None
78
+ return regular_polygon(x, y, ap.outer_diameter / 2.0, ap.num_vertices, ap.rotation)
79
+ return None
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Flash (D03)
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def flash_geometry(
88
+ op: DrawOp,
89
+ ap: Aperture | None,
90
+ ) -> tuple[BaseGeometry, list[Diagnostic]]:
91
+ """Expand a flash at ``(op.stop_x, op.stop_y)``.
92
+
93
+ Block apertures are handled by ``layer_geometry`` (they flatten into the
94
+ replay sequence); passing one here returns empty geometry.
95
+ """
96
+ if ap is None or isinstance(ap, BlockAperture):
97
+ return _EMPTY, _NO_DIAGS
98
+
99
+ x, y = op.stop_x, op.stop_y
100
+
101
+ if isinstance(ap, MacroAperture):
102
+ return macro_flash_geometry(ap, x, y)
103
+
104
+ shape = _aperture_outline(ap, x, y)
105
+ if shape is None:
106
+ return _EMPTY, _NO_DIAGS
107
+
108
+ hole = ap.hole_diameter
109
+ if hole is not None and hole > 0.0:
110
+ shape = shape.difference(circle(x, y, hole / 2.0))
111
+ return shape, _NO_DIAGS
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Stroke (D01)
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def stroke_geometry(
120
+ op: DrawOp,
121
+ ap: Aperture | None,
122
+ ) -> tuple[BaseGeometry, list[Diagnostic]]:
123
+ """Expand a D01 stroke (linear or arc) into its swept filled shape."""
124
+ if ap is None:
125
+ return _EMPTY, _NO_DIAGS
126
+
127
+ if op.arc_segment is not None:
128
+ return _arc_stroke(op, ap)
129
+ return _linear_stroke(op, ap)
130
+
131
+
132
+ def _linear_stroke(op: DrawOp, ap: Aperture) -> tuple[BaseGeometry, list[Diagnostic]]:
133
+ start = (op.start_x, op.start_y)
134
+ stop = (op.stop_x, op.stop_y)
135
+ degenerate = start == stop
136
+
137
+ if isinstance(ap, CircleAperture):
138
+ if ap.diameter <= 0.0:
139
+ return _EMPTY, _NO_DIAGS
140
+ r = ap.diameter / 2.0
141
+ if degenerate:
142
+ return circle(*stop, r), _NO_DIAGS
143
+ return LineString([start, stop]).buffer(r), _NO_DIAGS
144
+
145
+ # Convex non-round aperture: exact Minkowski sum for a linear segment is
146
+ # the convex hull of the aperture placed at both endpoints.
147
+ shape_a = _aperture_outline(ap, *start)
148
+ if shape_a is None:
149
+ return _EMPTY, _NO_DIAGS
150
+ if degenerate:
151
+ return shape_a, _NO_DIAGS
152
+ shape_b = _aperture_outline(ap, *stop)
153
+ assert shape_b is not None # same aperture, same validity
154
+ return unary_union([shape_a, shape_b]).convex_hull, _NO_DIAGS
155
+
156
+
157
+ def _arc_stroke(op: DrawOp, ap: Aperture) -> tuple[BaseGeometry, list[Diagnostic]]:
158
+ arc = op.arc_segment
159
+ assert arc is not None
160
+ pts = arc_points(arc)
161
+
162
+ if isinstance(ap, CircleAperture):
163
+ if ap.diameter <= 0.0:
164
+ return _EMPTY, _NO_DIAGS
165
+ return LineString(pts).buffer(ap.diameter / 2.0), _NO_DIAGS
166
+
167
+ # Non-round aperture swept along an arc: approximate with a round brush.
168
+ width = _stroke_fallback_width(ap)
169
+ if width <= 0.0:
170
+ return _EMPTY, _NO_DIAGS
171
+ diag = Diagnostic(
172
+ severity=DiagnosticSeverity.Info,
173
+ message=(
174
+ f"arc stroke with {type(ap).__name__} approximated by a round "
175
+ f"brush of diameter {width:.6f} in"
176
+ ),
177
+ )
178
+ return LineString(pts).buffer(width / 2.0), [diag]
179
+
180
+
181
+ def _stroke_fallback_width(ap: Aperture) -> float:
182
+ """Brush width for non-round arc strokes (mirrors the raster engine)."""
183
+ match ap:
184
+ case RectangleAperture() | ObroundAperture():
185
+ return max(ap.width, ap.height)
186
+ case PolygonAperture():
187
+ return ap.outer_diameter
188
+ case _:
189
+ return 0.0
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Region fill (G36/G37)
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def region_geometry(region: RegionFill) -> tuple[BaseGeometry, list[Diagnostic]]:
198
+ """Expand a region fill into polygon geometry.
199
+
200
+ Contours are split on ``Off`` (D02 move) segments; arcs are sampled at
201
+ chord tolerance. Multiple contours combine with even-odd semantics
202
+ (matching the renderer's ``FILL_RULE_EVEN_ODD``).
203
+ """
204
+ contours: list[list[tuple[float, float]]] = []
205
+ current: list[tuple[float, float]] = []
206
+
207
+ for seg in region.segments:
208
+ if seg.aperture_state == ApertureState.Off:
209
+ if len(current) >= 3:
210
+ contours.append(current)
211
+ current = [(seg.stop_x, seg.stop_y)]
212
+ continue
213
+ if not current:
214
+ current = [(seg.start_x, seg.start_y)]
215
+ if seg.arc_segment is not None:
216
+ # Skip the first sample -- it coincides with the current endpoint.
217
+ current.extend(arc_points(seg.arc_segment)[1:])
218
+ else:
219
+ current.append((seg.stop_x, seg.stop_y))
220
+
221
+ if len(current) >= 3:
222
+ contours.append(current)
223
+
224
+ if not contours:
225
+ return _EMPTY, _NO_DIAGS
226
+
227
+ geom: BaseGeometry = _EMPTY
228
+ for contour in contours:
229
+ ring = make_valid(Polygon(contour))
230
+ # Even-odd combination: overlapping areas toggle.
231
+ geom = geom.symmetric_difference(ring)
232
+ return geom, _NO_DIAGS