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,215 @@
|
|
|
1
|
+
"""Macro aperture geometry: evaluated macro primitives -> shapely.
|
|
2
|
+
|
|
3
|
+
Mirrors ``render/macro_renderer.py`` primitive-by-primitive, with two
|
|
4
|
+
deliberate divergences (geometry follows the Gerber spec where the raster
|
|
5
|
+
engine takes compositing shortcuts):
|
|
6
|
+
|
|
7
|
+
1. **Exposure scope** -- an exposure-0 primitive erases *within the macro
|
|
8
|
+
flash only* (spec), whereas the raster engine's ``DEST_OUT`` erases
|
|
9
|
+
underlying canvas content globally.
|
|
10
|
+
2. **Rotation centre** -- primitive rotation is applied to the *whole
|
|
11
|
+
primitive* around the macro origin (spec), whereas the raster engine
|
|
12
|
+
rotates some primitives (21, 5, 6, 7) around their own centre. The two
|
|
13
|
+
agree in the overwhelmingly common cases (rotation 0 or centre at origin).
|
|
14
|
+
|
|
15
|
+
A macro that fails to evaluate contributes no geometry and a ``Warning``
|
|
16
|
+
diagnostic (matching the renderer's behaviour).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
|
|
23
|
+
from shapely import affinity
|
|
24
|
+
from shapely.geometry import Polygon
|
|
25
|
+
from shapely.geometry.base import BaseGeometry
|
|
26
|
+
|
|
27
|
+
from gerberdiff.geometry.primitives import circle, rectangle, regular_polygon
|
|
28
|
+
from gerberdiff.parse.macro_parser import (
|
|
29
|
+
EvaluatedCircle,
|
|
30
|
+
EvaluatedLineCenter,
|
|
31
|
+
EvaluatedLineVector,
|
|
32
|
+
EvaluatedMoire,
|
|
33
|
+
EvaluatedOutline,
|
|
34
|
+
EvaluatedPolygon,
|
|
35
|
+
EvaluatedPrimitive,
|
|
36
|
+
EvaluatedThermal,
|
|
37
|
+
evaluate_macro_primitives,
|
|
38
|
+
)
|
|
39
|
+
from gerberdiff.types import Diagnostic, DiagnosticSeverity, MacroAperture
|
|
40
|
+
|
|
41
|
+
_EMPTY: BaseGeometry = Polygon()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def macro_flash_geometry(
|
|
45
|
+
aperture: MacroAperture,
|
|
46
|
+
x: float,
|
|
47
|
+
y: float,
|
|
48
|
+
) -> tuple[BaseGeometry, list[Diagnostic]]:
|
|
49
|
+
"""Expand a macro aperture flash at world position (x, y).
|
|
50
|
+
|
|
51
|
+
Returns the composed geometry (possibly empty) and any diagnostics.
|
|
52
|
+
Exposure-on primitives union into the accumulator; exposure-off
|
|
53
|
+
primitives subtract from it (within the macro only).
|
|
54
|
+
"""
|
|
55
|
+
if aperture.macro_def is None:
|
|
56
|
+
return _EMPTY, []
|
|
57
|
+
try:
|
|
58
|
+
primitives = evaluate_macro_primitives(aperture.macro_def, aperture.params)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
return _EMPTY, [
|
|
61
|
+
Diagnostic(
|
|
62
|
+
severity=DiagnosticSeverity.Warning,
|
|
63
|
+
message=f"Macro '{aperture.macro_def.name}' evaluation failed: {exc}",
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
acc: BaseGeometry = _EMPTY
|
|
68
|
+
for p in primitives:
|
|
69
|
+
geom = _primitive_geometry(p)
|
|
70
|
+
if geom is None or geom.is_empty:
|
|
71
|
+
continue
|
|
72
|
+
if _exposure_off(p):
|
|
73
|
+
acc = acc.difference(geom)
|
|
74
|
+
else:
|
|
75
|
+
acc = acc.union(geom)
|
|
76
|
+
|
|
77
|
+
if acc.is_empty:
|
|
78
|
+
return _EMPTY, []
|
|
79
|
+
|
|
80
|
+
# Macro coords -> world: scale to inches, then translate to the flash.
|
|
81
|
+
scale = aperture.unit_scale
|
|
82
|
+
if scale != 1.0:
|
|
83
|
+
acc = affinity.scale(acc, xfact=scale, yfact=scale, origin=(0, 0))
|
|
84
|
+
return affinity.translate(acc, xoff=x, yoff=y), []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Per-primitive geometry (macro-local coordinates, unscaled)
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _exposure_off(p: EvaluatedPrimitive) -> bool:
|
|
93
|
+
exposure = getattr(p, "exposure", 1.0)
|
|
94
|
+
return bool(exposure == 0.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _rotated(geom: BaseGeometry, rotation_deg: float) -> BaseGeometry:
|
|
98
|
+
"""Rotate a primitive around the macro origin (spec semantics)."""
|
|
99
|
+
if rotation_deg == 0.0 or geom.is_empty:
|
|
100
|
+
return geom
|
|
101
|
+
return affinity.rotate(geom, rotation_deg, origin=(0, 0))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _primitive_geometry(p: EvaluatedPrimitive) -> BaseGeometry | None:
|
|
105
|
+
match p:
|
|
106
|
+
case EvaluatedCircle():
|
|
107
|
+
if p.diameter <= 0.0:
|
|
108
|
+
return None
|
|
109
|
+
return _rotated(circle(p.center_x, p.center_y, p.diameter / 2.0), p.rotation)
|
|
110
|
+
|
|
111
|
+
case EvaluatedLineVector():
|
|
112
|
+
return _line_vector(p)
|
|
113
|
+
|
|
114
|
+
case EvaluatedLineCenter():
|
|
115
|
+
if p.width <= 0.0 or p.height <= 0.0:
|
|
116
|
+
return None
|
|
117
|
+
return _rotated(rectangle(p.center_x, p.center_y, p.width, p.height), p.rotation)
|
|
118
|
+
|
|
119
|
+
case EvaluatedOutline():
|
|
120
|
+
if len(p.vertices) < 6: # need at least 3 points
|
|
121
|
+
return None
|
|
122
|
+
pts = [(p.vertices[i], p.vertices[i + 1]) for i in range(0, len(p.vertices) - 1, 2)]
|
|
123
|
+
return _rotated(Polygon(pts), p.rotation)
|
|
124
|
+
|
|
125
|
+
case EvaluatedPolygon():
|
|
126
|
+
if p.diameter <= 0.0 or p.num_vertices < 3:
|
|
127
|
+
return None
|
|
128
|
+
return _rotated(
|
|
129
|
+
regular_polygon(p.center_x, p.center_y, p.diameter / 2.0, p.num_vertices),
|
|
130
|
+
p.rotation,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
case EvaluatedMoire():
|
|
134
|
+
return _moire(p)
|
|
135
|
+
|
|
136
|
+
case EvaluatedThermal():
|
|
137
|
+
return _thermal(p)
|
|
138
|
+
|
|
139
|
+
return None # pragma: no cover -- exhaustive match above
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _line_vector(p: EvaluatedLineVector) -> BaseGeometry | None:
|
|
143
|
+
"""Rectangle spanning start->end with the given width."""
|
|
144
|
+
if p.width <= 0.0:
|
|
145
|
+
return None
|
|
146
|
+
dx = p.end_x - p.start_x
|
|
147
|
+
dy = p.end_y - p.start_y
|
|
148
|
+
length = math.hypot(dx, dy)
|
|
149
|
+
if length == 0.0:
|
|
150
|
+
return None
|
|
151
|
+
nx = -dy / length * (p.width / 2.0)
|
|
152
|
+
ny = dx / length * (p.width / 2.0)
|
|
153
|
+
quad = Polygon(
|
|
154
|
+
[
|
|
155
|
+
(p.start_x + nx, p.start_y + ny),
|
|
156
|
+
(p.start_x - nx, p.start_y - ny),
|
|
157
|
+
(p.end_x - nx, p.end_y - ny),
|
|
158
|
+
(p.end_x + nx, p.end_y + ny),
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
return _rotated(quad, p.rotation)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _moire(p: EvaluatedMoire) -> BaseGeometry | None:
|
|
165
|
+
"""Concentric rings plus a two-bar crosshair."""
|
|
166
|
+
outer_r = p.outer_diameter / 2.0
|
|
167
|
+
if outer_r <= 0.0:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
acc: BaseGeometry = _EMPTY
|
|
171
|
+
max_rings = p.max_rings if p.max_rings > 0 else 100
|
|
172
|
+
for i in range(max_rings):
|
|
173
|
+
r_outer = outer_r - i * (p.ring_thickness + p.ring_gap)
|
|
174
|
+
if r_outer <= 0.0:
|
|
175
|
+
break
|
|
176
|
+
ring: BaseGeometry = circle(p.center_x, p.center_y, r_outer)
|
|
177
|
+
r_inner = r_outer - p.ring_thickness
|
|
178
|
+
if r_inner > 0.0:
|
|
179
|
+
ring = ring.difference(circle(p.center_x, p.center_y, r_inner))
|
|
180
|
+
acc = acc.union(ring)
|
|
181
|
+
|
|
182
|
+
cl = p.crosshair_length / 2.0
|
|
183
|
+
ct = p.crosshair_thickness / 2.0
|
|
184
|
+
if cl > 0.0 and ct > 0.0:
|
|
185
|
+
h_bar = rectangle(p.center_x, p.center_y, cl * 2.0, ct * 2.0)
|
|
186
|
+
v_bar = rectangle(p.center_x, p.center_y, ct * 2.0, cl * 2.0)
|
|
187
|
+
acc = acc.union(h_bar).union(v_bar)
|
|
188
|
+
|
|
189
|
+
if acc.is_empty:
|
|
190
|
+
return None
|
|
191
|
+
return _rotated(acc, p.rotation)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _thermal(p: EvaluatedThermal) -> BaseGeometry | None:
|
|
195
|
+
"""Annulus with four rectangular gaps at 0/90/180/270 degrees."""
|
|
196
|
+
r_outer = p.outer_diameter / 2.0
|
|
197
|
+
if r_outer <= 0.0:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
acc: BaseGeometry = circle(p.center_x, p.center_y, r_outer)
|
|
201
|
+
r_inner = p.inner_diameter / 2.0
|
|
202
|
+
if r_inner > 0.0:
|
|
203
|
+
acc = acc.difference(circle(p.center_x, p.center_y, r_inner))
|
|
204
|
+
|
|
205
|
+
gap_w = p.gap / 2.0
|
|
206
|
+
if gap_w > 0.0:
|
|
207
|
+
# Bars need only span the annulus (2*r_outer); oversizing to 4*r_outer
|
|
208
|
+
# avoids exact edge-tangency in the boolean difference.
|
|
209
|
+
h_gap = rectangle(p.center_x, p.center_y, r_outer * 4.0, gap_w * 2.0)
|
|
210
|
+
v_gap = rectangle(p.center_x, p.center_y, gap_w * 2.0, r_outer * 4.0)
|
|
211
|
+
acc = acc.difference(h_gap).difference(v_gap)
|
|
212
|
+
|
|
213
|
+
if acc.is_empty:
|
|
214
|
+
return None
|
|
215
|
+
return _rotated(acc, p.rotation)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Low-level shapely shape builders shared by the geometry engine.
|
|
2
|
+
|
|
3
|
+
All coordinates are in **inches** (the IR convention). Circle and arc
|
|
4
|
+
tessellation is adaptive: segment counts are derived from a chord (sagitta)
|
|
5
|
+
tolerance so small pads stay cheap while large features keep fidelity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
from shapely.geometry import LineString, Point, Polygon, box
|
|
13
|
+
from shapely.geometry.base import BaseGeometry
|
|
14
|
+
|
|
15
|
+
from gerberdiff.types import ArcSegment
|
|
16
|
+
|
|
17
|
+
# Chord (sagitta) tolerance for circle/arc tessellation: 1 um in inches.
|
|
18
|
+
CHORD_TOL_IN = 1e-3 / 25.4
|
|
19
|
+
|
|
20
|
+
# Bounds for tessellation segment counts (full circle).
|
|
21
|
+
_MIN_SEGMENTS = 16
|
|
22
|
+
_MAX_SEGMENTS = 256
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def circle_segments(radius: float, tol: float = CHORD_TOL_IN) -> int:
|
|
26
|
+
"""Segment count for a full circle of *radius* with sagitta <= *tol*.
|
|
27
|
+
|
|
28
|
+
sagitta = r * (1 - cos(pi/n)) => n >= pi / acos(1 - tol/r)
|
|
29
|
+
"""
|
|
30
|
+
if radius <= tol:
|
|
31
|
+
return _MIN_SEGMENTS
|
|
32
|
+
n = math.pi / math.acos(1.0 - tol / radius)
|
|
33
|
+
return max(_MIN_SEGMENTS, min(_MAX_SEGMENTS, math.ceil(n)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _quad_segs(radius: float) -> int:
|
|
37
|
+
"""quad_segs value for shapely ``buffer`` (segments per quarter circle)."""
|
|
38
|
+
return max(4, math.ceil(circle_segments(radius) / 4))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def circle(x: float, y: float, radius: float) -> BaseGeometry:
|
|
42
|
+
"""Filled circle centred at (x, y)."""
|
|
43
|
+
return Point(x, y).buffer(radius, quad_segs=_quad_segs(radius))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rectangle(x: float, y: float, width: float, height: float) -> BaseGeometry:
|
|
47
|
+
"""Axis-aligned filled rectangle centred at (x, y)."""
|
|
48
|
+
w2, h2 = width / 2.0, height / 2.0
|
|
49
|
+
return box(x - w2, y - h2, x + w2, y + h2)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def obround(x: float, y: float, width: float, height: float) -> BaseGeometry:
|
|
53
|
+
"""Obround (stadium/capsule): rectangle with semicircular short ends."""
|
|
54
|
+
r = min(width, height) / 2.0
|
|
55
|
+
if width == height:
|
|
56
|
+
return circle(x, y, r)
|
|
57
|
+
if width > height:
|
|
58
|
+
a = (x - (width / 2.0 - r), y)
|
|
59
|
+
b = (x + (width / 2.0 - r), y)
|
|
60
|
+
else:
|
|
61
|
+
a = (x, y - (height / 2.0 - r))
|
|
62
|
+
b = (x, y + (height / 2.0 - r))
|
|
63
|
+
return LineString([a, b]).buffer(r, quad_segs=_quad_segs(r))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def regular_polygon(
|
|
67
|
+
x: float,
|
|
68
|
+
y: float,
|
|
69
|
+
outer_radius: float,
|
|
70
|
+
num_vertices: int,
|
|
71
|
+
rotation_deg: float = 0.0,
|
|
72
|
+
) -> BaseGeometry:
|
|
73
|
+
"""Regular n-gon centred at (x, y); first vertex at *rotation_deg*.
|
|
74
|
+
|
|
75
|
+
Matches the renderer's vertex placement (``_draw_polygon_flash``).
|
|
76
|
+
"""
|
|
77
|
+
rot = math.radians(rotation_deg)
|
|
78
|
+
pts = [
|
|
79
|
+
(
|
|
80
|
+
x + outer_radius * math.cos(rot + 2.0 * math.pi * i / num_vertices),
|
|
81
|
+
y + outer_radius * math.sin(rot + 2.0 * math.pi * i / num_vertices),
|
|
82
|
+
)
|
|
83
|
+
for i in range(num_vertices)
|
|
84
|
+
]
|
|
85
|
+
return Polygon(pts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def arc_points(arc: ArcSegment, tol: float = CHORD_TOL_IN) -> list[tuple[float, float]]:
|
|
89
|
+
"""Sample an :class:`ArcSegment` into a polyline (including both endpoints).
|
|
90
|
+
|
|
91
|
+
The ArcSegment convention (``arc_math.py``) is directional and monotonic:
|
|
92
|
+
CCW arcs have ``end_angle_deg > start_angle_deg``; CW arcs have
|
|
93
|
+
``end_angle_deg < start_angle_deg``. The sweep is traversed directly --
|
|
94
|
+
no wraparound handling is required.
|
|
95
|
+
"""
|
|
96
|
+
sweep = arc.end_angle_deg - arc.start_angle_deg
|
|
97
|
+
n_full = circle_segments(arc.radius, tol)
|
|
98
|
+
n = max(2, math.ceil(n_full * abs(sweep) / 360.0))
|
|
99
|
+
pts: list[tuple[float, float]] = []
|
|
100
|
+
for i in range(n + 1):
|
|
101
|
+
theta = math.radians(arc.start_angle_deg + sweep * i / n)
|
|
102
|
+
pts.append(
|
|
103
|
+
(
|
|
104
|
+
arc.center_x + arc.radius * math.cos(theta),
|
|
105
|
+
arc.center_y + arc.radius * math.sin(theta),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return pts
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Public result types for the geometry diff engine.
|
|
2
|
+
|
|
3
|
+
Unit conventions (documented in ``docs/schema.md``):
|
|
4
|
+
|
|
5
|
+
- ``centroid_x`` / ``centroid_y`` are in **inches** (matching the raster
|
|
6
|
+
engine's ``Region`` convention),
|
|
7
|
+
- areas are in **mm^2**,
|
|
8
|
+
- ``dx_mm`` / ``dy_mm`` displacements are in **mm**.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING, Literal
|
|
15
|
+
|
|
16
|
+
from gerberdiff.types import DrawOp, LayerStatus, LayerType, RegionFill
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from shapely.geometry.base import BaseGeometry
|
|
20
|
+
|
|
21
|
+
ChangeKind = Literal["added", "removed", "moved", "resized"]
|
|
22
|
+
|
|
23
|
+
# mm^2 per square inch.
|
|
24
|
+
MM2_PER_IN2 = 25.4 * 25.4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GeometryChange:
|
|
29
|
+
"""One attributed change on a layer.
|
|
30
|
+
|
|
31
|
+
``centroid_*`` and ``area_mm2`` describe the after-state object when one
|
|
32
|
+
exists (added/moved/resized) and the before-state object otherwise
|
|
33
|
+
(removed). ``before_geom`` / ``after_geom`` carry the shapely polygons
|
|
34
|
+
for programmatic/SVG use; they are excluded from ``repr`` and JSON.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
kind: ChangeKind
|
|
38
|
+
op_kind: str # flash | stroke | region
|
|
39
|
+
centroid_x: float # inches
|
|
40
|
+
centroid_y: float # inches
|
|
41
|
+
area_mm2: float
|
|
42
|
+
dx_mm: float | None = None # moved/resized: after - before
|
|
43
|
+
dy_mm: float | None = None
|
|
44
|
+
net_name: str | None = None
|
|
45
|
+
before_op: DrawOp | RegionFill | None = field(default=None, repr=False)
|
|
46
|
+
after_op: DrawOp | RegionFill | None = field(default=None, repr=False)
|
|
47
|
+
before_geom: BaseGeometry | None = field(default=None, repr=False)
|
|
48
|
+
after_geom: BaseGeometry | None = field(default=None, repr=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class LayerGeometryDiff:
|
|
53
|
+
"""Geometry diff result for one matched/added/removed layer pair."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
layer_type: LayerType
|
|
57
|
+
status: LayerStatus # matched | added | removed
|
|
58
|
+
changes: list[GeometryChange] = field(default_factory=list)
|
|
59
|
+
unchanged_count: int = 0
|
|
60
|
+
added_area_mm2: float = 0.0
|
|
61
|
+
removed_area_mm2: float = 0.0
|
|
62
|
+
|
|
63
|
+
def count(self, kind: ChangeKind) -> int:
|
|
64
|
+
"""Number of changes of the given *kind*."""
|
|
65
|
+
return sum(1 for c in self.changes if c.kind == kind)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def has_changes(self) -> bool:
|
|
69
|
+
return (
|
|
70
|
+
bool(self.changes)
|
|
71
|
+
or self.status != LayerStatus.Matched
|
|
72
|
+
or self.added_area_mm2 > 0.0
|
|
73
|
+
or self.removed_area_mm2 > 0.0
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class GeometryDiffResult:
|
|
79
|
+
"""Full geometry diff across all matched layers."""
|
|
80
|
+
|
|
81
|
+
layers: list[LayerGeometryDiff] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def has_changes(self) -> bool:
|
|
85
|
+
return any(layer.has_changes for layer in self.layers)
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from gerberdiff.types import ArcSegment, BoundingBox
|
|
6
|
+
|
|
7
|
+
_RAD_TO_DEG = 180.0 / math.pi
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute_arc_multi_quadrant(
|
|
11
|
+
start_x: float,
|
|
12
|
+
start_y: float,
|
|
13
|
+
end_x: float,
|
|
14
|
+
end_y: float,
|
|
15
|
+
i: float,
|
|
16
|
+
j: float,
|
|
17
|
+
clockwise: bool,
|
|
18
|
+
) -> ArcSegment | None:
|
|
19
|
+
"""Compute arc geometry for G75 (multi-quadrant) mode.
|
|
20
|
+
|
|
21
|
+
(i, j) are signed offsets from the start point to the arc centre.
|
|
22
|
+
The arc can sweep any angle up to 360deg.
|
|
23
|
+
|
|
24
|
+
Returns None for a degenerate arc (radius < 1e-10).
|
|
25
|
+
"""
|
|
26
|
+
center_x = start_x + i
|
|
27
|
+
center_y = start_y + j
|
|
28
|
+
radius = math.hypot(start_x - center_x, start_y - center_y)
|
|
29
|
+
|
|
30
|
+
if radius < 1e-10:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
start_angle = math.atan2(start_y - center_y, start_x - center_x) * _RAD_TO_DEG
|
|
34
|
+
end_angle = math.atan2(end_y - center_y, end_x - center_x) * _RAD_TO_DEG
|
|
35
|
+
|
|
36
|
+
# Full-circle: start and end coincide
|
|
37
|
+
if math.hypot(start_x - end_x, start_y - end_y) < 1e-10:
|
|
38
|
+
end_angle = start_angle - 360.0 if clockwise else start_angle + 360.0
|
|
39
|
+
else:
|
|
40
|
+
if clockwise and end_angle >= start_angle:
|
|
41
|
+
end_angle -= 360.0
|
|
42
|
+
elif not clockwise and end_angle <= start_angle:
|
|
43
|
+
end_angle += 360.0
|
|
44
|
+
|
|
45
|
+
return ArcSegment(
|
|
46
|
+
center_x=center_x,
|
|
47
|
+
center_y=center_y,
|
|
48
|
+
radius=radius,
|
|
49
|
+
start_angle_deg=start_angle,
|
|
50
|
+
end_angle_deg=end_angle,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_arc_single_quadrant(
|
|
55
|
+
start_x: float,
|
|
56
|
+
start_y: float,
|
|
57
|
+
end_x: float,
|
|
58
|
+
end_y: float,
|
|
59
|
+
i: float,
|
|
60
|
+
j: float,
|
|
61
|
+
clockwise: bool,
|
|
62
|
+
) -> ArcSegment | None:
|
|
63
|
+
"""Compute arc geometry for G74 (single-quadrant) mode.
|
|
64
|
+
|
|
65
|
+
In G74 mode the magnitude of (i, j) is always positive but the sign is
|
|
66
|
+
implicit. Try all four sign combinations and keep the candidate where the
|
|
67
|
+
arc sweep is <= 90deg (single-quadrant constraint), preferring the one with
|
|
68
|
+
the smallest start-to-end radius mismatch.
|
|
69
|
+
|
|
70
|
+
Returns None if no valid candidate is found.
|
|
71
|
+
"""
|
|
72
|
+
abs_i = abs(i)
|
|
73
|
+
abs_j = abs(j)
|
|
74
|
+
|
|
75
|
+
best: ArcSegment | None = None
|
|
76
|
+
best_error = math.inf
|
|
77
|
+
|
|
78
|
+
for sign_i, sign_j in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
|
|
79
|
+
cx = start_x + sign_i * abs_i
|
|
80
|
+
cy = start_y + sign_j * abs_j
|
|
81
|
+
|
|
82
|
+
r_start = math.hypot(start_x - cx, start_y - cy)
|
|
83
|
+
if r_start < 1e-10:
|
|
84
|
+
continue
|
|
85
|
+
r_end = math.hypot(end_x - cx, end_y - cy)
|
|
86
|
+
|
|
87
|
+
# Normalise angles to [0, 360)
|
|
88
|
+
sa = math.atan2(start_y - cy, start_x - cx) * _RAD_TO_DEG % 360.0
|
|
89
|
+
ea = math.atan2(end_y - cy, end_x - cx) * _RAD_TO_DEG % 360.0
|
|
90
|
+
|
|
91
|
+
# Compute the arc sweep in the intended direction
|
|
92
|
+
if clockwise:
|
|
93
|
+
sweep = sa - ea
|
|
94
|
+
if sweep <= 0:
|
|
95
|
+
sweep += 360.0
|
|
96
|
+
else:
|
|
97
|
+
sweep = ea - sa
|
|
98
|
+
if sweep <= 0:
|
|
99
|
+
sweep += 360.0
|
|
100
|
+
|
|
101
|
+
# G74 constraint: arc must stay within a single quadrant (<= 90deg)
|
|
102
|
+
if sweep > 90.5:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
error = abs(r_start - r_end)
|
|
106
|
+
if error < best_error:
|
|
107
|
+
best_error = error
|
|
108
|
+
# Full-circle degenerate case
|
|
109
|
+
if math.hypot(start_x - end_x, start_y - end_y) < 1e-10:
|
|
110
|
+
end_a = sa - 360.0 if clockwise else sa + 360.0
|
|
111
|
+
else:
|
|
112
|
+
end_a = ea
|
|
113
|
+
best = ArcSegment(
|
|
114
|
+
center_x=cx,
|
|
115
|
+
center_y=cy,
|
|
116
|
+
radius=r_start,
|
|
117
|
+
start_angle_deg=sa,
|
|
118
|
+
end_angle_deg=end_a,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return best
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def arc_bounding_box(arc: ArcSegment, aperture_radius: float = 0.0) -> BoundingBox:
|
|
125
|
+
"""Return the axis-aligned bounding box of an arc segment.
|
|
126
|
+
|
|
127
|
+
Correctly handles multi-quadrant arcs by checking whether any of the four
|
|
128
|
+
axis-extrema angles (0, 90, 180, 270 degrees) fall within the swept range.
|
|
129
|
+
|
|
130
|
+
*aperture_radius* is added as padding on all sides (half the trace width).
|
|
131
|
+
"""
|
|
132
|
+
bb = BoundingBox()
|
|
133
|
+
cx, cy, r = arc.center_x, arc.center_y, arc.radius
|
|
134
|
+
|
|
135
|
+
# Expand by both endpoints.
|
|
136
|
+
for theta_deg in (arc.start_angle_deg, arc.end_angle_deg):
|
|
137
|
+
theta_rad = math.radians(theta_deg)
|
|
138
|
+
bb.expand(cx + r * math.cos(theta_rad), cy + r * math.sin(theta_rad), aperture_radius)
|
|
139
|
+
|
|
140
|
+
# Determine the angular range covered by the arc. The ArcSegment convention
|
|
141
|
+
# (set by compute_arc_multi_quadrant / compute_arc_single_quadrant) is:
|
|
142
|
+
# CCW arcs: end_angle_deg > start_angle_deg
|
|
143
|
+
# CW arcs: end_angle_deg < start_angle_deg
|
|
144
|
+
# So [lo, hi] = [min, max] covers the swept interval regardless of direction.
|
|
145
|
+
lo = min(arc.start_angle_deg, arc.end_angle_deg)
|
|
146
|
+
hi = max(arc.start_angle_deg, arc.end_angle_deg)
|
|
147
|
+
|
|
148
|
+
# For each axis extremum (0deg, 90deg, 180deg, 270deg), check whether any integer
|
|
149
|
+
# multiple of 360deg shifted copy of that angle falls in [lo, hi]. If so,
|
|
150
|
+
# the arc passes through that extremum and we must expand the bbox.
|
|
151
|
+
for axis_angle in (0.0, 90.0, 180.0, 270.0):
|
|
152
|
+
k_start = math.ceil((lo - axis_angle) / 360.0)
|
|
153
|
+
for k in range(k_start, k_start + 4):
|
|
154
|
+
candidate = axis_angle + 360.0 * k
|
|
155
|
+
if lo <= candidate <= hi:
|
|
156
|
+
theta_rad = math.radians(axis_angle)
|
|
157
|
+
bb.expand(
|
|
158
|
+
cx + r * math.cos(theta_rad), cy + r * math.sin(theta_rad), aperture_radius
|
|
159
|
+
)
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
return bb
|