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,153 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+
7
+
8
+ class TokenType(StrEnum):
9
+ G = "G"
10
+ D = "D"
11
+ M = "M"
12
+ X = "X"
13
+ Y = "Y"
14
+ I = "I" # noqa: E741
15
+ J = "J"
16
+ END_OF_BLOCK = "EOB"
17
+ EXTENDED = "EXTENDED"
18
+ COMMENT = "COMMENT"
19
+ EOF = "EOF"
20
+
21
+
22
+ @dataclass
23
+ class Token:
24
+ type: TokenType
25
+ value: int | str | None # int for G/D/M/X/Y/I/J; str for EXTENDED/COMMENT; None for EOB/EOF
26
+ line: int
27
+ # raw digit string for X/Y/I/J -- needed by convert_coordinate for trailing-zero omission
28
+ raw: str | None = field(default=None)
29
+
30
+
31
+ def tokenize_gerber(content: str) -> Generator[Token, None, None]:
32
+ """Yield tokens from a Gerber RS-274X file content string.
33
+
34
+ Handles:
35
+ - %...% extended command blocks (yields one EXTENDED token per *-terminated
36
+ command inside the block; the % delimiters are stripped)
37
+ - G04 comments (rest of block until * is the comment body)
38
+ - Coordinate words: X, Y, I, J with sign-aware integer values
39
+ - G, D, M codes with integer values
40
+ - Bare * end-of-block separators
41
+ - Whitespace and newlines outside extended blocks are silently skipped
42
+ """
43
+ pos = 0
44
+ line = 1
45
+ length = len(content)
46
+
47
+ def peek() -> str:
48
+ return content[pos] if pos < length else ""
49
+
50
+ def advance() -> str:
51
+ nonlocal pos, line
52
+ ch = content[pos]
53
+ pos += 1
54
+ if ch == "\n":
55
+ line += 1
56
+ return ch
57
+
58
+ def read_digits() -> tuple[str, int]:
59
+ """Consume an optional leading sign then digit characters.
60
+
61
+ Returns (raw_str, parsed_int). raw_str carries the sign (if any) and
62
+ all digit characters exactly as written -- needed for trailing-zero-omission
63
+ coordinate scaling. parsed_int is the signed integer value (0 on empty).
64
+ """
65
+ raw = ""
66
+ if peek() in ("+", "-"):
67
+ raw += advance()
68
+ while pos < length and content[pos].isdigit():
69
+ raw += advance()
70
+ value = int(raw, 10) if raw and raw not in ("+", "-") else 0
71
+ return raw, value
72
+
73
+ while pos < length:
74
+ ch = peek()
75
+
76
+ # Skip whitespace and newlines (newlines also increment `line` via advance)
77
+ if ch in (" ", "\t", "\r", "\n"):
78
+ advance()
79
+ continue
80
+
81
+ tok_line = line
82
+
83
+ if ch == "%":
84
+ advance() # consume opening %
85
+ buf = ""
86
+ while pos < length and peek() != "%":
87
+ c = peek()
88
+ if c == "*":
89
+ advance()
90
+ if buf:
91
+ yield Token(TokenType.EXTENDED, buf, tok_line)
92
+ buf = ""
93
+ elif c in ("\n", "\r"):
94
+ advance()
95
+ else:
96
+ buf += advance()
97
+ # Emit any trailing content before closing %
98
+ if buf:
99
+ yield Token(TokenType.EXTENDED, buf, tok_line)
100
+ if pos < length and peek() == "%":
101
+ advance() # consume closing %
102
+
103
+ elif ch == "G":
104
+ advance()
105
+ raw, num = read_digits()
106
+ if num == 4:
107
+ # G04: rest of block (until *) is a comment
108
+ comment = ""
109
+ while pos < length and peek() != "*":
110
+ comment += advance()
111
+ yield Token(TokenType.COMMENT, comment.strip(), tok_line)
112
+ else:
113
+ yield Token(TokenType.G, num, tok_line)
114
+
115
+ elif ch == "D":
116
+ advance()
117
+ _, num = read_digits()
118
+ yield Token(TokenType.D, num, tok_line)
119
+
120
+ elif ch == "M":
121
+ advance()
122
+ _, num = read_digits()
123
+ yield Token(TokenType.M, num, tok_line)
124
+
125
+ elif ch == "X":
126
+ advance()
127
+ raw, num = read_digits()
128
+ yield Token(TokenType.X, num, tok_line, raw)
129
+
130
+ elif ch == "Y":
131
+ advance()
132
+ raw, num = read_digits()
133
+ yield Token(TokenType.Y, num, tok_line, raw)
134
+
135
+ elif ch == "I":
136
+ advance()
137
+ raw, num = read_digits()
138
+ yield Token(TokenType.I, num, tok_line, raw)
139
+
140
+ elif ch == "J":
141
+ advance()
142
+ raw, num = read_digits()
143
+ yield Token(TokenType.J, num, tok_line, raw)
144
+
145
+ elif ch == "*":
146
+ advance()
147
+ yield Token(TokenType.END_OF_BLOCK, None, tok_line)
148
+
149
+ else:
150
+ # Unknown character -- skip silently (robustness for non-standard files)
151
+ advance()
152
+
153
+ yield Token(TokenType.EOF, None, line)
gerberdiff/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,240 @@
1
+ """Compile pass: group ParsedImage nets into batched draw groups.
2
+
3
+ The compile pass walks the flat nets list once and emits ``CompiledRender``
4
+ -- a list of ``CompiledLayer`` objects each holding ``CompiledGroup`` items
5
+ that the renderer can dispatch without re-inspecting every net.
6
+
7
+ Batching rules
8
+ --------------
9
+ * ``ApertureState.Flash`` + simple aperture (no hole) -> ``FlashBatch``
10
+ (grouped by aperture_code)
11
+ * ``ApertureState.Flash`` + holed aperture -> ``HoledFlash`` (one per net)
12
+ * ``ApertureState.Flash`` + ``MacroAperture`` -> ``MacroFlash`` (one per net)
13
+ * ``ApertureState.Flash`` + ``BlockAperture`` -> ``BlockFlash`` (one per net)
14
+ * ``ApertureState.On`` -> ``StrokeBatch`` (grouped by aperture_code)
15
+ * ``RegionFill`` -> ``RegionGroup``
16
+ * ``ApertureState.Off`` flushes any open stroke batch.
17
+
18
+ Layer boundaries flush all open batches.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Literal
25
+
26
+ from gerberdiff.types import (
27
+ ApertureState,
28
+ BlockAperture,
29
+ CircleAperture,
30
+ DrawOp,
31
+ MacroAperture,
32
+ MirrorState,
33
+ ObroundAperture,
34
+ ParsedImage,
35
+ Polarity,
36
+ PolygonAperture,
37
+ RectangleAperture,
38
+ RegionFill,
39
+ StepAndRepeat,
40
+ )
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Group dataclasses
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ @dataclass
48
+ class FlashBatch:
49
+ """Simple flashes sharing one aperture (no hole, no macro/block)."""
50
+
51
+ kind: Literal["flash_batch"] = field(default="flash_batch", init=False)
52
+ aperture_code: int = 0
53
+ nets: list[DrawOp] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class StrokeBatch:
58
+ """D01 strokes sharing one aperture."""
59
+
60
+ kind: Literal["stroke_batch"] = field(default="stroke_batch", init=False)
61
+ aperture_code: int = 0
62
+ nets: list[DrawOp] = field(default_factory=list)
63
+
64
+
65
+ @dataclass
66
+ class RegionGroup:
67
+ """Contiguous region fill bounded by G36/G37 markers."""
68
+
69
+ kind: Literal["region_group"] = field(default="region_group", init=False)
70
+ nets: list[DrawOp] = field(default_factory=list)
71
+
72
+
73
+ @dataclass
74
+ class HoledFlash:
75
+ """Single flash for an aperture with a punch-through hole."""
76
+
77
+ kind: Literal["holed_flash"] = field(default="holed_flash", init=False)
78
+ aperture_code: int = 0
79
+ net: DrawOp | None = None
80
+
81
+
82
+ @dataclass
83
+ class MacroFlash:
84
+ """Single flash for a macro aperture."""
85
+
86
+ kind: Literal["macro_flash"] = field(default="macro_flash", init=False)
87
+ aperture_code: int = 0
88
+ net: DrawOp | None = None
89
+
90
+
91
+ @dataclass
92
+ class BlockFlash:
93
+ """Single flash for a block aperture (rendered in Phase 14)."""
94
+
95
+ kind: Literal["block_flash"] = field(default="block_flash", init=False)
96
+ aperture_code: int = 0
97
+ net: DrawOp | None = None
98
+
99
+
100
+ CompiledGroup = FlashBatch | StrokeBatch | RegionGroup | HoledFlash | MacroFlash | BlockFlash
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Compiled layer / render containers
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ @dataclass
109
+ class CompiledLayer:
110
+ """One polarity layer with batched draw groups."""
111
+
112
+ layer_index: int = 0
113
+ step_and_repeat: StepAndRepeat = field(default_factory=StepAndRepeat)
114
+ polarity: Polarity = Polarity.Dark
115
+ mirror: MirrorState = MirrorState.None_
116
+ rotation: float = 0.0
117
+ scale: float = 1.0
118
+ groups: list[CompiledGroup] = field(default_factory=list)
119
+
120
+
121
+ @dataclass
122
+ class CompiledRender:
123
+ """Output of the compile pass, consumed by the renderer."""
124
+
125
+ layers: list[CompiledLayer] = field(default_factory=list)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Compile pass
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def compile_render(parsed_image: ParsedImage) -> CompiledRender:
134
+ """Walk parsed_image.nets once and produce a ``CompiledRender``.
135
+
136
+ The resulting structure is layer-ordered and batch-merged so the renderer
137
+ can operate without any aperture-classification logic.
138
+ """
139
+ compiled = CompiledRender()
140
+
141
+ # Build a CompiledLayer for every LayerState in the parsed image.
142
+ layer_map: dict[int, CompiledLayer] = {}
143
+ for i, ls in enumerate(parsed_image.layers):
144
+ cl = CompiledLayer(
145
+ layer_index=i,
146
+ step_and_repeat=ls.step_and_repeat,
147
+ polarity=ls.polarity,
148
+ mirror=ls.mirror,
149
+ rotation=ls.rotation,
150
+ scale=ls.scale,
151
+ )
152
+ layer_map[i] = cl
153
+ compiled.layers.append(cl)
154
+
155
+ # Mutable batching state (closed over by the flush helpers below).
156
+ pending_flash: FlashBatch | None = None
157
+ pending_stroke: StrokeBatch | None = None
158
+ # The layer that owns the two pending batches.
159
+ batch_layer: CompiledLayer | None = None
160
+
161
+ def _flush_flash() -> None:
162
+ nonlocal pending_flash
163
+ if pending_flash is not None and batch_layer is not None:
164
+ batch_layer.groups.append(pending_flash)
165
+ pending_flash = None
166
+
167
+ def _flush_stroke() -> None:
168
+ nonlocal pending_stroke
169
+ if pending_stroke is not None and batch_layer is not None:
170
+ batch_layer.groups.append(pending_stroke)
171
+ pending_stroke = None
172
+
173
+ def _switch_layer(new_layer: CompiledLayer) -> None:
174
+ nonlocal batch_layer
175
+ _flush_flash()
176
+ _flush_stroke()
177
+ batch_layer = new_layer
178
+
179
+ for item in parsed_image.draw_ops:
180
+ if isinstance(item, RegionFill):
181
+ layer = layer_map.get(item.layer_index)
182
+ if layer is not None:
183
+ _flush_flash()
184
+ _flush_stroke()
185
+ if item.segments:
186
+ layer.groups.append(RegionGroup(nets=list(item.segments)))
187
+ continue
188
+
189
+ net = item
190
+ layer = layer_map.get(net.layer_index)
191
+ if layer is None:
192
+ continue
193
+
194
+ # Flush open batches whenever the active layer changes.
195
+ if batch_layer is not layer:
196
+ _switch_layer(layer)
197
+
198
+ # ---- Flash (D03) ----
199
+ if net.aperture_state == ApertureState.Flash:
200
+ _flush_stroke()
201
+ ap = parsed_image.apertures.get(net.aperture_index)
202
+ if isinstance(ap, MacroAperture):
203
+ _flush_flash()
204
+ layer.groups.append(MacroFlash(aperture_code=net.aperture_index, net=net))
205
+ elif isinstance(ap, BlockAperture):
206
+ _flush_flash()
207
+ layer.groups.append(BlockFlash(aperture_code=net.aperture_index, net=net))
208
+ elif (
209
+ isinstance(
210
+ ap, (CircleAperture, RectangleAperture, ObroundAperture, PolygonAperture)
211
+ )
212
+ and ap.hole_diameter is not None
213
+ ):
214
+ _flush_flash()
215
+ layer.groups.append(HoledFlash(aperture_code=net.aperture_index, net=net))
216
+ else:
217
+ # Simple flash -- batch by aperture_code.
218
+ if pending_flash is None or pending_flash.aperture_code != net.aperture_index:
219
+ _flush_flash()
220
+ pending_flash = FlashBatch(aperture_code=net.aperture_index)
221
+ pending_flash.nets.append(net)
222
+
223
+ # ---- On (D01 draw) ----
224
+ elif net.aperture_state == ApertureState.On:
225
+ _flush_flash()
226
+ if pending_stroke is None or pending_stroke.aperture_code != net.aperture_index:
227
+ _flush_stroke()
228
+ pending_stroke = StrokeBatch(aperture_code=net.aperture_index)
229
+ pending_stroke.nets.append(net)
230
+
231
+ # ---- Off (D02 move) -- just flush open batches ----
232
+ elif net.aperture_state == ApertureState.Off:
233
+ _flush_flash()
234
+ _flush_stroke()
235
+
236
+ # Final flush.
237
+ _flush_flash()
238
+ _flush_stroke()
239
+
240
+ return compiled
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import cairocffi as cairo
6
+
7
+ from gerberdiff.types import (
8
+ Aperture,
9
+ ApertureState,
10
+ ArcSegment,
11
+ BlockAperture,
12
+ CircleAperture,
13
+ DrawOp,
14
+ InterpolationMode,
15
+ MacroAperture,
16
+ ObroundAperture,
17
+ PolygonAperture,
18
+ RectangleAperture,
19
+ )
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Arc
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def draw_arc_path(ctx: cairo.Context, arc: ArcSegment, clockwise: bool) -> None:
27
+ """Add an arc to the current path without stroking or filling.
28
+
29
+ Gerber clockwise arcs map to cairo's ``arc_negative`` because cairo
30
+ angles increase counter-clockwise.
31
+ """
32
+ start_rad = math.radians(arc.start_angle_deg)
33
+ end_rad = math.radians(arc.end_angle_deg)
34
+ if clockwise:
35
+ ctx.arc_negative(arc.center_x, arc.center_y, arc.radius, start_rad, end_rad)
36
+ else:
37
+ ctx.arc(arc.center_x, arc.center_y, arc.radius, start_rad, end_rad)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Region segments
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def draw_net_segment_in_region(ctx: cairo.Context, net: DrawOp) -> None:
46
+ """Add one net to the current region path (inside a G36..G37 block).
47
+
48
+ ``ApertureState.Off`` starts a new contour; arcs use :func:`draw_arc_path`;
49
+ linear moves become ``line_to``.
50
+ """
51
+ if net.aperture_state == ApertureState.Off:
52
+ ctx.move_to(net.stop_x, net.stop_y)
53
+ elif net.arc_segment is not None:
54
+ draw_arc_path(
55
+ ctx,
56
+ net.arc_segment,
57
+ net.interpolation == InterpolationMode.ClockwiseCircular,
58
+ )
59
+ else:
60
+ ctx.line_to(net.stop_x, net.stop_y)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Stroke
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def draw_net_as_stroke(ctx: cairo.Context, net: DrawOp, aperture: Aperture | None) -> None:
69
+ """Stroke a single ``On``-state net using the aperture's line width."""
70
+ if aperture is None:
71
+ return
72
+
73
+ match aperture:
74
+ case CircleAperture():
75
+ ctx.set_line_width(aperture.diameter)
76
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
77
+ case RectangleAperture():
78
+ # NOTE: max(w, h) is used as the stroke width so that the wide
79
+ # dimension is used for traces where the long axis aligns with the
80
+ # stroke direction (the common case). A fully correct Minkowski-sum
81
+ # stroke requires geometry-aware rendering; see
82
+ # docs/geometry-diff.md.
83
+ ctx.set_line_width(max(aperture.width, aperture.height))
84
+ ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
85
+ case ObroundAperture():
86
+ # NOTE: same approximation as RectangleAperture above.
87
+ ctx.set_line_width(max(aperture.width, aperture.height))
88
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
89
+ case PolygonAperture():
90
+ ctx.set_line_width(aperture.outer_diameter)
91
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
92
+ case _:
93
+ ctx.set_line_width(0.001)
94
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
95
+
96
+ ctx.new_path()
97
+ ctx.move_to(net.start_x, net.start_y)
98
+ if net.arc_segment is not None:
99
+ draw_arc_path(
100
+ ctx,
101
+ net.arc_segment,
102
+ net.interpolation == InterpolationMode.ClockwiseCircular,
103
+ )
104
+ else:
105
+ ctx.line_to(net.stop_x, net.stop_y)
106
+ ctx.stroke()
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Flash
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ def draw_flash(ctx: cairo.Context, net: DrawOp, aperture: Aperture | None) -> None:
115
+ """Draw a flash (D03) at ``net.stop_x``, ``net.stop_y``.
116
+
117
+ Macro and Block apertures are handled by the renderer directly
118
+ (``macro_renderer`` / block render pass) so they are silently skipped here.
119
+ """
120
+ if aperture is None:
121
+ return
122
+
123
+ x, y = net.stop_x, net.stop_y
124
+ match aperture:
125
+ case CircleAperture():
126
+ _draw_circle_flash(ctx, x, y, aperture)
127
+ case RectangleAperture():
128
+ _draw_rectangle_flash(ctx, x, y, aperture)
129
+ case ObroundAperture():
130
+ _draw_obround_flash(ctx, x, y, aperture)
131
+ case PolygonAperture():
132
+ _draw_polygon_flash(ctx, x, y, aperture)
133
+ case MacroAperture() | BlockAperture():
134
+ pass # deferred to renderer
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Private helpers -- each takes a concrete aperture type for mypy narrowing
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def _draw_hole(ctx: cairo.Context, x: float, y: float, hole_diameter: float) -> None:
143
+ """Punch a circular hole using DEST_OUT compositing."""
144
+ ctx.save()
145
+ ctx.set_operator(cairo.OPERATOR_DEST_OUT)
146
+ ctx.new_path()
147
+ ctx.arc(x, y, hole_diameter / 2.0, 0.0, 2.0 * math.pi)
148
+ ctx.fill()
149
+ ctx.restore()
150
+
151
+
152
+ def _draw_circle_flash(ctx: cairo.Context, x: float, y: float, ap: CircleAperture) -> None:
153
+ ctx.new_path()
154
+ ctx.arc(x, y, ap.diameter / 2.0, 0.0, 2.0 * math.pi)
155
+ ctx.fill()
156
+ if ap.hole_diameter is not None:
157
+ _draw_hole(ctx, x, y, ap.hole_diameter)
158
+
159
+
160
+ def _draw_rectangle_flash(ctx: cairo.Context, x: float, y: float, ap: RectangleAperture) -> None:
161
+ w2, h2 = ap.width / 2.0, ap.height / 2.0
162
+ ctx.new_path()
163
+ ctx.rectangle(x - w2, y - h2, ap.width, ap.height)
164
+ ctx.fill()
165
+ if ap.hole_diameter is not None:
166
+ _draw_hole(ctx, x, y, ap.hole_diameter)
167
+
168
+
169
+ def _draw_obround_flash(ctx: cairo.Context, x: float, y: float, ap: ObroundAperture) -> None:
170
+ """Obround: rectangle with semicircular ends on the short axis."""
171
+ w, h = ap.width, ap.height
172
+ r = min(w, h) / 2.0
173
+ w2, h2 = w / 2.0, h / 2.0
174
+ ctx.new_path()
175
+ if w >= h:
176
+ # Horizontal obround
177
+ ctx.arc(x + w2 - r, y, r, -math.pi / 2.0, math.pi / 2.0)
178
+ ctx.arc(x - w2 + r, y, r, math.pi / 2.0, 3.0 * math.pi / 2.0)
179
+ else:
180
+ # Vertical obround
181
+ ctx.arc(x, y + h2 - r, r, 0.0, math.pi)
182
+ ctx.arc(x, y - h2 + r, r, math.pi, 2.0 * math.pi)
183
+ ctx.close_path()
184
+ ctx.fill()
185
+ if ap.hole_diameter is not None:
186
+ _draw_hole(ctx, x, y, ap.hole_diameter)
187
+
188
+
189
+ def _draw_polygon_flash(ctx: cairo.Context, x: float, y: float, ap: PolygonAperture) -> None:
190
+ r = ap.outer_diameter / 2.0
191
+ n = ap.num_vertices
192
+ rot = math.radians(ap.rotation)
193
+ ctx.new_path()
194
+ for i in range(n):
195
+ angle = rot + 2.0 * math.pi * i / n
196
+ px = x + r * math.cos(angle)
197
+ py = y + r * math.sin(angle)
198
+ if i == 0:
199
+ ctx.move_to(px, py)
200
+ else:
201
+ ctx.line_to(px, py)
202
+ ctx.close_path()
203
+ ctx.fill()
204
+ if ap.hole_diameter is not None:
205
+ _draw_hole(ctx, x, y, ap.hole_diameter)