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