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
gerberdiff/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ __version__ = "0.29.0"
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from gerberdiff.diff.diff_engine import SingleLayerDiff, compute_diff, compute_full_diff
6
+ from gerberdiff.diff.layer_matcher import LayerPair, match_layers
7
+ from gerberdiff.geometry import (
8
+ GeometryChange,
9
+ GeometryDiffResult,
10
+ LayerGeometryDiff,
11
+ compute_geometry_diff,
12
+ )
13
+ from gerberdiff.parse.excellon_parser import parse_excellon
14
+ from gerberdiff.parse.gerber_state import parse_gerber
15
+ from gerberdiff.render.viewport import Viewport, compute_viewport
16
+ from gerberdiff.types import (
17
+ BoundingBox,
18
+ Diagnostic,
19
+ DiagnosticSeverity,
20
+ DiffResult,
21
+ GerberParseError,
22
+ LayerDiffResult,
23
+ LayerStatus,
24
+ LayerType,
25
+ ParsedImage,
26
+ Region,
27
+ RegionFill,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from gerberdiff.render.renderer import render_to_numpy, render_to_surface
32
+
33
+ # The rasteriser requires the native cairo library; import it lazily so that
34
+ # `import gerberdiff` -- and the Cairo-free parse/geometry pipelines -- work
35
+ # on systems without it (PEP 562 module __getattr__).
36
+ _LAZY_RENDER_ATTRS = ("render_to_numpy", "render_to_surface")
37
+
38
+
39
+ def __getattr__(name: str) -> Any:
40
+ if name in _LAZY_RENDER_ATTRS:
41
+ from gerberdiff.render import renderer
42
+
43
+ return getattr(renderer, name)
44
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
45
+
46
+
47
+ __all__ = [
48
+ "BoundingBox",
49
+ # Diagnostics
50
+ "Diagnostic",
51
+ # Enums
52
+ "DiagnosticSeverity",
53
+ # Diff result types
54
+ "DiffResult",
55
+ # Geometry diff
56
+ "GeometryChange",
57
+ "GeometryDiffResult",
58
+ # Exceptions
59
+ "GerberParseError",
60
+ "LayerDiffResult",
61
+ "LayerGeometryDiff",
62
+ "LayerPair",
63
+ "LayerStatus",
64
+ "LayerType",
65
+ # Core IR types
66
+ "ParsedImage",
67
+ "Region",
68
+ "RegionFill",
69
+ "SingleLayerDiff",
70
+ "Viewport",
71
+ # Version
72
+ "__version__",
73
+ # Diff
74
+ "compute_diff",
75
+ "compute_full_diff",
76
+ "compute_geometry_diff",
77
+ "compute_viewport",
78
+ # Layer matching
79
+ "match_layers",
80
+ "parse_excellon",
81
+ # Parse
82
+ "parse_gerber",
83
+ # Render
84
+ "render_to_numpy",
85
+ "render_to_surface",
86
+ ]
gerberdiff/cli.py ADDED
@@ -0,0 +1,527 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from gerberdiff import __version__
11
+ from gerberdiff.diff.layer_matcher import EXCELLON_SUFFIXES
12
+ from gerberdiff.types import Diagnostic, DiagnosticSeverity, LayerStatus
13
+
14
+ _MEMORY_WARN_PIXELS = 16_777_216 # 4096^2
15
+
16
+
17
+ @click.group()
18
+ @click.version_option(__version__, prog_name="gerberdiff")
19
+ def cli() -> None:
20
+ """Visual raster diff tool for Gerber/Excellon PCB design files."""
21
+
22
+
23
+ @cli.command("parse")
24
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
25
+ @click.option("--dump-ir", is_flag=True, help="Print ParsedImage summary as JSON to stdout.")
26
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress all output except errors.")
27
+ @click.option("-v", "--verbose", is_flag=True, help="Print Info-level diagnostics.")
28
+ def parse_cmd(file: Path, dump_ir: bool, quiet: bool, verbose: bool) -> None:
29
+ """Parse a Gerber or Excellon file and report diagnostics."""
30
+ from gerberdiff.parse.excellon_parser import parse_excellon
31
+ from gerberdiff.parse.gerber_state import parse_gerber
32
+
33
+ try:
34
+ content = file.read_text(errors="replace")
35
+ except OSError as exc:
36
+ click.echo(f"error: {exc}", err=True)
37
+ sys.exit(1)
38
+
39
+ if file.suffix.lower() in EXCELLON_SUFFIXES:
40
+ img = parse_excellon(content, source_path=file)
41
+ else:
42
+ img = parse_gerber(content, source_path=file)
43
+
44
+ has_errors = False
45
+ for diag in img.diagnostics:
46
+ loc = f" (line {diag.line})" if diag.line else ""
47
+ if diag.severity == DiagnosticSeverity.Error:
48
+ has_errors = True
49
+ click.echo(f"error: {diag.message}{loc}", err=True)
50
+ elif diag.severity == DiagnosticSeverity.Warning and not quiet:
51
+ click.echo(f"warning: {diag.message}{loc}", err=True)
52
+ elif diag.severity == DiagnosticSeverity.Info and verbose:
53
+ click.echo(f"info: {diag.message}", err=True)
54
+
55
+ if not quiet and not dump_ir:
56
+ click.echo(f"nets: {len(img.draw_ops)}")
57
+ click.echo(f"apertures: {len(img.apertures)}")
58
+ if img.bounding_box.is_valid:
59
+ bb = img.bounding_box
60
+ click.echo(
61
+ f"bbox: x=[{bb.min_x:.6f}, {bb.max_x:.6f}]"
62
+ f" y=[{bb.min_y:.6f}, {bb.max_y:.6f}] inches"
63
+ )
64
+ else:
65
+ click.echo("bbox: empty (no geometry)")
66
+
67
+ if dump_ir:
68
+ bb = img.bounding_box
69
+ ir: dict[str, object] = {
70
+ "source": str(file),
71
+ "net_count": len(img.draw_ops),
72
+ "aperture_count": len(img.apertures),
73
+ "layer_count": len(img.layers),
74
+ "bounding_box": {
75
+ "min_x": bb.min_x if bb.is_valid else None,
76
+ "min_y": bb.min_y if bb.is_valid else None,
77
+ "max_x": bb.max_x if bb.is_valid else None,
78
+ "max_y": bb.max_y if bb.is_valid else None,
79
+ },
80
+ "diagnostics": [
81
+ {"severity": d.severity.value, "message": d.message, "line": d.line}
82
+ for d in img.diagnostics
83
+ ],
84
+ }
85
+ click.echo(json.dumps(ir, indent=2))
86
+
87
+ sys.exit(2 if has_errors else 0)
88
+
89
+
90
+ @cli.command("render")
91
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
92
+ @click.option(
93
+ "--out-png",
94
+ required=True,
95
+ type=click.Path(dir_okay=False, path_type=Path),
96
+ help="Output PNG path.",
97
+ )
98
+ @click.option("--width", default=2048, show_default=True, help="Canvas width in pixels.")
99
+ @click.option("--height", default=2048, show_default=True, help="Canvas height in pixels.")
100
+ @click.option("--overwrite", is_flag=True, help="Overwrite output file if it already exists.")
101
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress all output except errors.")
102
+ @click.option("-v", "--verbose", is_flag=True, help="Print render timing and diagnostic detail.")
103
+ def render_cmd(
104
+ file: Path,
105
+ out_png: Path,
106
+ width: int,
107
+ height: int,
108
+ overwrite: bool,
109
+ quiet: bool,
110
+ verbose: bool,
111
+ ) -> None:
112
+ """Render a Gerber or Excellon file to a PNG image."""
113
+ from gerberdiff.parse.excellon_parser import parse_excellon
114
+ from gerberdiff.parse.gerber_state import parse_gerber
115
+ from gerberdiff.render.renderer import render_to_surface
116
+ from gerberdiff.render.viewport import compute_viewport
117
+
118
+ # Memory warning -- non-blocking.
119
+ total_pixels = width * height
120
+ if total_pixels > _MEMORY_WARN_PIXELS:
121
+ mb = (total_pixels * 4) / (1024 * 1024)
122
+ click.echo(
123
+ f"warning: canvas {width}x{height} = {total_pixels:,} pixels "
124
+ f"(~{mb:.0f} MB); reduce --width/--height if memory is limited.",
125
+ err=True,
126
+ )
127
+
128
+ if out_png.exists() and not overwrite:
129
+ click.echo(
130
+ f"error: output file already exists: {out_png} (use --overwrite to replace)",
131
+ err=True,
132
+ )
133
+ sys.exit(1)
134
+
135
+ try:
136
+ content = file.read_text(errors="replace")
137
+ except OSError as exc:
138
+ click.echo(f"error: {exc}", err=True)
139
+ sys.exit(1)
140
+
141
+ if file.suffix.lower() in EXCELLON_SUFFIXES:
142
+ img = parse_excellon(content, source_path=file)
143
+ else:
144
+ img = parse_gerber(content, source_path=file)
145
+
146
+ has_errors = False
147
+ for diag in img.diagnostics:
148
+ loc = f" (line {diag.line})" if diag.line else ""
149
+ if diag.severity == DiagnosticSeverity.Error:
150
+ has_errors = True
151
+ click.echo(f"error: {diag.message}{loc}", err=True)
152
+ elif diag.severity == DiagnosticSeverity.Warning and not quiet:
153
+ click.echo(f"warning: {diag.message}{loc}", err=True)
154
+ elif diag.severity == DiagnosticSeverity.Info and verbose:
155
+ click.echo(f"info: {diag.message}", err=True)
156
+
157
+ if has_errors:
158
+ sys.exit(2)
159
+
160
+ vp = compute_viewport(img.bounding_box, width, height)
161
+
162
+ t0 = time.perf_counter()
163
+ surface = render_to_surface(img, vp)
164
+ elapsed = time.perf_counter() - t0
165
+
166
+ try:
167
+ out_png.parent.mkdir(parents=True, exist_ok=True)
168
+ surface.write_to_png(str(out_png))
169
+ except OSError as exc:
170
+ click.echo(f"error: {exc}", err=True)
171
+ sys.exit(1)
172
+
173
+ if not quiet:
174
+ click.echo(f"rendered {width}x{height} -> {out_png}")
175
+ if verbose:
176
+ click.echo(f"render time: {elapsed * 1000:.1f} ms")
177
+ click.echo(f"nets: {len(img.draw_ops)} apertures: {len(img.apertures)}")
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # diff subcommand
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ @cli.command("diff")
186
+ @click.argument("before_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
187
+ @click.argument("after_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
188
+ @click.option(
189
+ "--layer",
190
+ "layers",
191
+ multiple=True,
192
+ help="Restrict diff to this layer name (repeatable).",
193
+ )
194
+ @click.option("--width", default=2048, show_default=True, help="Canvas width in pixels.")
195
+ @click.option("--height", default=2048, show_default=True, help="Canvas height in pixels.")
196
+ @click.option(
197
+ "--min-pixels",
198
+ default=4,
199
+ show_default=True,
200
+ help="Minimum changed-pixel count to report a region.",
201
+ )
202
+ @click.option(
203
+ "--merge-tolerance", default=0.05, show_default=True, help="Region merge padding in inches."
204
+ )
205
+ @click.option(
206
+ "--out-json",
207
+ type=click.Path(dir_okay=False, path_type=Path),
208
+ help="Write JSON report to this file.",
209
+ )
210
+ @click.option(
211
+ "--out-png",
212
+ "out_png_dir",
213
+ type=click.Path(file_okay=False, path_type=Path),
214
+ help="Write diff overlay PNG(s) to this directory.",
215
+ )
216
+ @click.option("--overwrite", is_flag=True, help="Allow overwriting existing output files.")
217
+ @click.option(
218
+ "--png-show-common", is_flag=True, help="Include unchanged geometry as grey in PNG overlay."
219
+ )
220
+ @click.option(
221
+ "--align-offset",
222
+ default="0,0",
223
+ show_default=True,
224
+ help=(
225
+ "Shift image B by DX,DY inches before diffing. "
226
+ "Positive DX shifts right; positive DY shifts downward "
227
+ "(screen convention, i.e. negative Gerber Y). "
228
+ "Example: '--align-offset 0.5,0' compensates for B being 0.5 in to the right of A."
229
+ ),
230
+ )
231
+ @click.option("--fail-on-diff", is_flag=True, help="Exit with code 1 if any changes are detected.")
232
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress all output except errors.")
233
+ @click.option("-v", "--verbose", is_flag=True, help="Print per-layer and per-region detail.")
234
+ def diff_cmd(
235
+ before_dir: Path,
236
+ after_dir: Path,
237
+ layers: tuple[str, ...],
238
+ width: int,
239
+ height: int,
240
+ min_pixels: int,
241
+ merge_tolerance: float,
242
+ out_json: Path | None,
243
+ out_png_dir: Path | None,
244
+ overwrite: bool,
245
+ png_show_common: bool,
246
+ align_offset: str,
247
+ fail_on_diff: bool,
248
+ quiet: bool,
249
+ verbose: bool,
250
+ ) -> None:
251
+ """Compare two directories of Gerber/Excellon layer files."""
252
+ from gerberdiff.diff.diff_engine import compute_full_diff
253
+ from gerberdiff.export.json_report import write_report
254
+ from gerberdiff.export.png_export import build_overlay_png
255
+ from gerberdiff.types import GerberParseError
256
+
257
+ # Parse --align-offset
258
+ try:
259
+ ox_str, oy_str = align_offset.split(",", 1)
260
+ alignment_offset: tuple[float, float] | None = (float(ox_str), float(oy_str))
261
+ if alignment_offset == (0.0, 0.0):
262
+ alignment_offset = None
263
+ except ValueError:
264
+ click.echo(
265
+ "error: --align-offset must be two comma-separated floats (e.g. '0.5,0')",
266
+ err=True,
267
+ )
268
+ sys.exit(2)
269
+
270
+ # Memory warning
271
+ total_pixels = width * height
272
+ if total_pixels > _MEMORY_WARN_PIXELS and not quiet:
273
+ mb = (total_pixels * 4) / (1024 * 1024)
274
+ click.echo(
275
+ f"warning: canvas {width}x{height} = {total_pixels:,} pixels (~{mb:.0f} MB)",
276
+ err=True,
277
+ )
278
+
279
+ def _overlay_cb(
280
+ layer_name: str,
281
+ arr_a: object,
282
+ arr_b: object,
283
+ xor: object,
284
+ ) -> None:
285
+ import numpy as _np
286
+
287
+ png_path = out_png_dir / f"{layer_name}_diff.png" # type: ignore[operator]
288
+ build_overlay_png(
289
+ _np.asarray(arr_a),
290
+ _np.asarray(arr_b),
291
+ _np.asarray(xor),
292
+ png_path,
293
+ show_common=png_show_common,
294
+ overwrite=overwrite,
295
+ )
296
+
297
+ def _on_diagnostic(path: Path, diag: Diagnostic) -> None:
298
+ loc = f" (line {diag.line})" if diag.line else ""
299
+ if diag.severity == DiagnosticSeverity.Warning and not quiet:
300
+ click.echo(f"warning: {path.name}: {diag.message}{loc}", err=True)
301
+ elif diag.severity == DiagnosticSeverity.Info and verbose:
302
+ click.echo(f"info: {path.name}: {diag.message}", err=True)
303
+
304
+ t_start = time.perf_counter()
305
+
306
+ try:
307
+ diff_result = compute_full_diff(
308
+ before_dir,
309
+ after_dir,
310
+ width=width,
311
+ height=height,
312
+ layers=layers if layers else None,
313
+ alignment_offset=alignment_offset,
314
+ min_pixel_count=min_pixels,
315
+ merge_tolerance=merge_tolerance,
316
+ overlay_callback=_overlay_cb if out_png_dir is not None else None,
317
+ on_diagnostic=_on_diagnostic,
318
+ )
319
+ except GerberParseError as exc:
320
+ click.echo(f"error: {exc}", err=True)
321
+ sys.exit(2)
322
+ except FileExistsError as exc:
323
+ click.echo(f"error: {exc} (use --overwrite to replace)", err=True)
324
+ sys.exit(1)
325
+ except OSError as exc:
326
+ click.echo(f"error: {exc}", err=True)
327
+ sys.exit(1)
328
+
329
+ elapsed_total = time.perf_counter() - t_start
330
+
331
+ # Verbose per-layer output
332
+ if verbose:
333
+ for lr in diff_result.layers:
334
+ click.echo(
335
+ f" {lr.name}: {lr.changed_pixel_count} changed px, {len(lr.regions)} regions"
336
+ )
337
+ for region in lr.regions:
338
+ click.echo(
339
+ f" region {region.id}: {region.pixel_count} px "
340
+ f"centroid=({region.centroid_x:.4f}, {region.centroid_y:.4f})"
341
+ )
342
+
343
+ # JSON report
344
+ if out_json is not None:
345
+ try:
346
+ write_report(diff_result, out_json, overwrite=overwrite)
347
+ except FileExistsError as exc:
348
+ click.echo(f"error: {exc} (use --overwrite to replace)", err=True)
349
+ sys.exit(1)
350
+
351
+ elapsed_ms = f"({elapsed_total * 1000:.0f} ms)"
352
+ if not quiet:
353
+ changed_layers = sum(
354
+ 1
355
+ for lr in diff_result.layers
356
+ if lr.changed_pixel_count > 0 or lr.status != LayerStatus.Matched
357
+ )
358
+ click.echo(f"diff: {changed_layers}/{len(diff_result.layers)} layers changed {elapsed_ms}")
359
+ if out_json:
360
+ click.echo(f"report: {out_json}")
361
+
362
+ sys.exit(1 if fail_on_diff and diff_result.has_changes else 0)
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # geomdiff subcommand
367
+ # ---------------------------------------------------------------------------
368
+
369
+
370
+ @cli.command("geomdiff")
371
+ @click.argument("before_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
372
+ @click.argument("after_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
373
+ @click.option(
374
+ "--layer",
375
+ "layers",
376
+ multiple=True,
377
+ help="Restrict diff to this layer name (repeatable).",
378
+ )
379
+ @click.option(
380
+ "--move-tol",
381
+ default=0.005,
382
+ show_default=True,
383
+ help="Minimum displacement (mm) to report a matched object as moved.",
384
+ )
385
+ @click.option(
386
+ "--gate-radius",
387
+ default=0.2,
388
+ show_default=True,
389
+ help="Maximum distance (mm) at which two objects can pair as the same.",
390
+ )
391
+ @click.option(
392
+ "--area-tol",
393
+ default=0.01,
394
+ show_default=True,
395
+ help="Relative area delta still counted as same dimensions.",
396
+ )
397
+ @click.option(
398
+ "--dust-area",
399
+ default=1e-6,
400
+ show_default=True,
401
+ help="Drop boolean-diff components smaller than this area (mm^2).",
402
+ )
403
+ @click.option(
404
+ "--out-json",
405
+ type=click.Path(dir_okay=False, path_type=Path),
406
+ help="Write geometry JSON report (schema v2) to this file.",
407
+ )
408
+ @click.option(
409
+ "--out-svg",
410
+ "out_svg_dir",
411
+ type=click.Path(file_okay=False, path_type=Path),
412
+ help="Write per-layer SVG overlays to this directory.",
413
+ )
414
+ @click.option("--overwrite", is_flag=True, help="Allow overwriting existing output files.")
415
+ @click.option("--fail-on-diff", is_flag=True, help="Exit with code 1 if any changes are detected.")
416
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress all output except errors.")
417
+ @click.option("-v", "--verbose", is_flag=True, help="Print per-change detail.")
418
+ def geomdiff_cmd(
419
+ before_dir: Path,
420
+ after_dir: Path,
421
+ layers: tuple[str, ...],
422
+ move_tol: float,
423
+ gate_radius: float,
424
+ area_tol: float,
425
+ dust_area: float,
426
+ out_json: Path | None,
427
+ out_svg_dir: Path | None,
428
+ overwrite: bool,
429
+ fail_on_diff: bool,
430
+ quiet: bool,
431
+ verbose: bool,
432
+ ) -> None:
433
+ """Geometry-aware diff: attributed, resolution-independent changes.
434
+
435
+ Compares two directories of Gerber/Excellon layer files on the parsed
436
+ vector geometry and classifies each change as added, removed, moved,
437
+ or resized -- including sub-pixel displacements invisible to the
438
+ raster diff.
439
+ """
440
+ from gerberdiff.export.json_report import write_geometry_report
441
+ from gerberdiff.export.svg_export import write_geometry_svg
442
+ from gerberdiff.geometry import compute_geometry_diff
443
+ from gerberdiff.types import GerberParseError
444
+
445
+ def _on_diagnostic(path: Path, diag: Diagnostic) -> None:
446
+ loc = f" (line {diag.line})" if diag.line else ""
447
+ if diag.severity == DiagnosticSeverity.Warning and not quiet:
448
+ click.echo(f"warning: {path.name}: {diag.message}{loc}", err=True)
449
+ elif diag.severity == DiagnosticSeverity.Info and verbose:
450
+ click.echo(f"info: {path.name}: {diag.message}", err=True)
451
+
452
+ t_start = time.perf_counter()
453
+ try:
454
+ result = compute_geometry_diff(
455
+ before_dir,
456
+ after_dir,
457
+ layers=layers if layers else None,
458
+ move_tol_mm=move_tol,
459
+ gate_radius_mm=gate_radius,
460
+ area_tol=area_tol,
461
+ dust_area_mm2=dust_area,
462
+ on_diagnostic=_on_diagnostic,
463
+ )
464
+ except GerberParseError as exc:
465
+ click.echo(f"error: {exc}", err=True)
466
+ sys.exit(2)
467
+ except OSError as exc:
468
+ click.echo(f"error: {exc}", err=True)
469
+ sys.exit(1)
470
+ elapsed_total = time.perf_counter() - t_start
471
+
472
+ if verbose:
473
+ for layer_diff in result.layers:
474
+ click.echo(
475
+ f" {layer_diff.name}: {len(layer_diff.changes)} changes, "
476
+ f"{layer_diff.unchanged_count} unchanged, "
477
+ f"+{layer_diff.added_area_mm2:.3f}/-{layer_diff.removed_area_mm2:.3f} mm^2"
478
+ )
479
+ for c in layer_diff.changes:
480
+ detail = f" {c.kind} {c.op_kind} at ({c.centroid_x:.4f}, {c.centroid_y:.4f})"
481
+ if c.kind in ("moved", "resized") and c.dx_mm is not None and c.dy_mm is not None:
482
+ detail += f" d=({c.dx_mm:+.4f}, {c.dy_mm:+.4f}) mm"
483
+ if c.net_name:
484
+ detail += f" net={c.net_name}"
485
+ click.echo(detail)
486
+
487
+ tolerances = {
488
+ "move_tol_mm": move_tol,
489
+ "gate_radius_mm": gate_radius,
490
+ "area_tol": area_tol,
491
+ "dust_area_mm2": dust_area,
492
+ }
493
+
494
+ if out_json is not None:
495
+ try:
496
+ write_geometry_report(result, out_json, tolerances=tolerances, overwrite=overwrite)
497
+ except FileExistsError as exc:
498
+ click.echo(f"error: {exc} (use --overwrite to replace)", err=True)
499
+ sys.exit(1)
500
+
501
+ if out_svg_dir is not None:
502
+ try:
503
+ for layer_diff in result.layers:
504
+ write_geometry_svg(
505
+ layer_diff,
506
+ out_svg_dir / f"{layer_diff.name}_geomdiff.svg",
507
+ overwrite=overwrite,
508
+ )
509
+ except FileExistsError as exc:
510
+ click.echo(f"error: {exc} (use --overwrite to replace)", err=True)
511
+ sys.exit(1)
512
+
513
+ if not quiet:
514
+ changed_layers = sum(1 for layer_diff in result.layers if layer_diff.has_changes)
515
+ total_changes = sum(len(layer_diff.changes) for layer_diff in result.layers)
516
+ click.echo(
517
+ f"geomdiff: {changed_layers}/{len(result.layers)} layers changed, "
518
+ f"{total_changes} changes ({elapsed_total * 1000:.0f} ms)"
519
+ )
520
+ if out_json:
521
+ click.echo(f"report: {out_json}")
522
+
523
+ sys.exit(1 if fail_on_diff and result.has_changes else 0)
524
+
525
+
526
+ if __name__ == "__main__":
527
+ cli()
File without changes