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,215 @@
1
+ """Macro aperture geometry: evaluated macro primitives -> shapely.
2
+
3
+ Mirrors ``render/macro_renderer.py`` primitive-by-primitive, with two
4
+ deliberate divergences (geometry follows the Gerber spec where the raster
5
+ engine takes compositing shortcuts):
6
+
7
+ 1. **Exposure scope** -- an exposure-0 primitive erases *within the macro
8
+ flash only* (spec), whereas the raster engine's ``DEST_OUT`` erases
9
+ underlying canvas content globally.
10
+ 2. **Rotation centre** -- primitive rotation is applied to the *whole
11
+ primitive* around the macro origin (spec), whereas the raster engine
12
+ rotates some primitives (21, 5, 6, 7) around their own centre. The two
13
+ agree in the overwhelmingly common cases (rotation 0 or centre at origin).
14
+
15
+ A macro that fails to evaluate contributes no geometry and a ``Warning``
16
+ diagnostic (matching the renderer's behaviour).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+
23
+ from shapely import affinity
24
+ from shapely.geometry import Polygon
25
+ from shapely.geometry.base import BaseGeometry
26
+
27
+ from gerberdiff.geometry.primitives import circle, rectangle, regular_polygon
28
+ from gerberdiff.parse.macro_parser import (
29
+ EvaluatedCircle,
30
+ EvaluatedLineCenter,
31
+ EvaluatedLineVector,
32
+ EvaluatedMoire,
33
+ EvaluatedOutline,
34
+ EvaluatedPolygon,
35
+ EvaluatedPrimitive,
36
+ EvaluatedThermal,
37
+ evaluate_macro_primitives,
38
+ )
39
+ from gerberdiff.types import Diagnostic, DiagnosticSeverity, MacroAperture
40
+
41
+ _EMPTY: BaseGeometry = Polygon()
42
+
43
+
44
+ def macro_flash_geometry(
45
+ aperture: MacroAperture,
46
+ x: float,
47
+ y: float,
48
+ ) -> tuple[BaseGeometry, list[Diagnostic]]:
49
+ """Expand a macro aperture flash at world position (x, y).
50
+
51
+ Returns the composed geometry (possibly empty) and any diagnostics.
52
+ Exposure-on primitives union into the accumulator; exposure-off
53
+ primitives subtract from it (within the macro only).
54
+ """
55
+ if aperture.macro_def is None:
56
+ return _EMPTY, []
57
+ try:
58
+ primitives = evaluate_macro_primitives(aperture.macro_def, aperture.params)
59
+ except Exception as exc:
60
+ return _EMPTY, [
61
+ Diagnostic(
62
+ severity=DiagnosticSeverity.Warning,
63
+ message=f"Macro '{aperture.macro_def.name}' evaluation failed: {exc}",
64
+ )
65
+ ]
66
+
67
+ acc: BaseGeometry = _EMPTY
68
+ for p in primitives:
69
+ geom = _primitive_geometry(p)
70
+ if geom is None or geom.is_empty:
71
+ continue
72
+ if _exposure_off(p):
73
+ acc = acc.difference(geom)
74
+ else:
75
+ acc = acc.union(geom)
76
+
77
+ if acc.is_empty:
78
+ return _EMPTY, []
79
+
80
+ # Macro coords -> world: scale to inches, then translate to the flash.
81
+ scale = aperture.unit_scale
82
+ if scale != 1.0:
83
+ acc = affinity.scale(acc, xfact=scale, yfact=scale, origin=(0, 0))
84
+ return affinity.translate(acc, xoff=x, yoff=y), []
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Per-primitive geometry (macro-local coordinates, unscaled)
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def _exposure_off(p: EvaluatedPrimitive) -> bool:
93
+ exposure = getattr(p, "exposure", 1.0)
94
+ return bool(exposure == 0.0)
95
+
96
+
97
+ def _rotated(geom: BaseGeometry, rotation_deg: float) -> BaseGeometry:
98
+ """Rotate a primitive around the macro origin (spec semantics)."""
99
+ if rotation_deg == 0.0 or geom.is_empty:
100
+ return geom
101
+ return affinity.rotate(geom, rotation_deg, origin=(0, 0))
102
+
103
+
104
+ def _primitive_geometry(p: EvaluatedPrimitive) -> BaseGeometry | None:
105
+ match p:
106
+ case EvaluatedCircle():
107
+ if p.diameter <= 0.0:
108
+ return None
109
+ return _rotated(circle(p.center_x, p.center_y, p.diameter / 2.0), p.rotation)
110
+
111
+ case EvaluatedLineVector():
112
+ return _line_vector(p)
113
+
114
+ case EvaluatedLineCenter():
115
+ if p.width <= 0.0 or p.height <= 0.0:
116
+ return None
117
+ return _rotated(rectangle(p.center_x, p.center_y, p.width, p.height), p.rotation)
118
+
119
+ case EvaluatedOutline():
120
+ if len(p.vertices) < 6: # need at least 3 points
121
+ return None
122
+ pts = [(p.vertices[i], p.vertices[i + 1]) for i in range(0, len(p.vertices) - 1, 2)]
123
+ return _rotated(Polygon(pts), p.rotation)
124
+
125
+ case EvaluatedPolygon():
126
+ if p.diameter <= 0.0 or p.num_vertices < 3:
127
+ return None
128
+ return _rotated(
129
+ regular_polygon(p.center_x, p.center_y, p.diameter / 2.0, p.num_vertices),
130
+ p.rotation,
131
+ )
132
+
133
+ case EvaluatedMoire():
134
+ return _moire(p)
135
+
136
+ case EvaluatedThermal():
137
+ return _thermal(p)
138
+
139
+ return None # pragma: no cover -- exhaustive match above
140
+
141
+
142
+ def _line_vector(p: EvaluatedLineVector) -> BaseGeometry | None:
143
+ """Rectangle spanning start->end with the given width."""
144
+ if p.width <= 0.0:
145
+ return None
146
+ dx = p.end_x - p.start_x
147
+ dy = p.end_y - p.start_y
148
+ length = math.hypot(dx, dy)
149
+ if length == 0.0:
150
+ return None
151
+ nx = -dy / length * (p.width / 2.0)
152
+ ny = dx / length * (p.width / 2.0)
153
+ quad = Polygon(
154
+ [
155
+ (p.start_x + nx, p.start_y + ny),
156
+ (p.start_x - nx, p.start_y - ny),
157
+ (p.end_x - nx, p.end_y - ny),
158
+ (p.end_x + nx, p.end_y + ny),
159
+ ]
160
+ )
161
+ return _rotated(quad, p.rotation)
162
+
163
+
164
+ def _moire(p: EvaluatedMoire) -> BaseGeometry | None:
165
+ """Concentric rings plus a two-bar crosshair."""
166
+ outer_r = p.outer_diameter / 2.0
167
+ if outer_r <= 0.0:
168
+ return None
169
+
170
+ acc: BaseGeometry = _EMPTY
171
+ max_rings = p.max_rings if p.max_rings > 0 else 100
172
+ for i in range(max_rings):
173
+ r_outer = outer_r - i * (p.ring_thickness + p.ring_gap)
174
+ if r_outer <= 0.0:
175
+ break
176
+ ring: BaseGeometry = circle(p.center_x, p.center_y, r_outer)
177
+ r_inner = r_outer - p.ring_thickness
178
+ if r_inner > 0.0:
179
+ ring = ring.difference(circle(p.center_x, p.center_y, r_inner))
180
+ acc = acc.union(ring)
181
+
182
+ cl = p.crosshair_length / 2.0
183
+ ct = p.crosshair_thickness / 2.0
184
+ if cl > 0.0 and ct > 0.0:
185
+ h_bar = rectangle(p.center_x, p.center_y, cl * 2.0, ct * 2.0)
186
+ v_bar = rectangle(p.center_x, p.center_y, ct * 2.0, cl * 2.0)
187
+ acc = acc.union(h_bar).union(v_bar)
188
+
189
+ if acc.is_empty:
190
+ return None
191
+ return _rotated(acc, p.rotation)
192
+
193
+
194
+ def _thermal(p: EvaluatedThermal) -> BaseGeometry | None:
195
+ """Annulus with four rectangular gaps at 0/90/180/270 degrees."""
196
+ r_outer = p.outer_diameter / 2.0
197
+ if r_outer <= 0.0:
198
+ return None
199
+
200
+ acc: BaseGeometry = circle(p.center_x, p.center_y, r_outer)
201
+ r_inner = p.inner_diameter / 2.0
202
+ if r_inner > 0.0:
203
+ acc = acc.difference(circle(p.center_x, p.center_y, r_inner))
204
+
205
+ gap_w = p.gap / 2.0
206
+ if gap_w > 0.0:
207
+ # Bars need only span the annulus (2*r_outer); oversizing to 4*r_outer
208
+ # avoids exact edge-tangency in the boolean difference.
209
+ h_gap = rectangle(p.center_x, p.center_y, r_outer * 4.0, gap_w * 2.0)
210
+ v_gap = rectangle(p.center_x, p.center_y, gap_w * 2.0, r_outer * 4.0)
211
+ acc = acc.difference(h_gap).difference(v_gap)
212
+
213
+ if acc.is_empty:
214
+ return None
215
+ return _rotated(acc, p.rotation)
@@ -0,0 +1,108 @@
1
+ """Low-level shapely shape builders shared by the geometry engine.
2
+
3
+ All coordinates are in **inches** (the IR convention). Circle and arc
4
+ tessellation is adaptive: segment counts are derived from a chord (sagitta)
5
+ tolerance so small pads stay cheap while large features keep fidelity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ from shapely.geometry import LineString, Point, Polygon, box
13
+ from shapely.geometry.base import BaseGeometry
14
+
15
+ from gerberdiff.types import ArcSegment
16
+
17
+ # Chord (sagitta) tolerance for circle/arc tessellation: 1 um in inches.
18
+ CHORD_TOL_IN = 1e-3 / 25.4
19
+
20
+ # Bounds for tessellation segment counts (full circle).
21
+ _MIN_SEGMENTS = 16
22
+ _MAX_SEGMENTS = 256
23
+
24
+
25
+ def circle_segments(radius: float, tol: float = CHORD_TOL_IN) -> int:
26
+ """Segment count for a full circle of *radius* with sagitta <= *tol*.
27
+
28
+ sagitta = r * (1 - cos(pi/n)) => n >= pi / acos(1 - tol/r)
29
+ """
30
+ if radius <= tol:
31
+ return _MIN_SEGMENTS
32
+ n = math.pi / math.acos(1.0 - tol / radius)
33
+ return max(_MIN_SEGMENTS, min(_MAX_SEGMENTS, math.ceil(n)))
34
+
35
+
36
+ def _quad_segs(radius: float) -> int:
37
+ """quad_segs value for shapely ``buffer`` (segments per quarter circle)."""
38
+ return max(4, math.ceil(circle_segments(radius) / 4))
39
+
40
+
41
+ def circle(x: float, y: float, radius: float) -> BaseGeometry:
42
+ """Filled circle centred at (x, y)."""
43
+ return Point(x, y).buffer(radius, quad_segs=_quad_segs(radius))
44
+
45
+
46
+ def rectangle(x: float, y: float, width: float, height: float) -> BaseGeometry:
47
+ """Axis-aligned filled rectangle centred at (x, y)."""
48
+ w2, h2 = width / 2.0, height / 2.0
49
+ return box(x - w2, y - h2, x + w2, y + h2)
50
+
51
+
52
+ def obround(x: float, y: float, width: float, height: float) -> BaseGeometry:
53
+ """Obround (stadium/capsule): rectangle with semicircular short ends."""
54
+ r = min(width, height) / 2.0
55
+ if width == height:
56
+ return circle(x, y, r)
57
+ if width > height:
58
+ a = (x - (width / 2.0 - r), y)
59
+ b = (x + (width / 2.0 - r), y)
60
+ else:
61
+ a = (x, y - (height / 2.0 - r))
62
+ b = (x, y + (height / 2.0 - r))
63
+ return LineString([a, b]).buffer(r, quad_segs=_quad_segs(r))
64
+
65
+
66
+ def regular_polygon(
67
+ x: float,
68
+ y: float,
69
+ outer_radius: float,
70
+ num_vertices: int,
71
+ rotation_deg: float = 0.0,
72
+ ) -> BaseGeometry:
73
+ """Regular n-gon centred at (x, y); first vertex at *rotation_deg*.
74
+
75
+ Matches the renderer's vertex placement (``_draw_polygon_flash``).
76
+ """
77
+ rot = math.radians(rotation_deg)
78
+ pts = [
79
+ (
80
+ x + outer_radius * math.cos(rot + 2.0 * math.pi * i / num_vertices),
81
+ y + outer_radius * math.sin(rot + 2.0 * math.pi * i / num_vertices),
82
+ )
83
+ for i in range(num_vertices)
84
+ ]
85
+ return Polygon(pts)
86
+
87
+
88
+ def arc_points(arc: ArcSegment, tol: float = CHORD_TOL_IN) -> list[tuple[float, float]]:
89
+ """Sample an :class:`ArcSegment` into a polyline (including both endpoints).
90
+
91
+ The ArcSegment convention (``arc_math.py``) is directional and monotonic:
92
+ CCW arcs have ``end_angle_deg > start_angle_deg``; CW arcs have
93
+ ``end_angle_deg < start_angle_deg``. The sweep is traversed directly --
94
+ no wraparound handling is required.
95
+ """
96
+ sweep = arc.end_angle_deg - arc.start_angle_deg
97
+ n_full = circle_segments(arc.radius, tol)
98
+ n = max(2, math.ceil(n_full * abs(sweep) / 360.0))
99
+ pts: list[tuple[float, float]] = []
100
+ for i in range(n + 1):
101
+ theta = math.radians(arc.start_angle_deg + sweep * i / n)
102
+ pts.append(
103
+ (
104
+ arc.center_x + arc.radius * math.cos(theta),
105
+ arc.center_y + arc.radius * math.sin(theta),
106
+ )
107
+ )
108
+ return pts
@@ -0,0 +1,85 @@
1
+ """Public result types for the geometry diff engine.
2
+
3
+ Unit conventions (documented in ``docs/schema.md``):
4
+
5
+ - ``centroid_x`` / ``centroid_y`` are in **inches** (matching the raster
6
+ engine's ``Region`` convention),
7
+ - areas are in **mm^2**,
8
+ - ``dx_mm`` / ``dy_mm`` displacements are in **mm**.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Literal
15
+
16
+ from gerberdiff.types import DrawOp, LayerStatus, LayerType, RegionFill
17
+
18
+ if TYPE_CHECKING:
19
+ from shapely.geometry.base import BaseGeometry
20
+
21
+ ChangeKind = Literal["added", "removed", "moved", "resized"]
22
+
23
+ # mm^2 per square inch.
24
+ MM2_PER_IN2 = 25.4 * 25.4
25
+
26
+
27
+ @dataclass
28
+ class GeometryChange:
29
+ """One attributed change on a layer.
30
+
31
+ ``centroid_*`` and ``area_mm2`` describe the after-state object when one
32
+ exists (added/moved/resized) and the before-state object otherwise
33
+ (removed). ``before_geom`` / ``after_geom`` carry the shapely polygons
34
+ for programmatic/SVG use; they are excluded from ``repr`` and JSON.
35
+ """
36
+
37
+ kind: ChangeKind
38
+ op_kind: str # flash | stroke | region
39
+ centroid_x: float # inches
40
+ centroid_y: float # inches
41
+ area_mm2: float
42
+ dx_mm: float | None = None # moved/resized: after - before
43
+ dy_mm: float | None = None
44
+ net_name: str | None = None
45
+ before_op: DrawOp | RegionFill | None = field(default=None, repr=False)
46
+ after_op: DrawOp | RegionFill | None = field(default=None, repr=False)
47
+ before_geom: BaseGeometry | None = field(default=None, repr=False)
48
+ after_geom: BaseGeometry | None = field(default=None, repr=False)
49
+
50
+
51
+ @dataclass
52
+ class LayerGeometryDiff:
53
+ """Geometry diff result for one matched/added/removed layer pair."""
54
+
55
+ name: str
56
+ layer_type: LayerType
57
+ status: LayerStatus # matched | added | removed
58
+ changes: list[GeometryChange] = field(default_factory=list)
59
+ unchanged_count: int = 0
60
+ added_area_mm2: float = 0.0
61
+ removed_area_mm2: float = 0.0
62
+
63
+ def count(self, kind: ChangeKind) -> int:
64
+ """Number of changes of the given *kind*."""
65
+ return sum(1 for c in self.changes if c.kind == kind)
66
+
67
+ @property
68
+ def has_changes(self) -> bool:
69
+ return (
70
+ bool(self.changes)
71
+ or self.status != LayerStatus.Matched
72
+ or self.added_area_mm2 > 0.0
73
+ or self.removed_area_mm2 > 0.0
74
+ )
75
+
76
+
77
+ @dataclass
78
+ class GeometryDiffResult:
79
+ """Full geometry diff across all matched layers."""
80
+
81
+ layers: list[LayerGeometryDiff] = field(default_factory=list)
82
+
83
+ @property
84
+ def has_changes(self) -> bool:
85
+ return any(layer.has_changes for layer in self.layers)
File without changes
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ from gerberdiff.types import ArcSegment, BoundingBox
6
+
7
+ _RAD_TO_DEG = 180.0 / math.pi
8
+
9
+
10
+ def compute_arc_multi_quadrant(
11
+ start_x: float,
12
+ start_y: float,
13
+ end_x: float,
14
+ end_y: float,
15
+ i: float,
16
+ j: float,
17
+ clockwise: bool,
18
+ ) -> ArcSegment | None:
19
+ """Compute arc geometry for G75 (multi-quadrant) mode.
20
+
21
+ (i, j) are signed offsets from the start point to the arc centre.
22
+ The arc can sweep any angle up to 360deg.
23
+
24
+ Returns None for a degenerate arc (radius < 1e-10).
25
+ """
26
+ center_x = start_x + i
27
+ center_y = start_y + j
28
+ radius = math.hypot(start_x - center_x, start_y - center_y)
29
+
30
+ if radius < 1e-10:
31
+ return None
32
+
33
+ start_angle = math.atan2(start_y - center_y, start_x - center_x) * _RAD_TO_DEG
34
+ end_angle = math.atan2(end_y - center_y, end_x - center_x) * _RAD_TO_DEG
35
+
36
+ # Full-circle: start and end coincide
37
+ if math.hypot(start_x - end_x, start_y - end_y) < 1e-10:
38
+ end_angle = start_angle - 360.0 if clockwise else start_angle + 360.0
39
+ else:
40
+ if clockwise and end_angle >= start_angle:
41
+ end_angle -= 360.0
42
+ elif not clockwise and end_angle <= start_angle:
43
+ end_angle += 360.0
44
+
45
+ return ArcSegment(
46
+ center_x=center_x,
47
+ center_y=center_y,
48
+ radius=radius,
49
+ start_angle_deg=start_angle,
50
+ end_angle_deg=end_angle,
51
+ )
52
+
53
+
54
+ def compute_arc_single_quadrant(
55
+ start_x: float,
56
+ start_y: float,
57
+ end_x: float,
58
+ end_y: float,
59
+ i: float,
60
+ j: float,
61
+ clockwise: bool,
62
+ ) -> ArcSegment | None:
63
+ """Compute arc geometry for G74 (single-quadrant) mode.
64
+
65
+ In G74 mode the magnitude of (i, j) is always positive but the sign is
66
+ implicit. Try all four sign combinations and keep the candidate where the
67
+ arc sweep is <= 90deg (single-quadrant constraint), preferring the one with
68
+ the smallest start-to-end radius mismatch.
69
+
70
+ Returns None if no valid candidate is found.
71
+ """
72
+ abs_i = abs(i)
73
+ abs_j = abs(j)
74
+
75
+ best: ArcSegment | None = None
76
+ best_error = math.inf
77
+
78
+ for sign_i, sign_j in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
79
+ cx = start_x + sign_i * abs_i
80
+ cy = start_y + sign_j * abs_j
81
+
82
+ r_start = math.hypot(start_x - cx, start_y - cy)
83
+ if r_start < 1e-10:
84
+ continue
85
+ r_end = math.hypot(end_x - cx, end_y - cy)
86
+
87
+ # Normalise angles to [0, 360)
88
+ sa = math.atan2(start_y - cy, start_x - cx) * _RAD_TO_DEG % 360.0
89
+ ea = math.atan2(end_y - cy, end_x - cx) * _RAD_TO_DEG % 360.0
90
+
91
+ # Compute the arc sweep in the intended direction
92
+ if clockwise:
93
+ sweep = sa - ea
94
+ if sweep <= 0:
95
+ sweep += 360.0
96
+ else:
97
+ sweep = ea - sa
98
+ if sweep <= 0:
99
+ sweep += 360.0
100
+
101
+ # G74 constraint: arc must stay within a single quadrant (<= 90deg)
102
+ if sweep > 90.5:
103
+ continue
104
+
105
+ error = abs(r_start - r_end)
106
+ if error < best_error:
107
+ best_error = error
108
+ # Full-circle degenerate case
109
+ if math.hypot(start_x - end_x, start_y - end_y) < 1e-10:
110
+ end_a = sa - 360.0 if clockwise else sa + 360.0
111
+ else:
112
+ end_a = ea
113
+ best = ArcSegment(
114
+ center_x=cx,
115
+ center_y=cy,
116
+ radius=r_start,
117
+ start_angle_deg=sa,
118
+ end_angle_deg=end_a,
119
+ )
120
+
121
+ return best
122
+
123
+
124
+ def arc_bounding_box(arc: ArcSegment, aperture_radius: float = 0.0) -> BoundingBox:
125
+ """Return the axis-aligned bounding box of an arc segment.
126
+
127
+ Correctly handles multi-quadrant arcs by checking whether any of the four
128
+ axis-extrema angles (0, 90, 180, 270 degrees) fall within the swept range.
129
+
130
+ *aperture_radius* is added as padding on all sides (half the trace width).
131
+ """
132
+ bb = BoundingBox()
133
+ cx, cy, r = arc.center_x, arc.center_y, arc.radius
134
+
135
+ # Expand by both endpoints.
136
+ for theta_deg in (arc.start_angle_deg, arc.end_angle_deg):
137
+ theta_rad = math.radians(theta_deg)
138
+ bb.expand(cx + r * math.cos(theta_rad), cy + r * math.sin(theta_rad), aperture_radius)
139
+
140
+ # Determine the angular range covered by the arc. The ArcSegment convention
141
+ # (set by compute_arc_multi_quadrant / compute_arc_single_quadrant) is:
142
+ # CCW arcs: end_angle_deg > start_angle_deg
143
+ # CW arcs: end_angle_deg < start_angle_deg
144
+ # So [lo, hi] = [min, max] covers the swept interval regardless of direction.
145
+ lo = min(arc.start_angle_deg, arc.end_angle_deg)
146
+ hi = max(arc.start_angle_deg, arc.end_angle_deg)
147
+
148
+ # For each axis extremum (0deg, 90deg, 180deg, 270deg), check whether any integer
149
+ # multiple of 360deg shifted copy of that angle falls in [lo, hi]. If so,
150
+ # the arc passes through that extremum and we must expand the bbox.
151
+ for axis_angle in (0.0, 90.0, 180.0, 270.0):
152
+ k_start = math.ceil((lo - axis_angle) / 360.0)
153
+ for k in range(k_start, k_start + 4):
154
+ candidate = axis_angle + 360.0 * k
155
+ if lo <= candidate <= hi:
156
+ theta_rad = math.radians(axis_angle)
157
+ bb.expand(
158
+ cx + r * math.cos(theta_rad), cy + r * math.sin(theta_rad), aperture_radius
159
+ )
160
+ break
161
+
162
+ return bb