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,184 @@
1
+ """Export diff results as a versioned JSON report.
2
+
3
+ Schemas
4
+ -------
5
+ - Version 1: raster pixel diff (``DiffResult``).
6
+ - Version 2: geometry diff (``GeometryDiffResult``), ``"mode": "geometry"``.
7
+
8
+ See ``docs/schema.md`` for canonical documentation.
9
+
10
+ Coordinate values are in **inches**; geometry areas are in **mm^2** and
11
+ displacements in **mm** (see ``gerberdiff/geometry/types.py``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from gerberdiff.geometry.types import GeometryChange, GeometryDiffResult, LayerGeometryDiff
21
+ from gerberdiff.types import DiffResult, LayerDiffResult, LayerStatus, Region
22
+
23
+ _SCHEMA_VERSION = 1
24
+ _GEOMETRY_SCHEMA_VERSION = 2
25
+ _GENERATOR = "gerberdiff"
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Public API
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def build_report(diff_result: DiffResult) -> dict[str, Any]:
34
+ """Serialize *diff_result* to a JSON-compatible dictionary.
35
+
36
+ The returned dict can be passed directly to ``json.dumps`` / ``json.dump``.
37
+ """
38
+ changed_layers = sum(
39
+ 1
40
+ for lr in diff_result.layers
41
+ if lr.changed_pixel_count > 0 or lr.status != LayerStatus.Matched
42
+ )
43
+ total_regions = sum(len(lr.regions) for lr in diff_result.layers)
44
+
45
+ return {
46
+ "version": _SCHEMA_VERSION,
47
+ "generator": _GENERATOR,
48
+ "summary": {
49
+ "changed_layers": changed_layers,
50
+ "total_regions": total_regions,
51
+ "has_changes": diff_result.has_changes,
52
+ },
53
+ "layers": [_serialize_layer(lr) for lr in diff_result.layers],
54
+ }
55
+
56
+
57
+ def write_report(diff_result: DiffResult, output_path: Path, overwrite: bool = False) -> None:
58
+ """Write the JSON report to *output_path*.
59
+
60
+ Raises
61
+ ------
62
+ FileExistsError
63
+ If *output_path* already exists and *overwrite* is ``False``.
64
+ """
65
+ if output_path.exists() and not overwrite:
66
+ raise FileExistsError(f"output file already exists: {output_path}")
67
+ output_path.parent.mkdir(parents=True, exist_ok=True)
68
+ report = build_report(diff_result)
69
+ output_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Geometry report (schema version 2)
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def build_geometry_report(
78
+ result: GeometryDiffResult,
79
+ tolerances: dict[str, float] | None = None,
80
+ ) -> dict[str, Any]:
81
+ """Serialize a geometry diff result to a JSON-compatible dictionary.
82
+
83
+ *tolerances* (optional) records the classification thresholds used, for
84
+ reproducibility (keys: ``move_tol_mm``, ``gate_radius_mm``, ``area_tol``,
85
+ ``dust_area_mm2``).
86
+ """
87
+ report: dict[str, Any] = {
88
+ "version": _GEOMETRY_SCHEMA_VERSION,
89
+ "generator": _GENERATOR,
90
+ "mode": "geometry",
91
+ "summary": {
92
+ "changed_layers": sum(1 for layer in result.layers if layer.has_changes),
93
+ "total_changes": sum(len(layer.changes) for layer in result.layers),
94
+ "has_changes": result.has_changes,
95
+ },
96
+ "layers": [_serialize_geometry_layer(layer) for layer in result.layers],
97
+ }
98
+ if tolerances is not None:
99
+ report["tolerances"] = tolerances
100
+ return report
101
+
102
+
103
+ def write_geometry_report(
104
+ result: GeometryDiffResult,
105
+ output_path: Path,
106
+ tolerances: dict[str, float] | None = None,
107
+ overwrite: bool = False,
108
+ ) -> None:
109
+ """Write the geometry JSON report to *output_path*.
110
+
111
+ Raises
112
+ ------
113
+ FileExistsError
114
+ If *output_path* already exists and *overwrite* is ``False``.
115
+ """
116
+ if output_path.exists() and not overwrite:
117
+ raise FileExistsError(f"output file already exists: {output_path}")
118
+ output_path.parent.mkdir(parents=True, exist_ok=True)
119
+ report = build_geometry_report(result, tolerances)
120
+ output_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Internal helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _serialize_geometry_layer(layer: LayerGeometryDiff) -> dict[str, Any]:
129
+ return {
130
+ "name": layer.name,
131
+ "status": layer.status,
132
+ "layer_type": layer.layer_type,
133
+ "unchanged_count": layer.unchanged_count,
134
+ "added_area_mm2": round(layer.added_area_mm2, 6),
135
+ "removed_area_mm2": round(layer.removed_area_mm2, 6),
136
+ "counts": {
137
+ "added": layer.count("added"),
138
+ "removed": layer.count("removed"),
139
+ "moved": layer.count("moved"),
140
+ "resized": layer.count("resized"),
141
+ },
142
+ "changes": [_serialize_geometry_change(c) for c in layer.changes],
143
+ }
144
+
145
+
146
+ def _serialize_geometry_change(c: GeometryChange) -> dict[str, Any]:
147
+ return {
148
+ "kind": c.kind,
149
+ "op_kind": c.op_kind,
150
+ "centroid_x": c.centroid_x,
151
+ "centroid_y": c.centroid_y,
152
+ "area_mm2": round(c.area_mm2, 6),
153
+ "dx_mm": round(c.dx_mm, 6) if c.dx_mm is not None else None,
154
+ "dy_mm": round(c.dy_mm, 6) if c.dy_mm is not None else None,
155
+ "net": c.net_name,
156
+ }
157
+
158
+
159
+ def _serialize_layer(lr: LayerDiffResult) -> dict[str, Any]:
160
+ return {
161
+ "name": lr.name,
162
+ "status": lr.status,
163
+ "layer_type": lr.layer_type,
164
+ "changed_pixel_count": lr.changed_pixel_count,
165
+ "total_pixel_count": lr.total_pixel_count,
166
+ "changed_fraction": round(lr.changed_fraction, 8),
167
+ "regions": [_serialize_region(r) for r in lr.regions],
168
+ }
169
+
170
+
171
+ def _serialize_region(r: Region) -> dict[str, Any]:
172
+ bb = r.bounding_box
173
+ return {
174
+ "id": r.id,
175
+ "centroid_x": r.centroid_x,
176
+ "centroid_y": r.centroid_y,
177
+ "bbox": {
178
+ "min_x": bb.min_x,
179
+ "min_y": bb.min_y,
180
+ "max_x": bb.max_x,
181
+ "max_y": bb.max_y,
182
+ },
183
+ "pixel_count": r.pixel_count,
184
+ }
@@ -0,0 +1,92 @@
1
+ """Export a diff overlay PNG using cairocffi surfaces and numpy.
2
+
3
+ Colour scheme
4
+ -------------
5
+ - **Red:** pixels present in image A but not B (geometry removed)
6
+ - **Green:** pixels present in image B but not A (geometry added)
7
+ - **Grey:** unchanged geometry from image A (only when *show_common=True*)
8
+ - **Black:** background (fully transparent in ARGB32)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import cairocffi as cairo
16
+ import numpy as np
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Public API
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def build_overlay_png(
24
+ arr_a: np.ndarray,
25
+ arr_b: np.ndarray,
26
+ xor: np.ndarray,
27
+ output_path: Path,
28
+ show_common: bool = False,
29
+ overwrite: bool = False,
30
+ ) -> None:
31
+ """Composite a red/green diff overlay and write it to *output_path*.
32
+
33
+ Parameters
34
+ ----------
35
+ arr_a:
36
+ Rendered image A, shape ``(H, W, 4)`` uint8 BGRA (Cairo ARGB32 LE).
37
+ arr_b:
38
+ Rendered image B, shape ``(H, W, 4)`` uint8.
39
+ xor:
40
+ Channel-wise XOR of *arr_a* and *arr_b* (from ``SingleLayerDiff.xor``).
41
+ output_path:
42
+ Destination PNG file path. Parent directories are created if needed.
43
+ show_common:
44
+ When ``True``, unchanged geometry (present in both A and B) is drawn
45
+ in grey (128, 128, 128) in the output image.
46
+ overwrite:
47
+ When ``False`` (default), raises ``FileExistsError`` if *output_path*
48
+ already exists.
49
+
50
+ Raises
51
+ ------
52
+ FileExistsError
53
+ If *output_path* exists and *overwrite* is ``False``.
54
+ """
55
+ if output_path.exists() and not overwrite:
56
+ raise FileExistsError(f"output file already exists: {output_path}")
57
+
58
+ height, width = arr_a.shape[:2]
59
+
60
+ # --- build boolean masks ------------------------------------------------
61
+ # Changed wherever any of the first three channels of XOR is non-zero.
62
+ xor_mask: np.ndarray = np.any(xor[..., :3] > 0, axis=-1)
63
+
64
+ # Alpha channels: pixel is "lit" in A or B when alpha > 0.
65
+ alpha_a: np.ndarray = arr_a[..., 3] > 0
66
+ alpha_b: np.ndarray = arr_b[..., 3] > 0
67
+
68
+ removed = xor_mask & alpha_a & ~alpha_b # in A, not in B
69
+ added = xor_mask & alpha_b & ~alpha_a # in B, not in A
70
+ if show_common:
71
+ common = alpha_a & alpha_b & ~xor_mask
72
+
73
+ # --- build BGRA output buffer -------------------------------------------
74
+ out = np.zeros((height, width, 4), dtype=np.uint8)
75
+
76
+ # Removed -> red (B=0, G=0, R=255, A=255 -> BGRA: [0, 0, 255, 255])
77
+ out[removed] = [0, 0, 255, 255]
78
+ # Added -> green (B=0, G=255, R=0, A=255 -> BGRA: [0, 255, 0, 255])
79
+ out[added] = [0, 255, 0, 255]
80
+ if show_common:
81
+ out[common] = [128, 128, 128, 255]
82
+
83
+ # --- write via cairocffi ImageSurface -----------------------------------
84
+ surface = cairo.ImageSurface.create_for_data(
85
+ out,
86
+ cairo.FORMAT_ARGB32,
87
+ width,
88
+ height,
89
+ width * 4,
90
+ )
91
+ output_path.parent.mkdir(parents=True, exist_ok=True)
92
+ surface.write_to_png(str(output_path))
@@ -0,0 +1,187 @@
1
+ """Export a geometry diff overlay as an SVG (Cairo-free).
2
+
3
+ Colour semantics:
4
+
5
+ - **red** -- removed material (before-state of ``removed`` changes)
6
+ - **green** -- added material (after-state of ``added`` changes)
7
+ - **blue** -- ``moved`` objects: after-state fill plus a displacement line
8
+ from the before-centroid to the after-centroid
9
+ - **orange** -- ``resized`` objects: after-state fill, before-state outline
10
+
11
+ Y axis is flipped (Gerber +Y up -> SVG +Y down). Polygon interiors (holes)
12
+ are preserved via even-odd fill paths.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ from shapely.geometry import MultiPolygon, Polygon
21
+ from shapely.geometry.base import BaseGeometry
22
+
23
+ from gerberdiff.geometry.types import GeometryChange, LayerGeometryDiff
24
+
25
+ _Sx = Callable[[float], float]
26
+
27
+ _COLOR_REMOVED = "#cc0000"
28
+ _COLOR_ADDED = "#00aa00"
29
+ _COLOR_MOVED = "#0066cc"
30
+ _COLOR_RESIZED = "#cc6600"
31
+
32
+ _FILL_OPACITY = 0.85
33
+ _PAD_FRACTION = 0.05
34
+
35
+
36
+ def write_geometry_svg(
37
+ layer: LayerGeometryDiff,
38
+ output_path: Path,
39
+ *,
40
+ canvas_px: int = 1600,
41
+ overwrite: bool = False,
42
+ ) -> None:
43
+ """Write an SVG overlay of *layer*'s changes to *output_path*.
44
+
45
+ Raises
46
+ ------
47
+ FileExistsError
48
+ If *output_path* already exists and *overwrite* is ``False``.
49
+ """
50
+ if output_path.exists() and not overwrite:
51
+ raise FileExistsError(f"output file already exists: {output_path}")
52
+ output_path.parent.mkdir(parents=True, exist_ok=True)
53
+ output_path.write_text(render_geometry_svg(layer, canvas_px=canvas_px), encoding="utf-8")
54
+
55
+
56
+ def render_geometry_svg(layer: LayerGeometryDiff, *, canvas_px: int = 1600) -> str:
57
+ """Render *layer*'s changes to an SVG document string."""
58
+ geoms: list[BaseGeometry] = []
59
+ for c in layer.changes:
60
+ if c.before_geom is not None:
61
+ geoms.append(c.before_geom)
62
+ if c.after_geom is not None:
63
+ geoms.append(c.after_geom)
64
+
65
+ if not geoms:
66
+ return (
67
+ "<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>"
68
+ f"<title>{_escape(layer.name)}: no changes</title></svg>"
69
+ )
70
+
71
+ min_x = min(g.bounds[0] for g in geoms)
72
+ min_y = min(g.bounds[1] for g in geoms)
73
+ max_x = max(g.bounds[2] for g in geoms)
74
+ max_y = max(g.bounds[3] for g in geoms)
75
+ span = max(max_x - min_x, max_y - min_y, 1e-6)
76
+ pad = span * _PAD_FRACTION
77
+ min_x -= pad
78
+ min_y -= pad
79
+ max_x += pad
80
+ max_y += pad
81
+
82
+ scale = canvas_px / max(max_x - min_x, max_y - min_y)
83
+ width = (max_x - min_x) * scale
84
+ height = (max_y - min_y) * scale
85
+
86
+ def sx(x: float) -> float:
87
+ return (x - min_x) * scale
88
+
89
+ def sy(y: float) -> float: # Gerber +Y up -> SVG +Y down
90
+ return (max_y - y) * scale
91
+
92
+ parts: list[str] = [
93
+ f"<svg xmlns='http://www.w3.org/2000/svg' width='{width:.0f}' "
94
+ f"height='{height:.0f}' viewBox='0 0 {width:.0f} {height:.0f}'>",
95
+ f"<title>{_escape(layer.name)}</title>",
96
+ f"<rect width='{width:.0f}' height='{height:.0f}' fill='white'/>",
97
+ ]
98
+ for c in layer.changes:
99
+ parts.extend(_change_svg(c, sx, sy))
100
+ parts.append(_legend())
101
+ parts.append("</svg>")
102
+ return "\n".join(parts)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Internal helpers
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def _change_svg(c: GeometryChange, sx: _Sx, sy: _Sx) -> list[str]:
111
+ parts: list[str] = []
112
+ if c.kind == "removed" and c.before_geom is not None:
113
+ parts.append(_geom_path(c.before_geom, _COLOR_REMOVED, _FILL_OPACITY, sx, sy))
114
+ elif c.kind == "added" and c.after_geom is not None:
115
+ parts.append(_geom_path(c.after_geom, _COLOR_ADDED, _FILL_OPACITY, sx, sy))
116
+ elif c.kind == "moved" and c.after_geom is not None:
117
+ parts.append(_geom_path(c.after_geom, _COLOR_MOVED, _FILL_OPACITY, sx, sy))
118
+ if c.before_geom is not None:
119
+ bc = c.before_geom.centroid
120
+ ac = c.after_geom.centroid
121
+ parts.append(
122
+ f"<line x1='{sx(bc.x):.1f}' y1='{sy(bc.y):.1f}' "
123
+ f"x2='{sx(ac.x):.1f}' y2='{sy(ac.y):.1f}' "
124
+ f"stroke='{_COLOR_MOVED}' stroke-width='1.5'/>"
125
+ )
126
+ elif c.kind == "resized":
127
+ if c.after_geom is not None:
128
+ parts.append(_geom_path(c.after_geom, _COLOR_RESIZED, _FILL_OPACITY, sx, sy))
129
+ if c.before_geom is not None:
130
+ parts.append(_geom_path(c.before_geom, "none", 0.0, sx, sy, stroke=_COLOR_RESIZED))
131
+ return [p for p in parts if p]
132
+
133
+
134
+ def _geom_path(
135
+ geom: BaseGeometry,
136
+ fill: str,
137
+ opacity: float,
138
+ sx: _Sx,
139
+ sy: _Sx,
140
+ stroke: str | None = None,
141
+ ) -> str:
142
+ """One even-odd <path> for all polygon rings (exteriors + holes)."""
143
+ polys: list[Polygon]
144
+ if isinstance(geom, Polygon):
145
+ polys = [geom]
146
+ elif isinstance(geom, MultiPolygon):
147
+ polys = list(geom.geoms)
148
+ else:
149
+ polys = [g for g in getattr(geom, "geoms", []) if isinstance(g, Polygon)]
150
+
151
+ d_parts: list[str] = []
152
+ for poly in polys:
153
+ if poly.is_empty:
154
+ continue
155
+ rings = [poly.exterior, *poly.interiors]
156
+ for ring in rings:
157
+ coords = " L ".join(f"{sx(x):.2f},{sy(y):.2f}" for x, y in ring.coords)
158
+ d_parts.append(f"M {coords} Z")
159
+ if not d_parts:
160
+ return ""
161
+ stroke_attr = f" stroke='{stroke}' stroke-width='1'" if stroke else " stroke='none'"
162
+ return (
163
+ f"<path d='{' '.join(d_parts)}' fill='{fill}' fill-opacity='{opacity}' "
164
+ f"fill-rule='evenodd'{stroke_attr}/>"
165
+ )
166
+
167
+
168
+ def _legend() -> str:
169
+ rows = [
170
+ (_COLOR_REMOVED, "removed"),
171
+ (_COLOR_ADDED, "added"),
172
+ (_COLOR_MOVED, "moved"),
173
+ (_COLOR_RESIZED, "resized"),
174
+ ]
175
+ items = [
176
+ "<g font-family='sans-serif' font-size='16'>",
177
+ "<rect x='8' y='8' width='120' height='98' fill='white' stroke='#888'/>",
178
+ ]
179
+ for i, (color, label) in enumerate(rows):
180
+ y = 30 + i * 22
181
+ items.append(f"<text x='20' y='{y}' fill='{color}'>{label}</text>")
182
+ items.append("</g>")
183
+ return "".join(items)
184
+
185
+
186
+ def _escape(text: str) -> str:
187
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
@@ -0,0 +1,37 @@
1
+ """Geometry-aware diff engine: resolution-independent, attributed changes.
2
+
3
+ Public surface:
4
+
5
+ - :func:`compute_geometry_diff` -- directory-vs-directory geometry diff.
6
+ - :class:`GeometryChange`, :class:`LayerGeometryDiff`,
7
+ :class:`GeometryDiffResult` -- result types.
8
+
9
+ The geometry pipeline operates on the parsed IR and is Cairo-free; see
10
+ ``docs/geometry-diff.md`` for the design.
11
+ """
12
+
13
+ from gerberdiff.geometry.driver import (
14
+ DEFAULT_AREA_TOL,
15
+ DEFAULT_DUST_AREA_MM2,
16
+ DEFAULT_GATE_RADIUS_MM,
17
+ DEFAULT_MOVE_TOL_MM,
18
+ compute_geometry_diff,
19
+ )
20
+ from gerberdiff.geometry.types import (
21
+ ChangeKind,
22
+ GeometryChange,
23
+ GeometryDiffResult,
24
+ LayerGeometryDiff,
25
+ )
26
+
27
+ __all__ = [
28
+ "DEFAULT_AREA_TOL",
29
+ "DEFAULT_DUST_AREA_MM2",
30
+ "DEFAULT_GATE_RADIUS_MM",
31
+ "DEFAULT_MOVE_TOL_MM",
32
+ "ChangeKind",
33
+ "GeometryChange",
34
+ "GeometryDiffResult",
35
+ "LayerGeometryDiff",
36
+ "compute_geometry_diff",
37
+ ]