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,343 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import warnings
5
+
6
+ import cairocffi as cairo
7
+
8
+ from gerberdiff.parse.macro_parser import (
9
+ EvaluatedCircle,
10
+ EvaluatedLineCenter,
11
+ EvaluatedLineVector,
12
+ EvaluatedMoire,
13
+ EvaluatedOutline,
14
+ EvaluatedPolygon,
15
+ EvaluatedThermal,
16
+ evaluate_macro_primitives,
17
+ )
18
+ from gerberdiff.types import MacroAperture
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Public API
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def draw_macro_flash(
26
+ ctx: cairo.Context,
27
+ x: float,
28
+ y: float,
29
+ aperture: MacroAperture,
30
+ ) -> None:
31
+ """Draw a macro aperture flash at world position (x, y).
32
+
33
+ Evaluates all primitives in the macro definition and draws each using
34
+ cairocffi path operations. Primitive exposure 0 -> DEST_OUT (erase);
35
+ exposure 1 -> OPERATOR_OVER (add). All dimensions are scaled by
36
+ ``aperture.unit_scale``.
37
+ """
38
+ if aperture.macro_def is None:
39
+ return
40
+ try:
41
+ primitives = evaluate_macro_primitives(aperture.macro_def, aperture.params)
42
+ except Exception as exc:
43
+ warnings.warn(
44
+ f"Macro '{aperture.macro_def.name}' evaluation failed: {exc}",
45
+ UserWarning,
46
+ stacklevel=2,
47
+ )
48
+ return
49
+
50
+ scale = aperture.unit_scale
51
+ for p in primitives:
52
+ match p:
53
+ case EvaluatedCircle():
54
+ _draw_circle(ctx, x, y, p, scale)
55
+ case EvaluatedLineVector():
56
+ _draw_line_vector(ctx, x, y, p, scale)
57
+ case EvaluatedLineCenter():
58
+ _draw_line_center(ctx, x, y, p, scale)
59
+ case EvaluatedOutline():
60
+ _draw_outline(ctx, x, y, p, scale)
61
+ case EvaluatedPolygon():
62
+ _draw_polygon(ctx, x, y, p, scale)
63
+ case EvaluatedMoire():
64
+ _draw_moire(ctx, x, y, p, scale)
65
+ case EvaluatedThermal():
66
+ _draw_thermal(ctx, x, y, p, scale)
67
+
68
+
69
+ def compute_macro_bounding_radius(aperture: MacroAperture) -> float:
70
+ """Estimate the bounding radius of a macro aperture in world units.
71
+
72
+ Returns the maximum reach from the origin across all evaluated primitives,
73
+ multiplied by ``aperture.unit_scale``. Returns 0.0 if the macro cannot
74
+ be evaluated.
75
+ """
76
+ if aperture.macro_def is None:
77
+ return 0.0
78
+ try:
79
+ primitives = evaluate_macro_primitives(aperture.macro_def, aperture.params)
80
+ except Exception: # pragma: no cover
81
+ return 0.0
82
+
83
+ max_r = 0.0
84
+ for p in primitives:
85
+ r: float = 0.0
86
+ match p:
87
+ case EvaluatedCircle():
88
+ r = math.hypot(p.center_x, p.center_y) + p.diameter / 2.0
89
+ case EvaluatedPolygon():
90
+ r = math.hypot(p.center_x, p.center_y) + p.diameter / 2.0
91
+ case EvaluatedLineVector():
92
+ r = (
93
+ max(
94
+ math.hypot(p.start_x, p.start_y),
95
+ math.hypot(p.end_x, p.end_y),
96
+ )
97
+ + p.width / 2.0
98
+ )
99
+ case EvaluatedLineCenter():
100
+ r = math.hypot(p.center_x, p.center_y) + math.hypot(p.width / 2.0, p.height / 2.0)
101
+ case EvaluatedMoire():
102
+ r = math.hypot(p.center_x, p.center_y) + p.outer_diameter / 2.0
103
+ case EvaluatedThermal():
104
+ r = math.hypot(p.center_x, p.center_y) + p.outer_diameter / 2.0
105
+ case EvaluatedOutline():
106
+ verts = p.vertices
107
+ if len(verts) >= 2:
108
+ r = max(math.hypot(verts[i], verts[i + 1]) for i in range(0, len(verts) - 1, 2))
109
+ if r > max_r:
110
+ max_r = r
111
+
112
+ return max_r * aperture.unit_scale
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Exposure helper
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def _apply_exposure(ctx: cairo.Context, exposure: float) -> None:
121
+ if exposure == 0.0:
122
+ ctx.set_operator(cairo.OPERATOR_DEST_OUT)
123
+ else:
124
+ ctx.set_operator(cairo.OPERATOR_OVER)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Per-primitive drawing helpers
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _draw_circle(ctx: cairo.Context, x: float, y: float, p: EvaluatedCircle, scale: float) -> None:
133
+ cx = x + p.center_x * scale
134
+ cy = y + p.center_y * scale
135
+ r = (p.diameter / 2.0) * scale
136
+ if r <= 0.0:
137
+ return
138
+ ctx.save()
139
+ _apply_exposure(ctx, p.exposure)
140
+ ctx.new_path()
141
+ ctx.arc(cx, cy, r, 0.0, 2.0 * math.pi)
142
+ ctx.fill()
143
+ ctx.restore()
144
+
145
+
146
+ def _draw_line_vector(
147
+ ctx: cairo.Context, x: float, y: float, p: EvaluatedLineVector, scale: float
148
+ ) -> None:
149
+ """Rotated rectangle spanning start->end with width p.width."""
150
+ sx = x + p.start_x * scale
151
+ sy = y + p.start_y * scale
152
+ ex = x + p.end_x * scale
153
+ ey = y + p.end_y * scale
154
+ w2 = (p.width / 2.0) * scale
155
+ dx = ex - sx
156
+ dy = ey - sy
157
+ length = math.hypot(dx, dy)
158
+ if length == 0.0:
159
+ return
160
+ # Perpendicular unit vector scaled to half-width
161
+ nx = -dy / length * w2
162
+ ny = dx / length * w2
163
+ # Overall primitive rotation applied around the flash origin (x, y)
164
+ rot = math.radians(p.rotation)
165
+ cos_r = math.cos(rot)
166
+ sin_r = math.sin(rot)
167
+
168
+ def _rot(px: float, py: float) -> tuple[float, float]:
169
+ rx = x + cos_r * (px - x) - sin_r * (py - y)
170
+ ry = y + sin_r * (px - x) + cos_r * (py - y)
171
+ return rx, ry
172
+
173
+ corners = [
174
+ _rot(sx + nx, sy + ny),
175
+ _rot(sx - nx, sy - ny),
176
+ _rot(ex - nx, ey - ny),
177
+ _rot(ex + nx, ey + ny),
178
+ ]
179
+ ctx.save()
180
+ _apply_exposure(ctx, p.exposure)
181
+ ctx.new_path()
182
+ ctx.move_to(*corners[0])
183
+ for corner_x, corner_y in corners[1:]:
184
+ ctx.line_to(corner_x, corner_y)
185
+ ctx.close_path()
186
+ ctx.fill()
187
+ ctx.restore()
188
+
189
+
190
+ def _draw_line_center(
191
+ ctx: cairo.Context, x: float, y: float, p: EvaluatedLineCenter, scale: float
192
+ ) -> None:
193
+ """Centred rectangle, optionally rotated."""
194
+ center_x = x + p.center_x * scale
195
+ center_y = y + p.center_y * scale
196
+ w2 = (p.width / 2.0) * scale
197
+ h2 = (p.height / 2.0) * scale
198
+ ctx.save()
199
+ _apply_exposure(ctx, p.exposure)
200
+ ctx.translate(center_x, center_y)
201
+ ctx.rotate(math.radians(p.rotation))
202
+ ctx.new_path()
203
+ ctx.rectangle(-w2, -h2, p.width * scale, p.height * scale)
204
+ ctx.fill()
205
+ ctx.restore()
206
+
207
+
208
+ def _draw_outline(
209
+ ctx: cairo.Context, x: float, y: float, p: EvaluatedOutline, scale: float
210
+ ) -> None:
211
+ """Arbitrary polygon from flat vertex list [x0, y0, x1, y1, ...]."""
212
+ if len(p.vertices) < 4:
213
+ return
214
+ verts = [v * scale for v in p.vertices]
215
+ ctx.save()
216
+ _apply_exposure(ctx, p.exposure)
217
+ ctx.translate(x, y)
218
+ ctx.rotate(math.radians(p.rotation))
219
+ ctx.new_path()
220
+ for i in range(0, len(verts) - 1, 2):
221
+ vx, vy = verts[i], verts[i + 1]
222
+ if i == 0:
223
+ ctx.move_to(vx, vy)
224
+ else:
225
+ ctx.line_to(vx, vy)
226
+ ctx.close_path()
227
+ ctx.fill()
228
+ ctx.restore()
229
+
230
+
231
+ def _draw_polygon(
232
+ ctx: cairo.Context, x: float, y: float, p: EvaluatedPolygon, scale: float
233
+ ) -> None:
234
+ """Regular n-sided polygon."""
235
+ center_x = x + p.center_x * scale
236
+ center_y = y + p.center_y * scale
237
+ r = (p.diameter / 2.0) * scale
238
+ if r <= 0.0 or p.num_vertices < 3:
239
+ return
240
+ rot = math.radians(p.rotation)
241
+ ctx.save()
242
+ _apply_exposure(ctx, p.exposure)
243
+ ctx.new_path()
244
+ for i in range(p.num_vertices):
245
+ angle = rot + 2.0 * math.pi * i / p.num_vertices
246
+ vx = center_x + r * math.cos(angle)
247
+ vy = center_y + r * math.sin(angle)
248
+ if i == 0:
249
+ ctx.move_to(vx, vy)
250
+ else:
251
+ ctx.line_to(vx, vy)
252
+ ctx.close_path()
253
+ ctx.fill()
254
+ ctx.restore()
255
+
256
+
257
+ def _draw_moire(ctx: cairo.Context, x: float, y: float, p: EvaluatedMoire, scale: float) -> None:
258
+ """Concentric rings plus crosshair."""
259
+ cx = x + p.center_x * scale
260
+ cy = y + p.center_y * scale
261
+ outer_r = (p.outer_diameter / 2.0) * scale
262
+ gap = p.ring_gap * scale
263
+ thickness = p.ring_thickness * scale
264
+ rot = math.radians(p.rotation)
265
+
266
+ max_rings = p.max_rings if p.max_rings > 0 else 100
267
+ for i in range(max_rings):
268
+ r_outer = outer_r - i * (thickness + gap)
269
+ r_inner = r_outer - thickness
270
+ if r_outer <= 0.0:
271
+ break
272
+ ctx.save()
273
+ ctx.set_operator(cairo.OPERATOR_OVER)
274
+ ctx.new_path()
275
+ ctx.arc(cx, cy, r_outer, 0.0, 2.0 * math.pi)
276
+ ctx.fill()
277
+ ctx.restore()
278
+ if r_inner > 0.0:
279
+ ctx.save()
280
+ ctx.set_operator(cairo.OPERATOR_DEST_OUT)
281
+ ctx.new_path()
282
+ ctx.arc(cx, cy, r_inner, 0.0, 2.0 * math.pi)
283
+ ctx.fill()
284
+ ctx.restore()
285
+
286
+ # Crosshair: two perpendicular bars
287
+ cl = (p.crosshair_length / 2.0) * scale
288
+ ct = (p.crosshair_thickness / 2.0) * scale
289
+ if cl > 0.0 and ct > 0.0:
290
+ for bar_rot in (rot, rot + math.pi / 2.0):
291
+ ctx.save()
292
+ ctx.set_operator(cairo.OPERATOR_OVER)
293
+ ctx.translate(cx, cy)
294
+ ctx.rotate(bar_rot)
295
+ ctx.new_path()
296
+ ctx.rectangle(-cl, -ct, cl * 2.0, ct * 2.0)
297
+ ctx.fill()
298
+ ctx.restore()
299
+
300
+
301
+ def _draw_thermal(
302
+ ctx: cairo.Context, x: float, y: float, p: EvaluatedThermal, scale: float
303
+ ) -> None:
304
+ """Annulus (ring) with four rectangular anti-pad gaps."""
305
+ cx = x + p.center_x * scale
306
+ cy = y + p.center_y * scale
307
+ r_outer = (p.outer_diameter / 2.0) * scale
308
+ r_inner = (p.inner_diameter / 2.0) * scale
309
+ gap_w = (p.gap / 2.0) * scale
310
+ rot = math.radians(p.rotation)
311
+
312
+ if r_outer <= 0.0:
313
+ return
314
+
315
+ # 1. Filled outer circle
316
+ ctx.save()
317
+ ctx.set_operator(cairo.OPERATOR_OVER)
318
+ ctx.new_path()
319
+ ctx.arc(cx, cy, r_outer, 0.0, 2.0 * math.pi)
320
+ ctx.fill()
321
+ ctx.restore()
322
+
323
+ # 2. Cut inner circle -> leaves the ring
324
+ if r_inner > 0.0:
325
+ ctx.save()
326
+ ctx.set_operator(cairo.OPERATOR_DEST_OUT)
327
+ ctx.new_path()
328
+ ctx.arc(cx, cy, r_inner, 0.0, 2.0 * math.pi)
329
+ ctx.fill()
330
+ ctx.restore()
331
+
332
+ # 3. Cut 4 rectangular gaps at 0deg, 90deg, 180deg, 270deg + rotation
333
+ if gap_w > 0.0:
334
+ for i in range(4):
335
+ angle = rot + i * math.pi / 2.0
336
+ ctx.save()
337
+ ctx.set_operator(cairo.OPERATOR_DEST_OUT)
338
+ ctx.translate(cx, cy)
339
+ ctx.rotate(angle)
340
+ ctx.new_path()
341
+ ctx.rectangle(-r_outer, -gap_w, r_outer * 2.0, gap_w * 2.0)
342
+ ctx.fill()
343
+ ctx.restore()
@@ -0,0 +1,283 @@
1
+ """Two-pass rasteriser: compile ParsedImage -> render to a Cairo surface.
2
+
3
+ Usage
4
+ -----
5
+ >>> from gerberdiff.render.renderer import render_to_surface, render_to_numpy
6
+ >>> from gerberdiff.render.viewport import compute_viewport
7
+ >>> vp = compute_viewport(parsed.bounding_box, width=1024, height=1024)
8
+ >>> surface = render_to_surface(parsed, vp)
9
+ >>> arr = render_to_numpy(parsed, vp) # shape (H, W, 4) uint8 BGRA
10
+
11
+ Polarity
12
+ --------
13
+ Layers with ``Polarity.Dark`` are composited with OPERATOR_OVER.
14
+ Layers with ``Polarity.Clear`` are composited with OPERATOR_DEST_OUT, which
15
+ punches holes into previously drawn content.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ import weakref
22
+
23
+ import cairocffi as cairo
24
+ import numpy as np
25
+
26
+ from gerberdiff.render.compiled_render import (
27
+ BlockFlash,
28
+ CompiledGroup,
29
+ CompiledLayer,
30
+ CompiledRender,
31
+ FlashBatch,
32
+ HoledFlash,
33
+ MacroFlash,
34
+ RegionGroup,
35
+ StrokeBatch,
36
+ )
37
+ from gerberdiff.render.compiled_render import (
38
+ compile_render as compile_render,
39
+ )
40
+ from gerberdiff.render.draw_ops import (
41
+ draw_flash,
42
+ draw_net_as_stroke,
43
+ draw_net_segment_in_region,
44
+ )
45
+ from gerberdiff.render.macro_renderer import draw_macro_flash
46
+ from gerberdiff.render.viewport import Viewport
47
+ from gerberdiff.types import (
48
+ Aperture,
49
+ BlockAperture,
50
+ CoordState,
51
+ MacroAperture,
52
+ MirrorState,
53
+ ParsedImage,
54
+ Polarity,
55
+ )
56
+
57
+ # Default draw colour: bright green (matches reference tool palette).
58
+ _DEFAULT_COLOR: tuple[float, float, float, float] = (0.0, 1.0, 0.533, 1.0)
59
+
60
+ # Cache of CompiledRender objects for BlockAperture instances. Keyed by
61
+ # id(block_ap). A weakref finalizer removes each entry automatically when
62
+ # its BlockAperture is garbage-collected, so stale ids cannot cause hits.
63
+ _block_compile_cache: dict[int, CompiledRender] = {}
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Public API
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def render_to_surface(
72
+ parsed_image: ParsedImage,
73
+ viewport: Viewport,
74
+ draw_color: tuple[float, float, float, float] = _DEFAULT_COLOR,
75
+ coordinate_offset: tuple[float, float] | None = None,
76
+ ) -> cairo.ImageSurface:
77
+ """Render *parsed_image* into a new ``cairo.ImageSurface``.
78
+
79
+ The surface uses ``FORMAT_ARGB32`` (premultiplied alpha). Transparent
80
+ pixels represent the PCB substrate / background.
81
+
82
+ *coordinate_offset* shifts the board in world-space (inches) before
83
+ rendering. Used by ``compute_diff`` to align two boards with different
84
+ origins.
85
+ """
86
+ surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, viewport.width, viewport.height)
87
+ ctx = cairo.Context(surface)
88
+
89
+ # Start with a fully transparent canvas.
90
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
91
+ ctx.paint()
92
+ ctx.set_operator(cairo.OPERATOR_OVER)
93
+
94
+ # Apply viewport transform: pan, then Y-flip + zoom so Gerber's
95
+ # mathematical Y-up coordinate system maps to screen Y-down.
96
+ ctx.save()
97
+ ctx.translate(viewport.pan_x, viewport.pan_y)
98
+ ctx.scale(viewport.zoom, -viewport.zoom)
99
+ if coordinate_offset is not None:
100
+ ctx.translate(coordinate_offset[0], -coordinate_offset[1])
101
+ # Global draw style.
102
+ ctx.set_source_rgba(*draw_color)
103
+ ctx.set_line_join(cairo.LINE_JOIN_ROUND)
104
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
105
+
106
+ cr = compile_render(parsed_image)
107
+ for layer in cr.layers:
108
+ _render_layer(ctx, layer, parsed_image.apertures)
109
+
110
+ ctx.restore()
111
+ return surface
112
+
113
+
114
+ def render_to_numpy(
115
+ parsed_image: ParsedImage,
116
+ viewport: Viewport,
117
+ draw_color: tuple[float, float, float, float] = _DEFAULT_COLOR,
118
+ coordinate_offset: tuple[float, float] | None = None,
119
+ ) -> np.ndarray:
120
+ """Render to a ``numpy`` array of shape ``(H, W, 4)`` with dtype ``uint8``.
121
+
122
+ Channel order is BGRA (Cairo's native ARGB32 little-endian layout).
123
+ """
124
+ surface = render_to_surface(parsed_image, viewport, draw_color, coordinate_offset)
125
+ surface.flush()
126
+ buf = surface.get_data()
127
+ arr = np.frombuffer(buf, dtype=np.uint8)
128
+ # copy() transfers ownership away from the cairo surface buffer so the
129
+ # returned array remains valid after the surface is garbage-collected.
130
+ return arr.reshape(viewport.height, viewport.width, 4).copy()
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Internal helpers
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ def _render_layer(
139
+ ctx: cairo.Context,
140
+ layer: CompiledLayer,
141
+ apertures: dict[int, Aperture],
142
+ depth: int = 0,
143
+ ) -> None:
144
+ """Render one compiled layer, applying polarity, transforms, and SR."""
145
+ ctx.save()
146
+
147
+ # Polarity: clear layers punch holes via DEST_OUT.
148
+ if layer.polarity == Polarity.Clear:
149
+ ctx.set_operator(cairo.OPERATOR_DEST_OUT)
150
+
151
+ # Optional layer-level transforms.
152
+ # Cairo post-multiplies each call into the CTM, so the last call in code
153
+ # is the FIRST transform applied to coordinates. RS-274X sec.4.9 specifies
154
+ # that coordinates are transformed as: scale -> rotation -> mirror.
155
+ # Code order must therefore be the reverse: mirror -> rotation -> scale.
156
+ if layer.mirror != MirrorState.None_:
157
+ sx = -1.0 if layer.mirror in (MirrorState.FlipA, MirrorState.FlipAB) else 1.0
158
+ sy = -1.0 if layer.mirror in (MirrorState.FlipB, MirrorState.FlipAB) else 1.0
159
+ ctx.scale(sx, sy)
160
+ if layer.rotation != 0.0:
161
+ ctx.rotate(math.radians(layer.rotation))
162
+ if layer.scale != 1.0:
163
+ ctx.scale(layer.scale, layer.scale)
164
+
165
+ # Step-and-repeat: only loop when SR counts exceed 1.
166
+ sr = layer.step_and_repeat
167
+ if sr.x > 1 or sr.y > 1:
168
+ for ix in range(sr.x):
169
+ for iy in range(sr.y):
170
+ ctx.save()
171
+ ctx.translate(ix * sr.dist_x, iy * sr.dist_y)
172
+ _render_groups(ctx, layer.groups, apertures, depth)
173
+ ctx.restore()
174
+ else:
175
+ _render_groups(ctx, layer.groups, apertures, depth)
176
+
177
+ ctx.restore()
178
+
179
+
180
+ def _render_groups(
181
+ ctx: cairo.Context,
182
+ groups: list[CompiledGroup],
183
+ apertures: dict[int, Aperture],
184
+ depth: int = 0,
185
+ ) -> None:
186
+ """Execute each compiled group against *ctx*."""
187
+ for group in groups:
188
+ match group:
189
+ case FlashBatch():
190
+ ap = apertures.get(group.aperture_code)
191
+ for net in group.nets:
192
+ draw_flash(ctx, net, ap)
193
+
194
+ case StrokeBatch():
195
+ ap = apertures.get(group.aperture_code)
196
+ for net in group.nets:
197
+ draw_net_as_stroke(ctx, net, ap)
198
+
199
+ case RegionGroup():
200
+ ctx.save()
201
+ ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
202
+ ctx.new_path()
203
+ for net in group.nets:
204
+ draw_net_segment_in_region(ctx, net)
205
+ ctx.close_path()
206
+ ctx.fill()
207
+ ctx.restore()
208
+
209
+ case HoledFlash():
210
+ if group.net is not None:
211
+ ap = apertures.get(group.aperture_code)
212
+ draw_flash(ctx, group.net, ap)
213
+
214
+ case MacroFlash():
215
+ if group.net is not None:
216
+ ap = apertures.get(group.aperture_code)
217
+ if isinstance(ap, MacroAperture):
218
+ draw_macro_flash(
219
+ ctx,
220
+ group.net.stop_x,
221
+ group.net.stop_y,
222
+ ap,
223
+ )
224
+
225
+ case BlockFlash():
226
+ if group.net is not None:
227
+ ap = apertures.get(group.aperture_code)
228
+ if isinstance(ap, BlockAperture):
229
+ _draw_block_flash(
230
+ ctx,
231
+ group.net.stop_x,
232
+ group.net.stop_y,
233
+ ap,
234
+ depth + 1,
235
+ )
236
+
237
+
238
+ def _draw_block_flash(
239
+ ctx: cairo.Context,
240
+ x: float,
241
+ y: float,
242
+ block_ap: BlockAperture,
243
+ depth: int = 0,
244
+ ) -> None:
245
+ """Render a block aperture flash by recursively compiling and drawing it.
246
+
247
+ The block's nets are in its own coordinate system. Translating by
248
+ ``(x, y)`` stamps the block at the flash position.
249
+
250
+ *depth* tracks the block-nesting level. Rendering is silently skipped
251
+ when ``depth >= 10``, matching the parser's nesting limit and preventing
252
+ unbounded recursion on malformed input.
253
+ """
254
+ if depth >= 10:
255
+ return
256
+ if not block_ap.draw_ops:
257
+ return
258
+
259
+ # Build a minimal synthetic ParsedImage so compile_render can be reused.
260
+ # Layer states come from the block's own captured layers (at least one).
261
+ layers = block_ap.layers if block_ap.layers else []
262
+ synthetic = ParsedImage(
263
+ draw_ops=block_ap.draw_ops,
264
+ apertures=block_ap.apertures,
265
+ layers=layers,
266
+ coord_states=[CoordState()],
267
+ bounding_box=block_ap.bounding_box,
268
+ diagnostics=[],
269
+ )
270
+
271
+ ctx.save()
272
+ ctx.translate(x, y)
273
+ cache_key = id(block_ap)
274
+ cr = _block_compile_cache.get(cache_key)
275
+ if cr is None:
276
+ cr = compile_render(synthetic)
277
+ _block_compile_cache[cache_key] = cr
278
+ # Evict the entry automatically when block_ap is garbage-collected so
279
+ # a future object that reuses the same id cannot get a stale hit.
280
+ weakref.finalize(block_ap, _block_compile_cache.pop, cache_key, None)
281
+ for layer in cr.layers:
282
+ _render_layer(ctx, layer, block_ap.apertures, depth)
283
+ ctx.restore()
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from gerberdiff.types import BoundingBox
6
+
7
+
8
+ @dataclass
9
+ class Viewport:
10
+ """Canvas viewport parameters derived from a board's bounding box.
11
+
12
+ Coordinate convention:
13
+ screen_x = pan_x + world_x * zoom
14
+ screen_y = pan_y - world_y * zoom (Y-flipped: Gerber +Y is screen up)
15
+ """
16
+
17
+ width: int
18
+ height: int
19
+ pan_x: float # canvas X of the world origin
20
+ pan_y: float # canvas Y of the world origin (after Y-flip)
21
+ zoom: float # world units -> pixels
22
+
23
+
24
+ def compute_viewport(bbox: BoundingBox, width: int, height: int) -> Viewport:
25
+ """Fit *bbox* into a *width* x *height* canvas with a 10% margin.
26
+
27
+ Returns a default viewport (zoom=100, centred) when the bbox is invalid
28
+ or has zero extent in either axis.
29
+ """
30
+ if not bbox.is_valid:
31
+ return Viewport(
32
+ width=width,
33
+ height=height,
34
+ pan_x=width / 2.0,
35
+ pan_y=height / 2.0,
36
+ zoom=100.0,
37
+ )
38
+
39
+ bbox_w = bbox.max_x - bbox.min_x
40
+ bbox_h = bbox.max_y - bbox.min_y
41
+
42
+ if bbox_w <= 0.0 or bbox_h <= 0.0:
43
+ return Viewport(
44
+ width=width,
45
+ height=height,
46
+ pan_x=width / 2.0,
47
+ pan_y=height / 2.0,
48
+ zoom=100.0,
49
+ )
50
+
51
+ zoom = min(width / bbox_w, height / bbox_h) * 0.9
52
+ center_x = bbox.min_x + bbox_w / 2.0
53
+ center_y = bbox.min_y + bbox_h / 2.0
54
+ pan_x = width / 2.0 - center_x * zoom
55
+ pan_y = height / 2.0 + center_y * zoom # Y-flip
56
+
57
+ return Viewport(width=width, height=height, pan_x=pan_x, pan_y=pan_y, zoom=zoom)
58
+
59
+
60
+ def merge_bounding_boxes(a: BoundingBox, b: BoundingBox) -> BoundingBox:
61
+ """Return the axis-aligned union of two BoundingBoxes.
62
+
63
+ If one box is invalid (empty) the other is returned unchanged.
64
+ If both are invalid the result is also invalid.
65
+ """
66
+ result = BoundingBox()
67
+ if a.is_valid:
68
+ result.expand(a.min_x, a.min_y)
69
+ result.expand(a.max_x, a.max_y)
70
+ if b.is_valid:
71
+ result.expand(b.min_x, b.min_y)
72
+ result.expand(b.max_x, b.max_y)
73
+ return result
74
+
75
+
76
+ def screen_to_world(px: float, py: float, vp: Viewport) -> tuple[float, float]:
77
+ """Convert pixel coordinates back to world (inch) coordinates.
78
+
79
+ Inverts the transform:
80
+ screen_x = pan_x + world_x * zoom
81
+ screen_y = pan_y - world_y * zoom
82
+ """
83
+ world_x = (px - vp.pan_x) / vp.zoom
84
+ world_y = -(py - vp.pan_y) / vp.zoom
85
+ return world_x, world_y