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,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