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,203 @@
|
|
|
1
|
+
"""Change attribution: match expanded ops between revisions and classify.
|
|
2
|
+
|
|
3
|
+
Three-stage algorithm:
|
|
4
|
+
|
|
5
|
+
1. **Exact cancellation** -- ops with identical content signatures (same
|
|
6
|
+
world geometry, polarity, kind) are unchanged. This typically covers
|
|
7
|
+
the vast majority of ops and costs only hashing.
|
|
8
|
+
2. **Gated geometric matching** -- remaining ops are pooled by (kind,
|
|
9
|
+
polarity) and matched A->B by centroid proximity within *gate_radius*
|
|
10
|
+
(KD-tree candidates, globally-greedy by distance for determinism).
|
|
11
|
+
3. **Classification** of each matched pair:
|
|
12
|
+
|
|
13
|
+
- same dims, offset > move_tol -> ``moved``
|
|
14
|
+
- same dims, offset <= move_tol -> unchanged (float noise)
|
|
15
|
+
- dims changed -> ``resized`` (dx/dy still recorded)
|
|
16
|
+
|
|
17
|
+
"Same dims" = identical aperture signature *and* relative area delta
|
|
18
|
+
within *area_tol* (the area check distinguishes a stretched stroke from
|
|
19
|
+
a translated one).
|
|
20
|
+
|
|
21
|
+
Unmatched A-ops are ``removed``; unmatched B-ops are ``added``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from scipy.spatial import cKDTree
|
|
32
|
+
|
|
33
|
+
from gerberdiff.geometry.layer_geometry import ExpandedOp
|
|
34
|
+
from gerberdiff.geometry.types import ChangeKind
|
|
35
|
+
|
|
36
|
+
# Classification kinds (public result vocabulary).
|
|
37
|
+
KIND_ADDED: ChangeKind = "added"
|
|
38
|
+
KIND_REMOVED: ChangeKind = "removed"
|
|
39
|
+
KIND_MOVED: ChangeKind = "moved"
|
|
40
|
+
KIND_RESIZED: ChangeKind = "resized"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class OpChange:
|
|
45
|
+
"""One attributed change between revisions (engine-internal record)."""
|
|
46
|
+
|
|
47
|
+
kind: ChangeKind
|
|
48
|
+
before: ExpandedOp | None
|
|
49
|
+
after: ExpandedOp | None
|
|
50
|
+
dx: float = 0.0 # inches (after - before), 0 when one side is absent
|
|
51
|
+
dy: float = 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Partition:
|
|
56
|
+
"""Result of exact-cancellation matching between two op lists."""
|
|
57
|
+
|
|
58
|
+
unchanged_a: list[ExpandedOp]
|
|
59
|
+
unchanged_b: list[ExpandedOp]
|
|
60
|
+
a_only: list[ExpandedOp]
|
|
61
|
+
b_only: list[ExpandedOp]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def partition_unchanged(
|
|
65
|
+
a_ops: list[ExpandedOp],
|
|
66
|
+
b_ops: list[ExpandedOp],
|
|
67
|
+
) -> Partition:
|
|
68
|
+
"""Multiset-match ops by content signature (stage 1)."""
|
|
69
|
+
counts_a = Counter(op.signature for op in a_ops)
|
|
70
|
+
counts_b = Counter(op.signature for op in b_ops)
|
|
71
|
+
shared = counts_a & counts_b
|
|
72
|
+
|
|
73
|
+
def _split(
|
|
74
|
+
ops: list[ExpandedOp], budget: Counter[str]
|
|
75
|
+
) -> tuple[list[ExpandedOp], list[ExpandedOp]]:
|
|
76
|
+
unchanged: list[ExpandedOp] = []
|
|
77
|
+
only: list[ExpandedOp] = []
|
|
78
|
+
for op in ops:
|
|
79
|
+
if budget[op.signature] > 0:
|
|
80
|
+
budget[op.signature] -= 1
|
|
81
|
+
unchanged.append(op)
|
|
82
|
+
else:
|
|
83
|
+
only.append(op)
|
|
84
|
+
return unchanged, only
|
|
85
|
+
|
|
86
|
+
unchanged_a, a_only = _split(a_ops, Counter(shared))
|
|
87
|
+
unchanged_b, b_only = _split(b_ops, Counter(shared))
|
|
88
|
+
return Partition(unchanged_a=unchanged_a, unchanged_b=unchanged_b, a_only=a_only, b_only=b_only)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def attribute_changes(
|
|
92
|
+
parts: Partition,
|
|
93
|
+
*,
|
|
94
|
+
move_tol: float,
|
|
95
|
+
gate_radius: float,
|
|
96
|
+
area_tol: float,
|
|
97
|
+
) -> tuple[list[OpChange], int]:
|
|
98
|
+
"""Stages 2+3: match and classify the non-identical ops.
|
|
99
|
+
|
|
100
|
+
All distances in inches. Returns ``(changes, unchanged_count)`` where
|
|
101
|
+
*unchanged_count* includes both exact-signature matches and matched
|
|
102
|
+
pairs whose offset fell below *move_tol*.
|
|
103
|
+
"""
|
|
104
|
+
unchanged_count = len(parts.unchanged_a)
|
|
105
|
+
changes: list[OpChange] = []
|
|
106
|
+
|
|
107
|
+
matched_a: set[int] = set()
|
|
108
|
+
matched_b: set[int] = set()
|
|
109
|
+
|
|
110
|
+
# Pool by (kind, polarity): a flash never matches a stroke, dark never
|
|
111
|
+
# matches clear.
|
|
112
|
+
pools: dict[tuple[str, str], tuple[list[int], list[int]]] = {}
|
|
113
|
+
for i, op in enumerate(parts.a_only):
|
|
114
|
+
pools.setdefault((op.kind, op.polarity.value), ([], []))[0].append(i)
|
|
115
|
+
for j, op in enumerate(parts.b_only):
|
|
116
|
+
pools.setdefault((op.kind, op.polarity.value), ([], []))[1].append(j)
|
|
117
|
+
|
|
118
|
+
for a_idx, b_idx in pools.values():
|
|
119
|
+
if not a_idx or not b_idx:
|
|
120
|
+
continue
|
|
121
|
+
pairs = _match_pool(parts, a_idx, b_idx, gate_radius, matched_a, matched_b)
|
|
122
|
+
for a_op, b_op in pairs:
|
|
123
|
+
dx = b_op.centroid_x - a_op.centroid_x
|
|
124
|
+
dy = b_op.centroid_y - a_op.centroid_y
|
|
125
|
+
offset = math.hypot(dx, dy)
|
|
126
|
+
if _dims_same(a_op, b_op, area_tol):
|
|
127
|
+
if offset <= move_tol:
|
|
128
|
+
unchanged_count += 1
|
|
129
|
+
else:
|
|
130
|
+
changes.append(OpChange(kind=KIND_MOVED, before=a_op, after=b_op, dx=dx, dy=dy))
|
|
131
|
+
elif offset <= move_tol and a_op.geom.equals(b_op.geom):
|
|
132
|
+
# Different aperture description, identical geometry (e.g. a
|
|
133
|
+
# square obround re-declared as a circle): not a change.
|
|
134
|
+
unchanged_count += 1
|
|
135
|
+
else:
|
|
136
|
+
changes.append(OpChange(kind=KIND_RESIZED, before=a_op, after=b_op, dx=dx, dy=dy))
|
|
137
|
+
|
|
138
|
+
for i, op in enumerate(parts.a_only):
|
|
139
|
+
if i not in matched_a:
|
|
140
|
+
changes.append(OpChange(kind=KIND_REMOVED, before=op, after=None))
|
|
141
|
+
for j, op in enumerate(parts.b_only):
|
|
142
|
+
if j not in matched_b:
|
|
143
|
+
changes.append(OpChange(kind=KIND_ADDED, before=None, after=op))
|
|
144
|
+
|
|
145
|
+
return changes, unchanged_count
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Internal helpers
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _match_pool(
|
|
154
|
+
parts: Partition,
|
|
155
|
+
a_indices: list[int],
|
|
156
|
+
b_indices: list[int],
|
|
157
|
+
gate_radius: float,
|
|
158
|
+
matched_a: set[int],
|
|
159
|
+
matched_b: set[int],
|
|
160
|
+
) -> list[tuple[ExpandedOp, ExpandedOp]]:
|
|
161
|
+
"""Greedy global matching by centroid distance within *gate_radius*."""
|
|
162
|
+
pairs_out: list[tuple[ExpandedOp, ExpandedOp]] = []
|
|
163
|
+
a_pts = [(parts.a_only[i].centroid_x, parts.a_only[i].centroid_y) for i in a_indices]
|
|
164
|
+
b_pts = [(parts.b_only[j].centroid_x, parts.b_only[j].centroid_y) for j in b_indices]
|
|
165
|
+
tree = cKDTree(b_pts)
|
|
166
|
+
|
|
167
|
+
k = min(8, len(b_pts))
|
|
168
|
+
candidates: list[tuple[float, int, int]] = []
|
|
169
|
+
for ai_local, pt in enumerate(a_pts):
|
|
170
|
+
d_raw, i_raw = tree.query(pt, k=k, distance_upper_bound=gate_radius)
|
|
171
|
+
# query on a single point returns scalars for k=1, 1-D arrays otherwise.
|
|
172
|
+
d_arr = np.atleast_1d(np.asarray(d_raw, dtype=float))
|
|
173
|
+
i_arr = np.atleast_1d(np.asarray(i_raw, dtype=int))
|
|
174
|
+
for d, j_local in zip(d_arr, i_arr, strict=True):
|
|
175
|
+
if math.isinf(float(d)) or int(j_local) >= len(b_pts):
|
|
176
|
+
continue
|
|
177
|
+
candidates.append((float(d), ai_local, int(j_local)))
|
|
178
|
+
|
|
179
|
+
# Sort by distance (deterministic index tie-break), accept greedily.
|
|
180
|
+
candidates.sort(key=lambda c: (c[0], c[1], c[2]))
|
|
181
|
+
used_a_local: set[int] = set()
|
|
182
|
+
used_b_local: set[int] = set()
|
|
183
|
+
for _d, ai_local, bj_local in candidates:
|
|
184
|
+
if ai_local in used_a_local or bj_local in used_b_local:
|
|
185
|
+
continue
|
|
186
|
+
used_a_local.add(ai_local)
|
|
187
|
+
used_b_local.add(bj_local)
|
|
188
|
+
ai = a_indices[ai_local]
|
|
189
|
+
bj = b_indices[bj_local]
|
|
190
|
+
matched_a.add(ai)
|
|
191
|
+
matched_b.add(bj)
|
|
192
|
+
pairs_out.append((parts.a_only[ai], parts.b_only[bj]))
|
|
193
|
+
return pairs_out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _dims_same(a: ExpandedOp, b: ExpandedOp, area_tol: float) -> bool:
|
|
197
|
+
"""Same dimensions modulo orientation (a rotated pad is still 'moved')."""
|
|
198
|
+
if a.dims_signature != b.dims_signature:
|
|
199
|
+
return False
|
|
200
|
+
biggest = max(a.area, b.area)
|
|
201
|
+
if biggest <= 0.0:
|
|
202
|
+
return True
|
|
203
|
+
return abs(a.area - b.area) / biggest <= area_tol
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Directory-level geometry diff driver.
|
|
2
|
+
|
|
3
|
+
``compute_geometry_diff`` is the geometry-engine counterpart of
|
|
4
|
+
``diff_engine.compute_full_diff``: it pairs layer files with
|
|
5
|
+
``match_layers``, parses both revisions, expands geometry, runs the boolean
|
|
6
|
+
diff and attribution per layer, and assembles a
|
|
7
|
+
:class:`~gerberdiff.geometry.types.GeometryDiffResult`.
|
|
8
|
+
|
|
9
|
+
The geometry pipeline is Cairo-free.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from gerberdiff.geometry.attribute import OpChange, attribute_changes, partition_unchanged
|
|
18
|
+
from gerberdiff.geometry.geom_diff import boolean_layer_diff
|
|
19
|
+
from gerberdiff.geometry.layer_geometry import (
|
|
20
|
+
LayerGeometry,
|
|
21
|
+
build_layer_geometry,
|
|
22
|
+
resolve_geometry,
|
|
23
|
+
)
|
|
24
|
+
from gerberdiff.geometry.types import (
|
|
25
|
+
MM2_PER_IN2,
|
|
26
|
+
GeometryChange,
|
|
27
|
+
GeometryDiffResult,
|
|
28
|
+
LayerGeometryDiff,
|
|
29
|
+
)
|
|
30
|
+
from gerberdiff.types import (
|
|
31
|
+
Diagnostic,
|
|
32
|
+
DiagnosticSeverity,
|
|
33
|
+
GerberParseError,
|
|
34
|
+
LayerStatus,
|
|
35
|
+
LayerType,
|
|
36
|
+
ParsedImage,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Default tolerances (user-facing units; see docs/geometry-diff.md).
|
|
40
|
+
DEFAULT_MOVE_TOL_MM = 0.005 # 5 um: below this a matched pair is "unchanged"
|
|
41
|
+
DEFAULT_GATE_RADIUS_MM = 0.2 # max centroid distance to pair two ops
|
|
42
|
+
DEFAULT_AREA_TOL = 0.01 # 1% relative area delta still counts as same dims
|
|
43
|
+
DEFAULT_DUST_AREA_MM2 = 1e-6 # boolean-diff components below this are noise
|
|
44
|
+
|
|
45
|
+
_MM_PER_IN = 25.4
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compute_geometry_diff(
|
|
49
|
+
before_dir: Path,
|
|
50
|
+
after_dir: Path,
|
|
51
|
+
*,
|
|
52
|
+
layers: Sequence[str] | None = None,
|
|
53
|
+
move_tol_mm: float = DEFAULT_MOVE_TOL_MM,
|
|
54
|
+
gate_radius_mm: float = DEFAULT_GATE_RADIUS_MM,
|
|
55
|
+
area_tol: float = DEFAULT_AREA_TOL,
|
|
56
|
+
dust_area_mm2: float = DEFAULT_DUST_AREA_MM2,
|
|
57
|
+
on_diagnostic: Callable[[Path, Diagnostic], None] | None = None,
|
|
58
|
+
) -> GeometryDiffResult:
|
|
59
|
+
"""Geometry-diff two directories of Gerber/Excellon layer files.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
before_dir, after_dir:
|
|
64
|
+
Directories containing the before and after layer files.
|
|
65
|
+
layers:
|
|
66
|
+
If given, only layers whose names appear in this sequence are diffed.
|
|
67
|
+
move_tol_mm:
|
|
68
|
+
Minimum centroid displacement (mm) for a matched pair to be reported
|
|
69
|
+
as ``moved`` rather than unchanged.
|
|
70
|
+
gate_radius_mm:
|
|
71
|
+
Maximum centroid distance (mm) at which two ops can be considered
|
|
72
|
+
the same object.
|
|
73
|
+
area_tol:
|
|
74
|
+
Relative area delta within which two matched ops still count as the
|
|
75
|
+
same dimensions (distinguishes ``moved`` from ``resized``).
|
|
76
|
+
dust_area_mm2:
|
|
77
|
+
Boolean-difference components smaller than this (mm^2) are dropped
|
|
78
|
+
as numeric noise.
|
|
79
|
+
on_diagnostic:
|
|
80
|
+
Called with ``(path, diagnostic)`` for every non-fatal parse or
|
|
81
|
+
expansion diagnostic.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
GerberParseError
|
|
86
|
+
When a file contains a fatal (``Error``-severity) parse diagnostic.
|
|
87
|
+
OSError
|
|
88
|
+
When a layer file cannot be read.
|
|
89
|
+
"""
|
|
90
|
+
from gerberdiff.diff.layer_matcher import EXCELLON_SUFFIXES, match_layers
|
|
91
|
+
from gerberdiff.parse.excellon_parser import parse_excellon
|
|
92
|
+
from gerberdiff.parse.gerber_state import parse_gerber
|
|
93
|
+
|
|
94
|
+
move_tol_in = move_tol_mm / _MM_PER_IN
|
|
95
|
+
gate_radius_in = gate_radius_mm / _MM_PER_IN
|
|
96
|
+
dust_area_in2 = dust_area_mm2 / MM2_PER_IN2
|
|
97
|
+
|
|
98
|
+
def _parse(path: Path) -> ParsedImage:
|
|
99
|
+
content = path.read_text(errors="replace")
|
|
100
|
+
if path.suffix.lower() in EXCELLON_SUFFIXES:
|
|
101
|
+
img = parse_excellon(content, source_path=path)
|
|
102
|
+
else:
|
|
103
|
+
img = parse_gerber(content, source_path=path)
|
|
104
|
+
for diag in img.diagnostics:
|
|
105
|
+
if diag.severity == DiagnosticSeverity.Error:
|
|
106
|
+
raise GerberParseError(path, diag.message, diag.line)
|
|
107
|
+
if on_diagnostic is not None:
|
|
108
|
+
on_diagnostic(path, diag)
|
|
109
|
+
return img
|
|
110
|
+
|
|
111
|
+
def _build(path: Path) -> LayerGeometry:
|
|
112
|
+
geometry = build_layer_geometry(_parse(path))
|
|
113
|
+
if on_diagnostic is not None:
|
|
114
|
+
for diag in geometry.diagnostics:
|
|
115
|
+
on_diagnostic(path, diag)
|
|
116
|
+
return geometry
|
|
117
|
+
|
|
118
|
+
pairs = match_layers(before_dir, after_dir)
|
|
119
|
+
if layers is not None:
|
|
120
|
+
pairs = [p for p in pairs if p.name in layers]
|
|
121
|
+
|
|
122
|
+
result = GeometryDiffResult()
|
|
123
|
+
for pair in pairs:
|
|
124
|
+
if pair.status in (LayerStatus.Added, LayerStatus.Removed):
|
|
125
|
+
src_path = pair.after_path if pair.status == LayerStatus.Added else pair.before_path
|
|
126
|
+
assert src_path is not None # invariant guaranteed by match_layers
|
|
127
|
+
geometry = _build(src_path)
|
|
128
|
+
total_mm2 = resolve_geometry(geometry.ops).area * MM2_PER_IN2
|
|
129
|
+
result.layers.append(
|
|
130
|
+
LayerGeometryDiff(
|
|
131
|
+
name=pair.name,
|
|
132
|
+
layer_type=pair.layer_type,
|
|
133
|
+
status=pair.status,
|
|
134
|
+
added_area_mm2=total_mm2 if pair.status == LayerStatus.Added else 0.0,
|
|
135
|
+
removed_area_mm2=total_mm2 if pair.status == LayerStatus.Removed else 0.0,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
assert pair.before_path is not None and pair.after_path is not None
|
|
141
|
+
geom_a = _build(pair.before_path)
|
|
142
|
+
geom_b = _build(pair.after_path)
|
|
143
|
+
result.layers.append(
|
|
144
|
+
_diff_layer_pair(
|
|
145
|
+
pair.name,
|
|
146
|
+
pair.layer_type,
|
|
147
|
+
geom_a,
|
|
148
|
+
geom_b,
|
|
149
|
+
move_tol_in=move_tol_in,
|
|
150
|
+
gate_radius_in=gate_radius_in,
|
|
151
|
+
area_tol=area_tol,
|
|
152
|
+
dust_area_in2=dust_area_in2,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Internal helpers
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _diff_layer_pair(
|
|
165
|
+
name: str,
|
|
166
|
+
layer_type: LayerType,
|
|
167
|
+
geom_a: LayerGeometry,
|
|
168
|
+
geom_b: LayerGeometry,
|
|
169
|
+
*,
|
|
170
|
+
move_tol_in: float,
|
|
171
|
+
gate_radius_in: float,
|
|
172
|
+
area_tol: float,
|
|
173
|
+
dust_area_in2: float,
|
|
174
|
+
) -> LayerGeometryDiff:
|
|
175
|
+
parts = partition_unchanged(geom_a.ops, geom_b.ops)
|
|
176
|
+
|
|
177
|
+
added_geom, removed_geom = boolean_layer_diff(
|
|
178
|
+
geom_a,
|
|
179
|
+
geom_b,
|
|
180
|
+
parts.a_only,
|
|
181
|
+
parts.b_only,
|
|
182
|
+
parts.unchanged_a,
|
|
183
|
+
dust_area=dust_area_in2,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
op_changes, unchanged_count = attribute_changes(
|
|
187
|
+
parts,
|
|
188
|
+
move_tol=move_tol_in,
|
|
189
|
+
gate_radius=gate_radius_in,
|
|
190
|
+
area_tol=area_tol,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
changes = [_to_public_change(c) for c in op_changes]
|
|
194
|
+
# Top-of-board first (descending Y), then left-to-right -- matches the
|
|
195
|
+
# raster engine's region ordering.
|
|
196
|
+
changes.sort(key=lambda c: (-c.centroid_y, c.centroid_x))
|
|
197
|
+
|
|
198
|
+
return LayerGeometryDiff(
|
|
199
|
+
name=name,
|
|
200
|
+
layer_type=layer_type,
|
|
201
|
+
status=LayerStatus.Matched,
|
|
202
|
+
changes=changes,
|
|
203
|
+
unchanged_count=unchanged_count,
|
|
204
|
+
added_area_mm2=added_geom.area * MM2_PER_IN2,
|
|
205
|
+
removed_area_mm2=removed_geom.area * MM2_PER_IN2,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _to_public_change(change: OpChange) -> GeometryChange:
|
|
210
|
+
"""Convert an engine-internal OpChange to the public GeometryChange."""
|
|
211
|
+
primary = change.after if change.after is not None else change.before
|
|
212
|
+
assert primary is not None # every OpChange has at least one side
|
|
213
|
+
moved_or_resized = change.before is not None and change.after is not None
|
|
214
|
+
return GeometryChange(
|
|
215
|
+
kind=change.kind,
|
|
216
|
+
op_kind=primary.kind,
|
|
217
|
+
centroid_x=primary.centroid_x,
|
|
218
|
+
centroid_y=primary.centroid_y,
|
|
219
|
+
area_mm2=primary.area * MM2_PER_IN2,
|
|
220
|
+
dx_mm=change.dx * _MM_PER_IN if moved_or_resized else None,
|
|
221
|
+
dy_mm=change.dy * _MM_PER_IN if moved_or_resized else None,
|
|
222
|
+
net_name=primary.net_name,
|
|
223
|
+
before_op=change.before.source if change.before is not None else None,
|
|
224
|
+
after_op=change.after.source if change.after is not None else None,
|
|
225
|
+
before_geom=change.before.geom if change.before is not None else None,
|
|
226
|
+
after_geom=change.after.geom if change.after is not None else None,
|
|
227
|
+
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Expand individual IR draw operations into shapely geometry.
|
|
2
|
+
|
|
3
|
+
Each function returns ``(geometry, diagnostics)``. Geometry is in the
|
|
4
|
+
operation's own coordinate space (inches); layer transforms and
|
|
5
|
+
step-and-repeat are applied later by ``layer_geometry``.
|
|
6
|
+
|
|
7
|
+
Fidelity notes
|
|
8
|
+
--------------
|
|
9
|
+
- Round-aperture strokes are exact (capsule = ``LineString.buffer``).
|
|
10
|
+
- Non-round convex apertures on **linear** strokes are exact: the Minkowski
|
|
11
|
+
sum of a segment with a convex shape is the convex hull of the shape placed
|
|
12
|
+
at both endpoints.
|
|
13
|
+
- Non-round apertures on **arc** strokes fall back to a round brush of
|
|
14
|
+
radius ``max(w, h) / 2`` with an Info diagnostic (matches the raster
|
|
15
|
+
engine's documented approximation for that case).
|
|
16
|
+
- Aperture holes are subtracted from the flash shape only. They do *not*
|
|
17
|
+
erase underlying image content (Gerber spec semantics; the raster engine's
|
|
18
|
+
``DEST_OUT`` punch is a known compositing shortcut).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from shapely.geometry import LineString, Polygon
|
|
24
|
+
from shapely.geometry.base import BaseGeometry
|
|
25
|
+
from shapely.ops import unary_union
|
|
26
|
+
from shapely.validation import make_valid
|
|
27
|
+
|
|
28
|
+
from gerberdiff.geometry.macro_geom import macro_flash_geometry
|
|
29
|
+
from gerberdiff.geometry.primitives import (
|
|
30
|
+
arc_points,
|
|
31
|
+
circle,
|
|
32
|
+
obround,
|
|
33
|
+
rectangle,
|
|
34
|
+
regular_polygon,
|
|
35
|
+
)
|
|
36
|
+
from gerberdiff.types import (
|
|
37
|
+
Aperture,
|
|
38
|
+
ApertureState,
|
|
39
|
+
BlockAperture,
|
|
40
|
+
CircleAperture,
|
|
41
|
+
Diagnostic,
|
|
42
|
+
DiagnosticSeverity,
|
|
43
|
+
DrawOp,
|
|
44
|
+
MacroAperture,
|
|
45
|
+
ObroundAperture,
|
|
46
|
+
PolygonAperture,
|
|
47
|
+
RectangleAperture,
|
|
48
|
+
RegionFill,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_EMPTY: BaseGeometry = Polygon()
|
|
52
|
+
_NO_DIAGS: list[Diagnostic] = []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Simple aperture outline (shared by flash and stroke expansion)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _aperture_outline(ap: Aperture, x: float, y: float) -> BaseGeometry | None:
|
|
61
|
+
"""The filled outline of a simple aperture centred at (x, y), no hole."""
|
|
62
|
+
match ap:
|
|
63
|
+
case CircleAperture():
|
|
64
|
+
if ap.diameter <= 0.0:
|
|
65
|
+
return None
|
|
66
|
+
return circle(x, y, ap.diameter / 2.0)
|
|
67
|
+
case RectangleAperture():
|
|
68
|
+
if ap.width <= 0.0 or ap.height <= 0.0:
|
|
69
|
+
return None
|
|
70
|
+
return rectangle(x, y, ap.width, ap.height)
|
|
71
|
+
case ObroundAperture():
|
|
72
|
+
if ap.width <= 0.0 or ap.height <= 0.0:
|
|
73
|
+
return None
|
|
74
|
+
return obround(x, y, ap.width, ap.height)
|
|
75
|
+
case PolygonAperture():
|
|
76
|
+
if ap.outer_diameter <= 0.0 or ap.num_vertices < 3:
|
|
77
|
+
return None
|
|
78
|
+
return regular_polygon(x, y, ap.outer_diameter / 2.0, ap.num_vertices, ap.rotation)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Flash (D03)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def flash_geometry(
|
|
88
|
+
op: DrawOp,
|
|
89
|
+
ap: Aperture | None,
|
|
90
|
+
) -> tuple[BaseGeometry, list[Diagnostic]]:
|
|
91
|
+
"""Expand a flash at ``(op.stop_x, op.stop_y)``.
|
|
92
|
+
|
|
93
|
+
Block apertures are handled by ``layer_geometry`` (they flatten into the
|
|
94
|
+
replay sequence); passing one here returns empty geometry.
|
|
95
|
+
"""
|
|
96
|
+
if ap is None or isinstance(ap, BlockAperture):
|
|
97
|
+
return _EMPTY, _NO_DIAGS
|
|
98
|
+
|
|
99
|
+
x, y = op.stop_x, op.stop_y
|
|
100
|
+
|
|
101
|
+
if isinstance(ap, MacroAperture):
|
|
102
|
+
return macro_flash_geometry(ap, x, y)
|
|
103
|
+
|
|
104
|
+
shape = _aperture_outline(ap, x, y)
|
|
105
|
+
if shape is None:
|
|
106
|
+
return _EMPTY, _NO_DIAGS
|
|
107
|
+
|
|
108
|
+
hole = ap.hole_diameter
|
|
109
|
+
if hole is not None and hole > 0.0:
|
|
110
|
+
shape = shape.difference(circle(x, y, hole / 2.0))
|
|
111
|
+
return shape, _NO_DIAGS
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Stroke (D01)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def stroke_geometry(
|
|
120
|
+
op: DrawOp,
|
|
121
|
+
ap: Aperture | None,
|
|
122
|
+
) -> tuple[BaseGeometry, list[Diagnostic]]:
|
|
123
|
+
"""Expand a D01 stroke (linear or arc) into its swept filled shape."""
|
|
124
|
+
if ap is None:
|
|
125
|
+
return _EMPTY, _NO_DIAGS
|
|
126
|
+
|
|
127
|
+
if op.arc_segment is not None:
|
|
128
|
+
return _arc_stroke(op, ap)
|
|
129
|
+
return _linear_stroke(op, ap)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _linear_stroke(op: DrawOp, ap: Aperture) -> tuple[BaseGeometry, list[Diagnostic]]:
|
|
133
|
+
start = (op.start_x, op.start_y)
|
|
134
|
+
stop = (op.stop_x, op.stop_y)
|
|
135
|
+
degenerate = start == stop
|
|
136
|
+
|
|
137
|
+
if isinstance(ap, CircleAperture):
|
|
138
|
+
if ap.diameter <= 0.0:
|
|
139
|
+
return _EMPTY, _NO_DIAGS
|
|
140
|
+
r = ap.diameter / 2.0
|
|
141
|
+
if degenerate:
|
|
142
|
+
return circle(*stop, r), _NO_DIAGS
|
|
143
|
+
return LineString([start, stop]).buffer(r), _NO_DIAGS
|
|
144
|
+
|
|
145
|
+
# Convex non-round aperture: exact Minkowski sum for a linear segment is
|
|
146
|
+
# the convex hull of the aperture placed at both endpoints.
|
|
147
|
+
shape_a = _aperture_outline(ap, *start)
|
|
148
|
+
if shape_a is None:
|
|
149
|
+
return _EMPTY, _NO_DIAGS
|
|
150
|
+
if degenerate:
|
|
151
|
+
return shape_a, _NO_DIAGS
|
|
152
|
+
shape_b = _aperture_outline(ap, *stop)
|
|
153
|
+
assert shape_b is not None # same aperture, same validity
|
|
154
|
+
return unary_union([shape_a, shape_b]).convex_hull, _NO_DIAGS
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _arc_stroke(op: DrawOp, ap: Aperture) -> tuple[BaseGeometry, list[Diagnostic]]:
|
|
158
|
+
arc = op.arc_segment
|
|
159
|
+
assert arc is not None
|
|
160
|
+
pts = arc_points(arc)
|
|
161
|
+
|
|
162
|
+
if isinstance(ap, CircleAperture):
|
|
163
|
+
if ap.diameter <= 0.0:
|
|
164
|
+
return _EMPTY, _NO_DIAGS
|
|
165
|
+
return LineString(pts).buffer(ap.diameter / 2.0), _NO_DIAGS
|
|
166
|
+
|
|
167
|
+
# Non-round aperture swept along an arc: approximate with a round brush.
|
|
168
|
+
width = _stroke_fallback_width(ap)
|
|
169
|
+
if width <= 0.0:
|
|
170
|
+
return _EMPTY, _NO_DIAGS
|
|
171
|
+
diag = Diagnostic(
|
|
172
|
+
severity=DiagnosticSeverity.Info,
|
|
173
|
+
message=(
|
|
174
|
+
f"arc stroke with {type(ap).__name__} approximated by a round "
|
|
175
|
+
f"brush of diameter {width:.6f} in"
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
return LineString(pts).buffer(width / 2.0), [diag]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _stroke_fallback_width(ap: Aperture) -> float:
|
|
182
|
+
"""Brush width for non-round arc strokes (mirrors the raster engine)."""
|
|
183
|
+
match ap:
|
|
184
|
+
case RectangleAperture() | ObroundAperture():
|
|
185
|
+
return max(ap.width, ap.height)
|
|
186
|
+
case PolygonAperture():
|
|
187
|
+
return ap.outer_diameter
|
|
188
|
+
case _:
|
|
189
|
+
return 0.0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Region fill (G36/G37)
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def region_geometry(region: RegionFill) -> tuple[BaseGeometry, list[Diagnostic]]:
|
|
198
|
+
"""Expand a region fill into polygon geometry.
|
|
199
|
+
|
|
200
|
+
Contours are split on ``Off`` (D02 move) segments; arcs are sampled at
|
|
201
|
+
chord tolerance. Multiple contours combine with even-odd semantics
|
|
202
|
+
(matching the renderer's ``FILL_RULE_EVEN_ODD``).
|
|
203
|
+
"""
|
|
204
|
+
contours: list[list[tuple[float, float]]] = []
|
|
205
|
+
current: list[tuple[float, float]] = []
|
|
206
|
+
|
|
207
|
+
for seg in region.segments:
|
|
208
|
+
if seg.aperture_state == ApertureState.Off:
|
|
209
|
+
if len(current) >= 3:
|
|
210
|
+
contours.append(current)
|
|
211
|
+
current = [(seg.stop_x, seg.stop_y)]
|
|
212
|
+
continue
|
|
213
|
+
if not current:
|
|
214
|
+
current = [(seg.start_x, seg.start_y)]
|
|
215
|
+
if seg.arc_segment is not None:
|
|
216
|
+
# Skip the first sample -- it coincides with the current endpoint.
|
|
217
|
+
current.extend(arc_points(seg.arc_segment)[1:])
|
|
218
|
+
else:
|
|
219
|
+
current.append((seg.stop_x, seg.stop_y))
|
|
220
|
+
|
|
221
|
+
if len(current) >= 3:
|
|
222
|
+
contours.append(current)
|
|
223
|
+
|
|
224
|
+
if not contours:
|
|
225
|
+
return _EMPTY, _NO_DIAGS
|
|
226
|
+
|
|
227
|
+
geom: BaseGeometry = _EMPTY
|
|
228
|
+
for contour in contours:
|
|
229
|
+
ring = make_valid(Polygon(contour))
|
|
230
|
+
# Even-odd combination: overlapping areas toggle.
|
|
231
|
+
geom = geom.symmetric_difference(ring)
|
|
232
|
+
return geom, _NO_DIAGS
|