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,153 @@
1
+ """Per-layer boolean difference between two revisions' geometry.
2
+
3
+ Produces (added, removed) material as polygonal geometry:
4
+
5
+ added = geometry(B) \\ geometry(A)
6
+ removed = geometry(A) \\ geometry(B)
7
+
8
+ Fast path (all-dark layers, the overwhelmingly common case)
9
+ -----------------------------------------------------------
10
+ Ops whose content signature appears in both revisions cancel *exactly* and
11
+ are never expanded. Only changed ops enter the boolean math:
12
+
13
+ added_raw = union(B_only) \\ union(A_only)
14
+ removed_raw = union(A_only) \\ union(B_only)
15
+
16
+ Unchanged material can still mask part of a raw difference (e.g. a removed
17
+ trace running under an unchanged pad), so each raw result is then reduced
18
+ by the unchanged ops whose bounding boxes intersect it:
19
+
20
+ added = added_raw \\ union(interacting unchanged)
21
+ removed = removed_raw \\ union(interacting unchanged)
22
+
23
+ This is exact -- unchanged material that does not intersect a raw
24
+ difference cannot affect it -- and the bbox query uses the ops' analytic
25
+ bounds, so non-interacting unchanged ops are never expanded at all.
26
+
27
+ All differences are snap-rounded via GEOS ``grid_size`` for numeric
28
+ robustness (no separate ``set_precision`` pass).
29
+
30
+ Full path
31
+ ---------
32
+ Any clear-polarity content (either side) invalidates the flat-union model;
33
+ fall back to the ordered polarity replay (``resolve_geometry``) on both
34
+ sides and difference the resolved geometry. Correct, slower.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import shapely
40
+ from shapely.geometry import MultiPolygon, Polygon, box
41
+ from shapely.geometry.base import BaseGeometry
42
+ from shapely.ops import unary_union
43
+ from shapely.strtree import STRtree
44
+
45
+ from gerberdiff.geometry.layer_geometry import ExpandedOp, LayerGeometry, resolve_geometry
46
+
47
+ # Snap-rounding grid for boolean robustness (inches). Far below any
48
+ # manufacturable feature; kills float-noise slivers without moving geometry.
49
+ _GRID_IN = 1e-8
50
+
51
+ _EMPTY: BaseGeometry = Polygon()
52
+
53
+
54
+ def boolean_layer_diff(
55
+ a: LayerGeometry,
56
+ b: LayerGeometry,
57
+ a_only: list[ExpandedOp],
58
+ b_only: list[ExpandedOp],
59
+ unchanged: list[ExpandedOp],
60
+ *,
61
+ dust_area: float = 0.0,
62
+ ) -> tuple[BaseGeometry, BaseGeometry]:
63
+ """Return ``(added, removed)`` polygonal geometry in square-inch space.
64
+
65
+ *a_only*, *b_only*, *unchanged* come from
66
+ :func:`gerberdiff.geometry.attribute.partition_unchanged`. *unchanged*
67
+ may be either side's list (the geometry is identical by construction).
68
+ *dust_area* drops difference components smaller than this area (in^2).
69
+ """
70
+ if a.has_clear or b.has_clear:
71
+ geom_a = resolve_geometry(a.ops)
72
+ geom_b = resolve_geometry(b.ops)
73
+ added = _difference(geom_b, geom_a)
74
+ removed = _difference(geom_a, geom_b)
75
+ return _drop_dust(added, dust_area), _drop_dust(removed, dust_area)
76
+
77
+ if not a_only and not b_only:
78
+ return _EMPTY, _EMPTY
79
+
80
+ u_a_only = unary_union([op.geom for op in a_only]) if a_only else _EMPTY
81
+ u_b_only = unary_union([op.geom for op in b_only]) if b_only else _EMPTY
82
+
83
+ added_raw = _difference(u_b_only, u_a_only)
84
+ removed_raw = _difference(u_a_only, u_b_only)
85
+
86
+ # Reduce by unchanged material that intersects the raw differences.
87
+ # The raw results are small (the changed neighbourhood), so the masked
88
+ # differences are cheap even on dense layers.
89
+ tree = _bounds_tree(unchanged)
90
+ added = _subtract_interacting(added_raw, unchanged, tree)
91
+ removed = _subtract_interacting(removed_raw, unchanged, tree)
92
+ return _drop_dust(added, dust_area), _drop_dust(removed, dust_area)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Internal helpers
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _bounds_tree(ops: list[ExpandedOp]) -> STRtree | None:
101
+ """STRtree over the ops' analytic bounding boxes (no expansion)."""
102
+ if not ops:
103
+ return None
104
+ return STRtree([box(*op.bounds) for op in ops])
105
+
106
+
107
+ def _subtract_interacting(
108
+ raw: BaseGeometry,
109
+ unchanged: list[ExpandedOp],
110
+ tree: STRtree | None,
111
+ ) -> BaseGeometry:
112
+ """Subtract unchanged ops whose bboxes intersect *raw* from it."""
113
+ if raw.is_empty or tree is None:
114
+ return raw
115
+ hit_indices: set[int] = set()
116
+ for part in _polygon_parts(raw):
117
+ hit_indices.update(int(i) for i in tree.query(part))
118
+ if not hit_indices:
119
+ return raw
120
+ context = unary_union([unchanged[i].geom for i in sorted(hit_indices)])
121
+ return _difference(raw, context)
122
+
123
+
124
+ def _difference(minuend: BaseGeometry, subtrahend: BaseGeometry) -> BaseGeometry:
125
+ """Snap-rounded difference, robust against float-noise topology errors."""
126
+ if minuend.is_empty:
127
+ return _EMPTY
128
+ if subtrahend.is_empty:
129
+ return minuend
130
+ return shapely.difference(minuend, subtrahend, grid_size=_GRID_IN)
131
+
132
+
133
+ def _drop_dust(geom: BaseGeometry, dust_area: float) -> BaseGeometry:
134
+ """Keep only polygonal components with area >= *dust_area*."""
135
+ polys = [p for p in _polygon_parts(geom) if not p.is_empty and p.area >= dust_area]
136
+ if not polys:
137
+ return _EMPTY
138
+ if len(polys) == 1:
139
+ return polys[0]
140
+ return MultiPolygon(polys)
141
+
142
+
143
+ def _polygon_parts(geom: BaseGeometry) -> list[Polygon]:
144
+ """Flatten any geometry to its polygonal parts (drops lines/points)."""
145
+ if geom.is_empty:
146
+ return []
147
+ if isinstance(geom, Polygon):
148
+ return [geom]
149
+ parts: list[Polygon] = []
150
+ if hasattr(geom, "geoms"):
151
+ for g in geom.geoms:
152
+ parts.extend(_polygon_parts(g))
153
+ return parts