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.
- gerberdiff/__init__.py +86 -0
- gerberdiff/cli.py +527 -0
- gerberdiff/diff/__init__.py +0 -0
- gerberdiff/diff/diff_engine.py +409 -0
- gerberdiff/diff/layer_matcher.py +202 -0
- gerberdiff/export/__init__.py +0 -0
- gerberdiff/export/json_report.py +184 -0
- gerberdiff/export/png_export.py +92 -0
- gerberdiff/export/svg_export.py +187 -0
- gerberdiff/geometry/__init__.py +37 -0
- gerberdiff/geometry/attribute.py +203 -0
- gerberdiff/geometry/driver.py +227 -0
- gerberdiff/geometry/expand.py +232 -0
- gerberdiff/geometry/geom_diff.py +153 -0
- gerberdiff/geometry/layer_geometry.py +665 -0
- gerberdiff/geometry/macro_geom.py +215 -0
- gerberdiff/geometry/primitives.py +108 -0
- gerberdiff/geometry/types.py +85 -0
- gerberdiff/parse/__init__.py +0 -0
- gerberdiff/parse/arc_math.py +162 -0
- gerberdiff/parse/excellon_parser.py +338 -0
- gerberdiff/parse/gerber_parser.py +244 -0
- gerberdiff/parse/gerber_state.py +780 -0
- gerberdiff/parse/macro_parser.py +604 -0
- gerberdiff/parse/tokenizer.py +153 -0
- gerberdiff/py.typed +0 -0
- gerberdiff/render/__init__.py +0 -0
- gerberdiff/render/compiled_render.py +240 -0
- gerberdiff/render/draw_ops.py +205 -0
- gerberdiff/render/macro_renderer.py +343 -0
- gerberdiff/render/renderer.py +283 -0
- gerberdiff/render/viewport.py +85 -0
- gerberdiff/types.py +360 -0
- gerberdiff-0.29.0.dist-info/METADATA +105 -0
- gerberdiff-0.29.0.dist-info/RECORD +38 -0
- gerberdiff-0.29.0.dist-info/WHEEL +4 -0
- gerberdiff-0.29.0.dist-info/entry_points.txt +2 -0
- 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
|