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