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,409 @@
|
|
|
1
|
+
"""Pixel-based diff engine: render two ParsedImages, XOR pixel buffers, CCL.
|
|
2
|
+
|
|
3
|
+
Pipeline
|
|
4
|
+
--------
|
|
5
|
+
1. ``compute_diff`` renders both images to the same viewport and XORs the
|
|
6
|
+
RGB channels to find changed pixels.
|
|
7
|
+
2. ``_ccl_and_extract`` uses ``scipy.ndimage.label`` (4-connectivity) to
|
|
8
|
+
identify contiguous changed regions and converts pixel coordinates to
|
|
9
|
+
world coordinates via ``screen_to_world``.
|
|
10
|
+
3. ``merge_overlapping_regions`` iteratively merges regions whose bounding
|
|
11
|
+
boxes overlap within a tolerance, then re-sorts and re-numbers them.
|
|
12
|
+
|
|
13
|
+
Coordinate convention
|
|
14
|
+
---------------------
|
|
15
|
+
All region coordinates (centroid, bounding box) are in **inches**, matching
|
|
16
|
+
the ``ParsedImage`` IR convention.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from dataclasses import replace as dc_replace
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from scipy.ndimage import center_of_mass, find_objects
|
|
28
|
+
from scipy.ndimage import label as ndimage_label
|
|
29
|
+
|
|
30
|
+
from gerberdiff.render.viewport import (
|
|
31
|
+
Viewport,
|
|
32
|
+
compute_viewport,
|
|
33
|
+
merge_bounding_boxes,
|
|
34
|
+
screen_to_world,
|
|
35
|
+
)
|
|
36
|
+
from gerberdiff.types import (
|
|
37
|
+
BoundingBox,
|
|
38
|
+
Diagnostic,
|
|
39
|
+
DiagnosticSeverity,
|
|
40
|
+
DiffResult,
|
|
41
|
+
GerberParseError,
|
|
42
|
+
LayerDiffResult,
|
|
43
|
+
LayerStatus,
|
|
44
|
+
ParsedImage,
|
|
45
|
+
Region,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Result container
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SingleLayerDiff:
|
|
55
|
+
"""Full output of a single-layer pixel diff."""
|
|
56
|
+
|
|
57
|
+
regions: list[Region]
|
|
58
|
+
viewport: Viewport
|
|
59
|
+
changed_pixel_count: int
|
|
60
|
+
total_pixel_count: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Public API
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compute_diff(
|
|
69
|
+
image_a: ParsedImage,
|
|
70
|
+
image_b: ParsedImage,
|
|
71
|
+
width: int,
|
|
72
|
+
height: int,
|
|
73
|
+
alignment_offset: tuple[float, float] | None = None,
|
|
74
|
+
min_pixel_count: int = 4,
|
|
75
|
+
merge_tolerance: float = 0.05,
|
|
76
|
+
overlay_callback: Callable[[np.ndarray, np.ndarray, np.ndarray], None] | None = None,
|
|
77
|
+
) -> SingleLayerDiff:
|
|
78
|
+
"""Render both images to a shared viewport and compute the pixel diff.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
image_a, image_b:
|
|
83
|
+
Parsed Gerber/Excellon images to compare.
|
|
84
|
+
width, height:
|
|
85
|
+
Canvas dimensions in pixels.
|
|
86
|
+
alignment_offset:
|
|
87
|
+
Optional ``(dx, dy)`` inches translation applied to *image_b* before
|
|
88
|
+
rendering. Used when the two board revisions have different origins.
|
|
89
|
+
min_pixel_count:
|
|
90
|
+
Minimum pixel count for a region to be reported (filters noise).
|
|
91
|
+
merge_tolerance:
|
|
92
|
+
Bounding-box padding (inches) used when deciding whether to merge two
|
|
93
|
+
nearby regions into one.
|
|
94
|
+
overlay_callback:
|
|
95
|
+
Optional callable invoked with ``(arr_a, arr_b, xor)`` before the
|
|
96
|
+
arrays are released. Use this to write a PNG overlay without keeping
|
|
97
|
+
all three ``(H, W, 4)`` arrays live simultaneously.
|
|
98
|
+
"""
|
|
99
|
+
# Lazy import: keeps `import gerberdiff` (and the Cairo-free geometry
|
|
100
|
+
# pipeline) working on systems without the native cairo library.
|
|
101
|
+
from gerberdiff.render.renderer import render_to_numpy
|
|
102
|
+
|
|
103
|
+
bbox = merge_bounding_boxes(image_a.bounding_box, image_b.bounding_box)
|
|
104
|
+
vp = compute_viewport(bbox, width, height)
|
|
105
|
+
|
|
106
|
+
arr_a = render_to_numpy(image_a, vp)
|
|
107
|
+
arr_b = render_to_numpy(image_b, vp, coordinate_offset=alignment_offset)
|
|
108
|
+
|
|
109
|
+
xor = np.bitwise_xor(arr_a, arr_b)
|
|
110
|
+
# Changed wherever any of the three colour channels differs (ignore alpha).
|
|
111
|
+
mask: np.ndarray = np.any(xor[..., :3] > 0, axis=-1)
|
|
112
|
+
|
|
113
|
+
regions = _ccl_and_extract(mask, vp, min_pixel_count)
|
|
114
|
+
regions = merge_overlapping_regions(regions, tolerance=merge_tolerance)
|
|
115
|
+
|
|
116
|
+
if overlay_callback is not None:
|
|
117
|
+
overlay_callback(arr_a, arr_b, xor)
|
|
118
|
+
|
|
119
|
+
return SingleLayerDiff(
|
|
120
|
+
regions=regions,
|
|
121
|
+
viewport=vp,
|
|
122
|
+
changed_pixel_count=int(mask.sum()),
|
|
123
|
+
total_pixel_count=width * height,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def compute_full_diff(
|
|
128
|
+
before_dir: Path,
|
|
129
|
+
after_dir: Path,
|
|
130
|
+
*,
|
|
131
|
+
width: int = 2048,
|
|
132
|
+
height: int = 2048,
|
|
133
|
+
layers: Sequence[str] | None = None,
|
|
134
|
+
alignment_offset: tuple[float, float] | None = None,
|
|
135
|
+
min_pixel_count: int = 4,
|
|
136
|
+
merge_tolerance: float = 0.05,
|
|
137
|
+
overlay_callback: Callable[[str, np.ndarray, np.ndarray, np.ndarray], None] | None = None,
|
|
138
|
+
on_diagnostic: Callable[[Path, Diagnostic], None] | None = None,
|
|
139
|
+
) -> DiffResult:
|
|
140
|
+
"""Diff two directories of Gerber/Excellon layer files.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
before_dir, after_dir:
|
|
145
|
+
Directories containing the before and after layer files.
|
|
146
|
+
width, height:
|
|
147
|
+
Canvas dimensions in pixels.
|
|
148
|
+
layers:
|
|
149
|
+
If given, only layers whose names appear in this sequence are diffed.
|
|
150
|
+
alignment_offset:
|
|
151
|
+
Optional ``(dx, dy)`` inch translation applied to *after_dir* images
|
|
152
|
+
before diffing.
|
|
153
|
+
min_pixel_count:
|
|
154
|
+
Minimum pixel count for a region to be reported.
|
|
155
|
+
merge_tolerance:
|
|
156
|
+
Bounding-box padding (inches) used when merging nearby regions.
|
|
157
|
+
overlay_callback:
|
|
158
|
+
Called with ``(layer_name, arr_a, arr_b, xor)`` for each matched
|
|
159
|
+
layer. Use this to write per-layer overlay PNGs without keeping all
|
|
160
|
+
arrays live simultaneously.
|
|
161
|
+
on_diagnostic:
|
|
162
|
+
Called with ``(path, diagnostic)`` for every non-fatal diagnostic
|
|
163
|
+
(``Warning`` and ``Info`` severity) encountered while parsing.
|
|
164
|
+
|
|
165
|
+
Raises
|
|
166
|
+
------
|
|
167
|
+
GerberParseError
|
|
168
|
+
When a file contains a fatal (``Error``-severity) parse diagnostic.
|
|
169
|
+
OSError
|
|
170
|
+
When a layer file cannot be read.
|
|
171
|
+
"""
|
|
172
|
+
# Lazy imports: keep parse/ and diff/layer_matcher out of the module-load
|
|
173
|
+
# critical path for callers that only use compute_diff.
|
|
174
|
+
from gerberdiff.diff.layer_matcher import EXCELLON_SUFFIXES, match_layers
|
|
175
|
+
from gerberdiff.parse.excellon_parser import parse_excellon
|
|
176
|
+
from gerberdiff.parse.gerber_state import parse_gerber
|
|
177
|
+
|
|
178
|
+
def _parse(path: Path) -> ParsedImage:
|
|
179
|
+
content = path.read_text(errors="replace")
|
|
180
|
+
if path.suffix.lower() in EXCELLON_SUFFIXES:
|
|
181
|
+
img = parse_excellon(content, source_path=path)
|
|
182
|
+
else:
|
|
183
|
+
img = parse_gerber(content, source_path=path)
|
|
184
|
+
for diag in img.diagnostics:
|
|
185
|
+
if diag.severity == DiagnosticSeverity.Error:
|
|
186
|
+
raise GerberParseError(path, diag.message, diag.line)
|
|
187
|
+
if on_diagnostic is not None:
|
|
188
|
+
on_diagnostic(path, diag)
|
|
189
|
+
return img
|
|
190
|
+
|
|
191
|
+
pairs = match_layers(before_dir, after_dir)
|
|
192
|
+
if layers is not None:
|
|
193
|
+
pairs = [p for p in pairs if p.name in layers]
|
|
194
|
+
|
|
195
|
+
layer_results: list[LayerDiffResult] = []
|
|
196
|
+
|
|
197
|
+
for pair in pairs:
|
|
198
|
+
total_px = width * height
|
|
199
|
+
|
|
200
|
+
if pair.status in (LayerStatus.Added, LayerStatus.Removed):
|
|
201
|
+
src_path = pair.after_path if pair.status == LayerStatus.Added else pair.before_path
|
|
202
|
+
assert src_path is not None # invariant guaranteed by match_layers
|
|
203
|
+
_parse(src_path) # validate file and surface diagnostics
|
|
204
|
+
lr = LayerDiffResult(
|
|
205
|
+
name=pair.name,
|
|
206
|
+
status=pair.status,
|
|
207
|
+
layer_type=pair.layer_type,
|
|
208
|
+
changed_pixel_count=total_px,
|
|
209
|
+
total_pixel_count=total_px,
|
|
210
|
+
regions=[],
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
assert pair.before_path is not None and pair.after_path is not None
|
|
214
|
+
img_a = _parse(pair.before_path)
|
|
215
|
+
img_b = _parse(pair.after_path)
|
|
216
|
+
|
|
217
|
+
layer_ov_cb: Callable[[np.ndarray, np.ndarray, np.ndarray], None] | None = None
|
|
218
|
+
if overlay_callback is not None:
|
|
219
|
+
name = pair.name
|
|
220
|
+
|
|
221
|
+
def _wrap(
|
|
222
|
+
a: np.ndarray,
|
|
223
|
+
b: np.ndarray,
|
|
224
|
+
x: np.ndarray,
|
|
225
|
+
_n: str = name,
|
|
226
|
+
) -> None:
|
|
227
|
+
overlay_callback(_n, a, b, x)
|
|
228
|
+
|
|
229
|
+
layer_ov_cb = _wrap
|
|
230
|
+
|
|
231
|
+
result = compute_diff(
|
|
232
|
+
img_a,
|
|
233
|
+
img_b,
|
|
234
|
+
width=width,
|
|
235
|
+
height=height,
|
|
236
|
+
alignment_offset=alignment_offset,
|
|
237
|
+
min_pixel_count=min_pixel_count,
|
|
238
|
+
merge_tolerance=merge_tolerance,
|
|
239
|
+
overlay_callback=layer_ov_cb,
|
|
240
|
+
)
|
|
241
|
+
lr = LayerDiffResult(
|
|
242
|
+
name=pair.name,
|
|
243
|
+
status=LayerStatus.Matched,
|
|
244
|
+
layer_type=pair.layer_type,
|
|
245
|
+
changed_pixel_count=result.changed_pixel_count,
|
|
246
|
+
total_pixel_count=result.total_pixel_count,
|
|
247
|
+
regions=result.regions,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
layer_results.append(lr)
|
|
251
|
+
|
|
252
|
+
return DiffResult(layers=layer_results)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Region merge helpers (public so layer_matcher / CLI can call them)
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def boxes_overlap(a: BoundingBox, b: BoundingBox, tolerance: float) -> bool:
|
|
261
|
+
"""Return True if box *a* and box *b* overlap when each is padded by
|
|
262
|
+
*tolerance* on every side."""
|
|
263
|
+
if a.max_x + tolerance < b.min_x - tolerance:
|
|
264
|
+
return False
|
|
265
|
+
if b.max_x + tolerance < a.min_x - tolerance:
|
|
266
|
+
return False
|
|
267
|
+
if a.max_y + tolerance < b.min_y - tolerance:
|
|
268
|
+
return False
|
|
269
|
+
if b.max_y + tolerance < a.min_y - tolerance:
|
|
270
|
+
return False
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def merge_overlapping_regions(
|
|
275
|
+
regions: list[Region],
|
|
276
|
+
tolerance: float = 0.05,
|
|
277
|
+
) -> list[Region]:
|
|
278
|
+
"""Iteratively merge regions whose bounding boxes overlap (within
|
|
279
|
+
*tolerance*), then sort and re-number.
|
|
280
|
+
|
|
281
|
+
The sort key matches the reference: descending ``centroid_y``, then
|
|
282
|
+
ascending ``centroid_x`` (top-of-board changes first).
|
|
283
|
+
"""
|
|
284
|
+
if len(regions) <= 1:
|
|
285
|
+
return regions
|
|
286
|
+
|
|
287
|
+
working = list(regions)
|
|
288
|
+
changed = True
|
|
289
|
+
|
|
290
|
+
while changed:
|
|
291
|
+
changed = False
|
|
292
|
+
merged: list[Region] = []
|
|
293
|
+
absorbed: set[int] = set()
|
|
294
|
+
|
|
295
|
+
for i in range(len(working)):
|
|
296
|
+
if i in absorbed:
|
|
297
|
+
continue
|
|
298
|
+
current = working[i]
|
|
299
|
+
retry = True
|
|
300
|
+
while retry:
|
|
301
|
+
retry = False
|
|
302
|
+
for j in range(i + 1, len(working)):
|
|
303
|
+
if j in absorbed:
|
|
304
|
+
continue
|
|
305
|
+
if boxes_overlap(current.bounding_box, working[j].bounding_box, tolerance):
|
|
306
|
+
current = _merge_region_pair(current, working[j])
|
|
307
|
+
absorbed.add(j)
|
|
308
|
+
retry = True
|
|
309
|
+
changed = True
|
|
310
|
+
merged.append(current)
|
|
311
|
+
working = merged
|
|
312
|
+
|
|
313
|
+
# Sort: descending centroid_y (higher Y = closer to top in Gerber coords),
|
|
314
|
+
# then ascending centroid_x to break ties.
|
|
315
|
+
working.sort(key=lambda r: (-r.centroid_y, r.centroid_x))
|
|
316
|
+
|
|
317
|
+
# Re-number ids 1..n after sort.
|
|
318
|
+
return [dc_replace(r, id=i + 1) for i, r in enumerate(working)]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Internal helpers
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _ccl_and_extract(
|
|
327
|
+
mask: np.ndarray,
|
|
328
|
+
vp: Viewport,
|
|
329
|
+
min_pixel_count: int,
|
|
330
|
+
) -> list[Region]:
|
|
331
|
+
"""Run 4-connected CCL on *mask* and return world-space regions.
|
|
332
|
+
|
|
333
|
+
Uses ``scipy.ndimage.label`` (default cross structure = 4-connectivity,
|
|
334
|
+
matching the reference union-find implementation) and collects per-label
|
|
335
|
+
bounding boxes and centroids via ``find_objects`` / ``center_of_mass``.
|
|
336
|
+
"""
|
|
337
|
+
labeled_arr, num_features = ndimage_label(mask)
|
|
338
|
+
if num_features == 0:
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
# Per-label bounding slices -- O(H*W) single pass.
|
|
342
|
+
obj_slices = find_objects(labeled_arr)
|
|
343
|
+
|
|
344
|
+
# center_of_mass with a list index always returns list[tuple[float, ...]].
|
|
345
|
+
label_ids = list(range(1, num_features + 1))
|
|
346
|
+
centroids_list = center_of_mass(mask, labeled_arr, label_ids)
|
|
347
|
+
|
|
348
|
+
regions: list[Region] = []
|
|
349
|
+
region_id = 1
|
|
350
|
+
|
|
351
|
+
for idx, (obj_slice, centroid_rc) in enumerate(zip(obj_slices, centroids_list, strict=True)):
|
|
352
|
+
if obj_slice is None:
|
|
353
|
+
continue
|
|
354
|
+
lbl = idx + 1
|
|
355
|
+
sub = labeled_arr[obj_slice] == lbl
|
|
356
|
+
count = int(sub.sum())
|
|
357
|
+
if count < min_pixel_count:
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
# Bounding box corners in pixel coords.
|
|
361
|
+
row_min = obj_slice[0].start
|
|
362
|
+
row_max = obj_slice[0].stop - 1
|
|
363
|
+
col_min = obj_slice[1].start
|
|
364
|
+
col_max = obj_slice[1].stop - 1
|
|
365
|
+
|
|
366
|
+
# screen_to_world(px=col, py=row, vp) -- note col is x, row is y.
|
|
367
|
+
x0, y0 = screen_to_world(col_min, row_min, vp)
|
|
368
|
+
x1, y1 = screen_to_world(col_max, row_max, vp)
|
|
369
|
+
|
|
370
|
+
bb = BoundingBox(
|
|
371
|
+
min_x=min(x0, x1),
|
|
372
|
+
min_y=min(y0, y1),
|
|
373
|
+
max_x=max(x0, x1),
|
|
374
|
+
max_y=max(y0, y1),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
cx, cy = screen_to_world(centroid_rc[1], centroid_rc[0], vp)
|
|
378
|
+
|
|
379
|
+
regions.append(
|
|
380
|
+
Region(
|
|
381
|
+
id=region_id,
|
|
382
|
+
centroid_x=cx,
|
|
383
|
+
centroid_y=cy,
|
|
384
|
+
bounding_box=bb,
|
|
385
|
+
pixel_count=count,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
region_id += 1
|
|
389
|
+
|
|
390
|
+
# Initial sort by descending pixel count (largest changes first, before merge).
|
|
391
|
+
regions.sort(key=lambda r: -r.pixel_count)
|
|
392
|
+
return regions
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _merge_region_pair(a: Region, b: Region) -> Region:
|
|
396
|
+
"""Return a new Region that is the weighted merge of *a* and *b*."""
|
|
397
|
+
total = a.pixel_count + b.pixel_count
|
|
398
|
+
return Region(
|
|
399
|
+
id=a.id,
|
|
400
|
+
centroid_x=(a.centroid_x * a.pixel_count + b.centroid_x * b.pixel_count) / total,
|
|
401
|
+
centroid_y=(a.centroid_y * a.pixel_count + b.centroid_y * b.pixel_count) / total,
|
|
402
|
+
bounding_box=BoundingBox(
|
|
403
|
+
min_x=min(a.bounding_box.min_x, b.bounding_box.min_x),
|
|
404
|
+
min_y=min(a.bounding_box.min_y, b.bounding_box.min_y),
|
|
405
|
+
max_x=max(a.bounding_box.max_x, b.bounding_box.max_x),
|
|
406
|
+
max_y=max(a.bounding_box.max_y, b.bounding_box.max_y),
|
|
407
|
+
),
|
|
408
|
+
pixel_count=total,
|
|
409
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Layer matcher: pair before/after files by stem and classify by type.
|
|
2
|
+
|
|
3
|
+
Algorithm
|
|
4
|
+
---------
|
|
5
|
+
1. List all Gerber/Excellon files in *before_dir* and *after_dir*.
|
|
6
|
+
2. Exact stem match -> status ``"matched"``.
|
|
7
|
+
3. Unmatched in *before_dir* only -> status ``"removed"``.
|
|
8
|
+
4. Unmatched in *after_dir* only -> status ``"added"``.
|
|
9
|
+
|
|
10
|
+
Layer type detection uses suffix and stem patterns:
|
|
11
|
+
|
|
12
|
+
Gerber extensions: ``.gbr .ger .gtl .gbl .gts .gbs .gto .gbo .gtp .gbp .gm1``
|
|
13
|
+
Excellon extensions: ``.drl .exc .xln .ncd``
|
|
14
|
+
|
|
15
|
+
Stem keyword matching (case-insensitive, substring) determines ``LayerType``:
|
|
16
|
+
- ``f.cu`` or ``front copper`` -> ``FCu``
|
|
17
|
+
- ``b.cu`` or ``back copper`` -> ``BCu``
|
|
18
|
+
- ``in1.cu`` ... ``in4.cu`` -> ``InCu``
|
|
19
|
+
- ``f.mask`` / ``b.mask`` -> ``FMask`` / ``BMask``
|
|
20
|
+
- ``f.paste`` / ``b.paste`` -> ``FPaste`` / ``BPaste``
|
|
21
|
+
- ``f.silks`` / ``f.silk`` -> ``FSilk``
|
|
22
|
+
- ``b.silks`` / ``b.silk`` -> ``BSilk``
|
|
23
|
+
- ``edge.cuts`` / ``edgecuts``-> ``EdgeCuts``
|
|
24
|
+
- ``npth`` -> ``NPTH``
|
|
25
|
+
- ``pth`` (but not ``npth``) -> ``PTH``
|
|
26
|
+
- Excellon extension but no keyword match -> ``Drill``
|
|
27
|
+
- Anything else -> ``Unknown``
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from gerberdiff.types import LayerStatus, LayerType
|
|
37
|
+
|
|
38
|
+
_GERBER_SUFFIXES = frozenset(
|
|
39
|
+
{
|
|
40
|
+
".gbr",
|
|
41
|
+
".ger",
|
|
42
|
+
".gtl",
|
|
43
|
+
".gbl", # top/bottom copper (legacy)
|
|
44
|
+
".gts",
|
|
45
|
+
".gbs", # top/bottom solder mask
|
|
46
|
+
".gto",
|
|
47
|
+
".gbo", # top/bottom silkscreen
|
|
48
|
+
".gtp",
|
|
49
|
+
".gbp", # top/bottom paste
|
|
50
|
+
".gm1", # mechanical/edge cuts (legacy)
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
EXCELLON_SUFFIXES = frozenset({".drl", ".exc", ".xln", ".ncd"})
|
|
55
|
+
|
|
56
|
+
_LAYER_SUFFIXES = _GERBER_SUFFIXES | EXCELLON_SUFFIXES
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Data types
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
@dataclass
|
|
63
|
+
class LayerPair:
|
|
64
|
+
"""A matched, added, or removed layer."""
|
|
65
|
+
|
|
66
|
+
name: str # display name = common stem (or bare filename)
|
|
67
|
+
before_path: Path | None # None -> layer was added in after/
|
|
68
|
+
after_path: Path | None # None -> layer was removed from before/
|
|
69
|
+
layer_type: LayerType
|
|
70
|
+
status: LayerStatus # Matched | Added | Removed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Public API
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def match_layers(before_dir: Path, after_dir: Path) -> list[LayerPair]:
|
|
79
|
+
"""Match layer files between *before_dir* and *after_dir*.
|
|
80
|
+
|
|
81
|
+
Returns a list of :class:`LayerPair` objects sorted by layer type order
|
|
82
|
+
then alphabetically by name.
|
|
83
|
+
"""
|
|
84
|
+
before_files = _index_dir(before_dir)
|
|
85
|
+
after_files = _index_dir(after_dir)
|
|
86
|
+
|
|
87
|
+
pairs: list[LayerPair] = []
|
|
88
|
+
|
|
89
|
+
all_stems = sorted(set(before_files) | set(after_files))
|
|
90
|
+
for stem in all_stems:
|
|
91
|
+
b_path = before_files.get(stem)
|
|
92
|
+
a_path = after_files.get(stem)
|
|
93
|
+
|
|
94
|
+
if b_path is not None and a_path is not None:
|
|
95
|
+
status = LayerStatus.Matched
|
|
96
|
+
elif b_path is not None:
|
|
97
|
+
status = LayerStatus.Removed
|
|
98
|
+
else:
|
|
99
|
+
status = LayerStatus.Added
|
|
100
|
+
|
|
101
|
+
# Determine layer type from whichever path is available.
|
|
102
|
+
sample_path = b_path if b_path is not None else a_path
|
|
103
|
+
ltype = _classify(stem, sample_path) # type: ignore[arg-type]
|
|
104
|
+
|
|
105
|
+
pairs.append(
|
|
106
|
+
LayerPair(
|
|
107
|
+
name=stem,
|
|
108
|
+
before_path=b_path,
|
|
109
|
+
after_path=a_path,
|
|
110
|
+
layer_type=ltype,
|
|
111
|
+
status=status,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
pairs.sort(key=lambda p: (_LAYER_TYPE_ORDER.get(p.layer_type, 99), p.name))
|
|
116
|
+
return pairs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def classify_layer(path: Path) -> LayerType:
|
|
120
|
+
"""Classify a single file's layer type by name."""
|
|
121
|
+
return _classify(path.stem, path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Internal helpers
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _index_dir(directory: Path) -> dict[str, Path]:
|
|
130
|
+
"""Return a mapping of stem -> Path for all layer files in *directory*."""
|
|
131
|
+
result: dict[str, Path] = {}
|
|
132
|
+
if not directory.is_dir():
|
|
133
|
+
return result
|
|
134
|
+
for p in directory.iterdir():
|
|
135
|
+
if p.suffix.lower() in _LAYER_SUFFIXES:
|
|
136
|
+
result[p.stem] = p
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _classify(stem: str, path: Path) -> LayerType:
|
|
141
|
+
"""Return the :class:`LayerType` for a file with the given *stem*."""
|
|
142
|
+
s = stem.lower()
|
|
143
|
+
ext = path.suffix.lower()
|
|
144
|
+
|
|
145
|
+
# Order matters: NPTH must be checked before PTH.
|
|
146
|
+
if "npth" in s:
|
|
147
|
+
return LayerType.NPTH
|
|
148
|
+
if "pth" in s:
|
|
149
|
+
return LayerType.PTH
|
|
150
|
+
if "f.cu" in s or "f_cu" in s or "front_copper" in s or "front copper" in s:
|
|
151
|
+
return LayerType.FCu
|
|
152
|
+
if "b.cu" in s or "b_cu" in s or "back_copper" in s or "back copper" in s:
|
|
153
|
+
return LayerType.BCu
|
|
154
|
+
if re.search(r"\bin\d+[._]cu\b", s):
|
|
155
|
+
return LayerType.InCu
|
|
156
|
+
if "f.mask" in s or "f_mask" in s:
|
|
157
|
+
return LayerType.FMask
|
|
158
|
+
if "b.mask" in s or "b_mask" in s:
|
|
159
|
+
return LayerType.BMask
|
|
160
|
+
if "f.paste" in s or "f_paste" in s:
|
|
161
|
+
return LayerType.FPaste
|
|
162
|
+
if "b.paste" in s or "b_paste" in s:
|
|
163
|
+
return LayerType.BPaste
|
|
164
|
+
if "f.silks" in s or "f_silks" in s or "f.silk" in s or "f_silk" in s:
|
|
165
|
+
return LayerType.FSilk
|
|
166
|
+
if "b.silks" in s or "b_silks" in s or "b.silk" in s or "b_silk" in s:
|
|
167
|
+
return LayerType.BSilk
|
|
168
|
+
if "edge.cuts" in s or "edge_cuts" in s or "edgecuts" in s:
|
|
169
|
+
return LayerType.EdgeCuts
|
|
170
|
+
# Legacy top/bottom copper by suffix
|
|
171
|
+
if ext in (".gtl", ".gbl"):
|
|
172
|
+
return LayerType.FCu if ext == ".gtl" else LayerType.BCu
|
|
173
|
+
# Legacy mask / silkscreen / paste by suffix
|
|
174
|
+
if ext in (".gts", ".gbs"):
|
|
175
|
+
return LayerType.FMask if ext == ".gts" else LayerType.BMask
|
|
176
|
+
if ext in (".gto", ".gbo"):
|
|
177
|
+
return LayerType.FSilk if ext == ".gto" else LayerType.BSilk
|
|
178
|
+
if ext in (".gtp", ".gbp"):
|
|
179
|
+
return LayerType.FPaste if ext == ".gtp" else LayerType.BPaste
|
|
180
|
+
# Drill files with no keyword match
|
|
181
|
+
if ext in EXCELLON_SUFFIXES:
|
|
182
|
+
return LayerType.Drill
|
|
183
|
+
return LayerType.Unknown
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Display / sort order for layer types (signal layers first, then mechanical).
|
|
187
|
+
_LAYER_TYPE_ORDER: dict[LayerType, int] = {
|
|
188
|
+
LayerType.FCu: 0,
|
|
189
|
+
LayerType.InCu: 1,
|
|
190
|
+
LayerType.BCu: 2,
|
|
191
|
+
LayerType.FMask: 3,
|
|
192
|
+
LayerType.BMask: 4,
|
|
193
|
+
LayerType.FPaste: 5,
|
|
194
|
+
LayerType.BPaste: 6,
|
|
195
|
+
LayerType.FSilk: 7,
|
|
196
|
+
LayerType.BSilk: 8,
|
|
197
|
+
LayerType.EdgeCuts: 9,
|
|
198
|
+
LayerType.NPTH: 10,
|
|
199
|
+
LayerType.PTH: 11,
|
|
200
|
+
LayerType.Drill: 12,
|
|
201
|
+
LayerType.Unknown: 13,
|
|
202
|
+
}
|
|
File without changes
|