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,780 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from gerberdiff.parse.arc_math import (
|
|
7
|
+
arc_bounding_box,
|
|
8
|
+
compute_arc_multi_quadrant,
|
|
9
|
+
compute_arc_single_quadrant,
|
|
10
|
+
)
|
|
11
|
+
from gerberdiff.parse.gerber_parser import (
|
|
12
|
+
FormatStatement,
|
|
13
|
+
convert_coordinate,
|
|
14
|
+
parse_aperture_definition,
|
|
15
|
+
parse_format_statement,
|
|
16
|
+
)
|
|
17
|
+
from gerberdiff.parse.macro_parser import MacroDef, parse_macro_body
|
|
18
|
+
from gerberdiff.parse.tokenizer import TokenType, tokenize_gerber
|
|
19
|
+
from gerberdiff.types import (
|
|
20
|
+
Aperture,
|
|
21
|
+
ApertureState,
|
|
22
|
+
BlockAperture,
|
|
23
|
+
BoundingBox,
|
|
24
|
+
CircleAperture,
|
|
25
|
+
CoordinateMode,
|
|
26
|
+
CoordState,
|
|
27
|
+
Diagnostic,
|
|
28
|
+
DiagnosticSeverity,
|
|
29
|
+
DrawOp,
|
|
30
|
+
InterpolationMode,
|
|
31
|
+
LayerState,
|
|
32
|
+
MirrorState,
|
|
33
|
+
ObroundAperture,
|
|
34
|
+
ParsedImage,
|
|
35
|
+
Polarity,
|
|
36
|
+
PolygonAperture,
|
|
37
|
+
RectangleAperture,
|
|
38
|
+
RegionFill,
|
|
39
|
+
StepAndRepeat,
|
|
40
|
+
UnitType,
|
|
41
|
+
ZeroOmission,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Default format statement used when no %FS...% is present in the file.
|
|
45
|
+
# FSLAX25Y25 is the most common real-world default.
|
|
46
|
+
_DEFAULT_FORMAT = FormatStatement(
|
|
47
|
+
zero_omission=ZeroOmission.Leading,
|
|
48
|
+
coordinate_mode=CoordinateMode.Absolute,
|
|
49
|
+
x_integer=2,
|
|
50
|
+
x_decimal=5,
|
|
51
|
+
y_integer=2,
|
|
52
|
+
y_decimal=5,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Two-character prefix strings that begin a top-level extended command.
|
|
56
|
+
# Any EXTENDED token whose prefix is NOT in this set, when we're inside a
|
|
57
|
+
# macro definition, is treated as a macro body line.
|
|
58
|
+
_COMMAND_PREFIXES: frozenset[str] = frozenset(
|
|
59
|
+
[
|
|
60
|
+
"FS",
|
|
61
|
+
"MO",
|
|
62
|
+
"AD",
|
|
63
|
+
"AM",
|
|
64
|
+
"LP",
|
|
65
|
+
"LM",
|
|
66
|
+
"LR",
|
|
67
|
+
"LS",
|
|
68
|
+
"LN",
|
|
69
|
+
"SR",
|
|
70
|
+
"AB",
|
|
71
|
+
"TO",
|
|
72
|
+
"TA",
|
|
73
|
+
"TD",
|
|
74
|
+
"TF",
|
|
75
|
+
"IA",
|
|
76
|
+
"AS",
|
|
77
|
+
"MI",
|
|
78
|
+
"OF",
|
|
79
|
+
"SF",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class _BlockFrame:
|
|
86
|
+
"""Saved state for a single level of block-aperture nesting."""
|
|
87
|
+
|
|
88
|
+
d_code: int
|
|
89
|
+
block_ap: BlockAperture
|
|
90
|
+
saved_nets: list[DrawOp | RegionFill]
|
|
91
|
+
saved_layers: list[LayerState]
|
|
92
|
+
saved_apertures: dict[int, Aperture]
|
|
93
|
+
saved_bbox: BoundingBox
|
|
94
|
+
saved_layer_idx: int
|
|
95
|
+
saved_net_state_idx: int
|
|
96
|
+
saved_current_aperture: int
|
|
97
|
+
saved_aperture_state: ApertureState
|
|
98
|
+
saved_interpolation: InterpolationMode
|
|
99
|
+
saved_multi_quadrant: bool
|
|
100
|
+
saved_unit: UnitType
|
|
101
|
+
saved_macro_map: dict[str, MacroDef]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Internal parser class
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _GerberParser:
|
|
110
|
+
"""Stateful RS-274X parser. Instantiate once per file; call parse()."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, source_path: Path | None) -> None:
|
|
113
|
+
# ---- accumulated output ----
|
|
114
|
+
self._fmt: FormatStatement = _DEFAULT_FORMAT
|
|
115
|
+
self._fmt_seen: bool = False
|
|
116
|
+
self._apertures: dict[int, Aperture] = {}
|
|
117
|
+
self._nets: list[DrawOp | RegionFill] = []
|
|
118
|
+
self._layers: list[LayerState] = [LayerState()]
|
|
119
|
+
self._net_states: list[CoordState] = [CoordState()]
|
|
120
|
+
self._bbox: BoundingBox = BoundingBox()
|
|
121
|
+
self._diagnostics: list[Diagnostic] = []
|
|
122
|
+
self._source_path = source_path
|
|
123
|
+
|
|
124
|
+
# ---- drawing cursor ----
|
|
125
|
+
self._prev_x: float = 0.0
|
|
126
|
+
self._prev_y: float = 0.0
|
|
127
|
+
|
|
128
|
+
# per-block raw coordinate storage (reset each END_OF_BLOCK)
|
|
129
|
+
self._raw_x_int: int = 0
|
|
130
|
+
self._raw_x_str: str = "0"
|
|
131
|
+
self._raw_y_int: int = 0
|
|
132
|
+
self._raw_y_str: str = "0"
|
|
133
|
+
self._raw_i_int: int = 0
|
|
134
|
+
self._raw_i_str: str = "0"
|
|
135
|
+
self._raw_j_int: int = 0
|
|
136
|
+
self._raw_j_str: str = "0"
|
|
137
|
+
self._x_in_block: bool = False
|
|
138
|
+
self._y_in_block: bool = False
|
|
139
|
+
self._i_in_block: bool = False
|
|
140
|
+
self._j_in_block: bool = False
|
|
141
|
+
self._coord_changed: bool = False
|
|
142
|
+
|
|
143
|
+
# ---- drawing state ----
|
|
144
|
+
self._current_aperture: int = 0
|
|
145
|
+
self._aperture_state: ApertureState = ApertureState.Off
|
|
146
|
+
self._interpolation: InterpolationMode = InterpolationMode.Linear
|
|
147
|
+
self._multi_quadrant: bool = False
|
|
148
|
+
self._in_region_fill: bool = False
|
|
149
|
+
self._region_start_layer_idx: int = 0
|
|
150
|
+
self._region_start_net_state_idx: int = 0
|
|
151
|
+
self._region_segments: list[DrawOp] = []
|
|
152
|
+
self._current_layer_idx: int = 0
|
|
153
|
+
self._current_net_state_idx: int = 0
|
|
154
|
+
self._unit: UnitType = UnitType.Inch
|
|
155
|
+
self._done: bool = False
|
|
156
|
+
|
|
157
|
+
# ---- macro assembly ----
|
|
158
|
+
self._macro_map: dict[str, MacroDef] = {}
|
|
159
|
+
self._macro_name: str | None = None
|
|
160
|
+
self._macro_lines: list[str] = []
|
|
161
|
+
|
|
162
|
+
# ---- object / aperture attributes ----
|
|
163
|
+
self._net_attrs: dict[str, str] = {}
|
|
164
|
+
self._aperture_attrs: dict[str, str] = {}
|
|
165
|
+
|
|
166
|
+
# ---- block aperture stack ----
|
|
167
|
+
# Each frame saves state so that when %AB*% closes the block the
|
|
168
|
+
# parent drawing context is fully restored.
|
|
169
|
+
self._block_stack: list[_BlockFrame] = []
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Small helpers
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _warn(self, msg: str, line: int | None = None) -> None:
|
|
176
|
+
self._diagnostics.append(Diagnostic(DiagnosticSeverity.Warning, msg, line))
|
|
177
|
+
|
|
178
|
+
def _error(self, msg: str, line: int | None = None) -> None:
|
|
179
|
+
self._diagnostics.append(Diagnostic(DiagnosticSeverity.Error, msg, line))
|
|
180
|
+
|
|
181
|
+
def _info(self, msg: str, line: int | None = None) -> None:
|
|
182
|
+
self._diagnostics.append(Diagnostic(DiagnosticSeverity.Info, msg, line))
|
|
183
|
+
|
|
184
|
+
def _current_layer(self) -> LayerState:
|
|
185
|
+
return self._layers[self._current_layer_idx]
|
|
186
|
+
|
|
187
|
+
def _convert_x(self, raw_int: int, raw_str: str) -> float:
|
|
188
|
+
return convert_coordinate(
|
|
189
|
+
raw_int,
|
|
190
|
+
raw_str,
|
|
191
|
+
self._fmt.x_integer,
|
|
192
|
+
self._fmt.x_decimal,
|
|
193
|
+
self._fmt.zero_omission,
|
|
194
|
+
self._unit,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _convert_y(self, raw_int: int, raw_str: str) -> float:
|
|
198
|
+
return convert_coordinate(
|
|
199
|
+
raw_int,
|
|
200
|
+
raw_str,
|
|
201
|
+
self._fmt.y_integer,
|
|
202
|
+
self._fmt.y_decimal,
|
|
203
|
+
self._fmt.zero_omission,
|
|
204
|
+
self._unit,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _aperture_radius(self) -> float:
|
|
208
|
+
ap = self._apertures.get(self._current_aperture)
|
|
209
|
+
if ap is None:
|
|
210
|
+
return 0.0
|
|
211
|
+
if isinstance(ap, CircleAperture):
|
|
212
|
+
return ap.diameter / 2.0
|
|
213
|
+
if isinstance(ap, (RectangleAperture, ObroundAperture)):
|
|
214
|
+
return max(ap.width, ap.height) / 2.0
|
|
215
|
+
if isinstance(ap, PolygonAperture):
|
|
216
|
+
return ap.outer_diameter / 2.0
|
|
217
|
+
# MacroAperture, BlockAperture: conservative -- renderer computes exact bbox
|
|
218
|
+
return 0.0
|
|
219
|
+
|
|
220
|
+
def _flush_macro(self) -> None:
|
|
221
|
+
if self._macro_name is None:
|
|
222
|
+
return
|
|
223
|
+
body = "*".join(self._macro_lines)
|
|
224
|
+
try:
|
|
225
|
+
mdef = parse_macro_body(self._macro_name, body)
|
|
226
|
+
self._macro_map[self._macro_name] = mdef
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
self._error(f"Macro parse failed for {self._macro_name!r}: {exc}")
|
|
229
|
+
self._macro_name = None
|
|
230
|
+
self._macro_lines = []
|
|
231
|
+
|
|
232
|
+
def _reset_block(self) -> None:
|
|
233
|
+
"""Reset per-block state after END_OF_BLOCK."""
|
|
234
|
+
self._x_in_block = False
|
|
235
|
+
self._y_in_block = False
|
|
236
|
+
self._i_in_block = False
|
|
237
|
+
self._j_in_block = False
|
|
238
|
+
self._coord_changed = False
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Net emission
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def _emit_net(self) -> None:
|
|
245
|
+
fmt = self._fmt
|
|
246
|
+
|
|
247
|
+
# Resolve stop position (use prev if coordinate not updated this block)
|
|
248
|
+
if self._x_in_block:
|
|
249
|
+
stop_x = self._convert_x(self._raw_x_int, self._raw_x_str)
|
|
250
|
+
if fmt.coordinate_mode == CoordinateMode.Incremental:
|
|
251
|
+
stop_x += self._prev_x
|
|
252
|
+
else:
|
|
253
|
+
stop_x = self._prev_x
|
|
254
|
+
|
|
255
|
+
if self._y_in_block:
|
|
256
|
+
stop_y = self._convert_y(self._raw_y_int, self._raw_y_str)
|
|
257
|
+
if fmt.coordinate_mode == CoordinateMode.Incremental:
|
|
258
|
+
stop_y += self._prev_y
|
|
259
|
+
else:
|
|
260
|
+
stop_y = self._prev_y
|
|
261
|
+
|
|
262
|
+
# Arc centre offsets (I uses X format, J uses Y format per RS-274X spec)
|
|
263
|
+
arc_i = self._convert_x(self._raw_i_int, self._raw_i_str) if self._i_in_block else 0.0
|
|
264
|
+
arc_j = self._convert_y(self._raw_j_int, self._raw_j_str) if self._j_in_block else 0.0
|
|
265
|
+
|
|
266
|
+
# Compute arc geometry when drawing in arc mode
|
|
267
|
+
arc_segment = None
|
|
268
|
+
if self._aperture_state == ApertureState.On and self._interpolation in (
|
|
269
|
+
InterpolationMode.ClockwiseCircular,
|
|
270
|
+
InterpolationMode.CounterClockwiseCircular,
|
|
271
|
+
):
|
|
272
|
+
clockwise = self._interpolation == InterpolationMode.ClockwiseCircular
|
|
273
|
+
if self._multi_quadrant:
|
|
274
|
+
arc_segment = compute_arc_multi_quadrant(
|
|
275
|
+
self._prev_x,
|
|
276
|
+
self._prev_y,
|
|
277
|
+
stop_x,
|
|
278
|
+
stop_y,
|
|
279
|
+
arc_i,
|
|
280
|
+
arc_j,
|
|
281
|
+
clockwise,
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
arc_segment = compute_arc_single_quadrant(
|
|
285
|
+
self._prev_x,
|
|
286
|
+
self._prev_y,
|
|
287
|
+
stop_x,
|
|
288
|
+
stop_y,
|
|
289
|
+
arc_i,
|
|
290
|
+
arc_j,
|
|
291
|
+
clockwise,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
net = DrawOp(
|
|
295
|
+
start_x=self._prev_x,
|
|
296
|
+
start_y=self._prev_y,
|
|
297
|
+
stop_x=stop_x,
|
|
298
|
+
stop_y=stop_y,
|
|
299
|
+
aperture_index=self._current_aperture,
|
|
300
|
+
aperture_state=self._aperture_state,
|
|
301
|
+
interpolation=self._interpolation,
|
|
302
|
+
layer_index=self._current_layer_idx,
|
|
303
|
+
net_state_index=self._current_net_state_idx,
|
|
304
|
+
arc_segment=arc_segment,
|
|
305
|
+
attributes=dict(self._net_attrs) if self._net_attrs else None,
|
|
306
|
+
)
|
|
307
|
+
if self._in_region_fill:
|
|
308
|
+
self._region_segments.append(net)
|
|
309
|
+
else:
|
|
310
|
+
self._nets.append(net)
|
|
311
|
+
|
|
312
|
+
# Expand bounding box
|
|
313
|
+
r = self._aperture_radius()
|
|
314
|
+
if arc_segment is not None:
|
|
315
|
+
ab = arc_bounding_box(arc_segment, r)
|
|
316
|
+
self._bbox.expand(ab.min_x, ab.min_y)
|
|
317
|
+
self._bbox.expand(ab.max_x, ab.max_y)
|
|
318
|
+
else:
|
|
319
|
+
self._bbox.expand(stop_x, stop_y, r)
|
|
320
|
+
if self._aperture_state == ApertureState.On:
|
|
321
|
+
self._bbox.expand(self._prev_x, self._prev_y, r)
|
|
322
|
+
|
|
323
|
+
# Also expand for all step-and-repeat instances of the current layer.
|
|
324
|
+
sr = self._current_layer().step_and_repeat
|
|
325
|
+
if sr.x > 1 or sr.y > 1:
|
|
326
|
+
for ix in range(sr.x):
|
|
327
|
+
for iy in range(sr.y):
|
|
328
|
+
if ix == 0 and iy == 0:
|
|
329
|
+
continue # already handled above
|
|
330
|
+
ox, oy = ix * sr.dist_x, iy * sr.dist_y
|
|
331
|
+
self._bbox.expand(stop_x + ox, stop_y + oy, r)
|
|
332
|
+
if self._aperture_state == ApertureState.On:
|
|
333
|
+
self._bbox.expand(self._prev_x + ox, self._prev_y + oy, r)
|
|
334
|
+
|
|
335
|
+
self._prev_x = stop_x
|
|
336
|
+
self._prev_y = stop_y
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
# Token handlers
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _handle_g_code(self, value: int, line: int) -> None:
|
|
343
|
+
if value == 1:
|
|
344
|
+
self._interpolation = InterpolationMode.Linear
|
|
345
|
+
elif value == 2:
|
|
346
|
+
self._interpolation = InterpolationMode.ClockwiseCircular
|
|
347
|
+
elif value == 3:
|
|
348
|
+
self._interpolation = InterpolationMode.CounterClockwiseCircular
|
|
349
|
+
elif value == 36:
|
|
350
|
+
self._in_region_fill = True
|
|
351
|
+
self._region_start_layer_idx = self._current_layer_idx
|
|
352
|
+
self._region_start_net_state_idx = self._current_net_state_idx
|
|
353
|
+
self._region_segments = []
|
|
354
|
+
elif value == 37:
|
|
355
|
+
self._in_region_fill = False
|
|
356
|
+
self._nets.append(
|
|
357
|
+
RegionFill(
|
|
358
|
+
layer_index=self._region_start_layer_idx,
|
|
359
|
+
net_state_index=self._region_start_net_state_idx,
|
|
360
|
+
segments=self._region_segments,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
self._region_segments = []
|
|
364
|
+
elif value in (54, 55, 70, 71):
|
|
365
|
+
# 54/55: deprecated aperture select/flash -- ignore
|
|
366
|
+
# 70/71: deprecated inch/mm (should use MO instead) -- update unit
|
|
367
|
+
if value == 70:
|
|
368
|
+
self._unit = UnitType.Inch
|
|
369
|
+
elif value == 71:
|
|
370
|
+
self._unit = UnitType.Millimeter
|
|
371
|
+
elif value == 74:
|
|
372
|
+
self._multi_quadrant = False
|
|
373
|
+
elif value == 75:
|
|
374
|
+
self._multi_quadrant = True
|
|
375
|
+
elif value == 90:
|
|
376
|
+
self._fmt = FormatStatement(
|
|
377
|
+
zero_omission=self._fmt.zero_omission,
|
|
378
|
+
coordinate_mode=CoordinateMode.Absolute,
|
|
379
|
+
x_integer=self._fmt.x_integer,
|
|
380
|
+
x_decimal=self._fmt.x_decimal,
|
|
381
|
+
y_integer=self._fmt.y_integer,
|
|
382
|
+
y_decimal=self._fmt.y_decimal,
|
|
383
|
+
)
|
|
384
|
+
elif value == 91:
|
|
385
|
+
self._fmt = FormatStatement(
|
|
386
|
+
zero_omission=self._fmt.zero_omission,
|
|
387
|
+
coordinate_mode=CoordinateMode.Incremental,
|
|
388
|
+
x_integer=self._fmt.x_integer,
|
|
389
|
+
x_decimal=self._fmt.x_decimal,
|
|
390
|
+
y_integer=self._fmt.y_integer,
|
|
391
|
+
y_decimal=self._fmt.y_decimal,
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
self._warn(f"Unknown G code G{value:02d}", line)
|
|
395
|
+
|
|
396
|
+
def _handle_d_code(self, value: int, line: int) -> None:
|
|
397
|
+
if value == 1:
|
|
398
|
+
self._aperture_state = ApertureState.On
|
|
399
|
+
self._coord_changed = True
|
|
400
|
+
elif value == 2:
|
|
401
|
+
self._aperture_state = ApertureState.Off
|
|
402
|
+
self._coord_changed = True
|
|
403
|
+
elif value == 3:
|
|
404
|
+
self._aperture_state = ApertureState.Flash
|
|
405
|
+
self._coord_changed = True
|
|
406
|
+
elif value >= 10:
|
|
407
|
+
self._current_aperture = value
|
|
408
|
+
else:
|
|
409
|
+
self._warn(f"Unknown D code D{value:02d}", line)
|
|
410
|
+
|
|
411
|
+
def _handle_extended(self, body: str, line: int) -> None:
|
|
412
|
+
prefix = body[:2].upper()
|
|
413
|
+
|
|
414
|
+
# When accumulating a macro body, all unrecognised token bodies are
|
|
415
|
+
# macro primitive / assignment lines. Any top-level command ends it.
|
|
416
|
+
if self._macro_name is not None:
|
|
417
|
+
if prefix not in _COMMAND_PREFIXES:
|
|
418
|
+
self._macro_lines.append(body)
|
|
419
|
+
return
|
|
420
|
+
# Recognised command -- flush macro first, then dispatch normally
|
|
421
|
+
self._flush_macro()
|
|
422
|
+
|
|
423
|
+
if prefix == "FS":
|
|
424
|
+
fs = parse_format_statement(body)
|
|
425
|
+
if fs is None:
|
|
426
|
+
self._warn(f"Could not parse format statement: {body!r}", line)
|
|
427
|
+
else:
|
|
428
|
+
self._fmt = fs
|
|
429
|
+
self._fmt_seen = True
|
|
430
|
+
|
|
431
|
+
elif prefix == "MO":
|
|
432
|
+
code = body[2:4].upper()
|
|
433
|
+
if code == "IN":
|
|
434
|
+
self._unit = UnitType.Inch
|
|
435
|
+
elif code == "MM":
|
|
436
|
+
self._unit = UnitType.Millimeter
|
|
437
|
+
# Push a NetState capturing the unit change
|
|
438
|
+
self._net_states.append(CoordState(unit=self._unit))
|
|
439
|
+
self._current_net_state_idx = len(self._net_states) - 1
|
|
440
|
+
|
|
441
|
+
elif prefix == "AD":
|
|
442
|
+
result = parse_aperture_definition(body, self._unit, self._macro_map)
|
|
443
|
+
if result is None:
|
|
444
|
+
self._warn(f"Could not parse aperture definition: {body!r}", line)
|
|
445
|
+
elif isinstance(result, str):
|
|
446
|
+
# result == "MACRO_NOT_FOUND:<name>" -- the aperture is permanently absent
|
|
447
|
+
macro_name = result.split(":", 1)[1] if ":" in result else result
|
|
448
|
+
self._error(f"Aperture definition references undefined macro {macro_name!r}", line)
|
|
449
|
+
else:
|
|
450
|
+
d_code, aperture = result
|
|
451
|
+
self._apertures[d_code] = aperture
|
|
452
|
+
self._aperture_attrs = {} # aperture attributes consumed
|
|
453
|
+
|
|
454
|
+
elif prefix == "AM":
|
|
455
|
+
# Start a new macro definition
|
|
456
|
+
name = body[2:].strip()
|
|
457
|
+
if name:
|
|
458
|
+
self._macro_name = name
|
|
459
|
+
self._macro_lines = []
|
|
460
|
+
|
|
461
|
+
elif prefix == "LP":
|
|
462
|
+
code = body[2:3].upper()
|
|
463
|
+
polarity = Polarity.Clear if code == "C" else Polarity.Dark
|
|
464
|
+
prev = self._current_layer()
|
|
465
|
+
new_layer = LayerState(
|
|
466
|
+
polarity=polarity,
|
|
467
|
+
rotation=prev.rotation,
|
|
468
|
+
mirror=prev.mirror,
|
|
469
|
+
scale=prev.scale,
|
|
470
|
+
name=prev.name,
|
|
471
|
+
)
|
|
472
|
+
self._layers.append(new_layer)
|
|
473
|
+
self._current_layer_idx = len(self._layers) - 1
|
|
474
|
+
|
|
475
|
+
elif prefix == "LM":
|
|
476
|
+
code = body[2:].strip().upper()
|
|
477
|
+
mirror = {
|
|
478
|
+
"N": MirrorState.None_,
|
|
479
|
+
"X": MirrorState.FlipA,
|
|
480
|
+
"Y": MirrorState.FlipB,
|
|
481
|
+
"XY": MirrorState.FlipAB,
|
|
482
|
+
}.get(code, MirrorState.None_)
|
|
483
|
+
self._current_layer().mirror = mirror
|
|
484
|
+
|
|
485
|
+
elif prefix == "LR":
|
|
486
|
+
try:
|
|
487
|
+
self._current_layer().rotation = float(body[2:])
|
|
488
|
+
except ValueError:
|
|
489
|
+
self._warn(f"Invalid LR value: {body!r}", line)
|
|
490
|
+
|
|
491
|
+
elif prefix == "LS":
|
|
492
|
+
try:
|
|
493
|
+
self._current_layer().scale = float(body[2:])
|
|
494
|
+
except ValueError:
|
|
495
|
+
self._warn(f"Invalid LS value: {body!r}", line)
|
|
496
|
+
|
|
497
|
+
elif prefix == "LN":
|
|
498
|
+
self._current_layer().name = body[2:]
|
|
499
|
+
|
|
500
|
+
elif prefix == "SR":
|
|
501
|
+
self._handle_sr(body[2:], line)
|
|
502
|
+
|
|
503
|
+
elif prefix == "AB":
|
|
504
|
+
self._handle_ab(body[2:], line)
|
|
505
|
+
|
|
506
|
+
elif prefix == "TO":
|
|
507
|
+
# Object attribute: %TO.<name>,<value>*%
|
|
508
|
+
rest = body[2:]
|
|
509
|
+
if rest.startswith("."):
|
|
510
|
+
comma = rest.find(",")
|
|
511
|
+
if comma > 0:
|
|
512
|
+
self._net_attrs[rest[1:comma]] = rest[comma + 1 :]
|
|
513
|
+
|
|
514
|
+
elif prefix == "TA":
|
|
515
|
+
# Aperture attribute
|
|
516
|
+
rest = body[2:]
|
|
517
|
+
if rest.startswith("."):
|
|
518
|
+
comma = rest.find(",")
|
|
519
|
+
if comma > 0:
|
|
520
|
+
self._aperture_attrs[rest[1:comma]] = rest[comma + 1 :]
|
|
521
|
+
|
|
522
|
+
elif prefix == "TD":
|
|
523
|
+
# Delete attribute(s)
|
|
524
|
+
name = body[2:].strip()
|
|
525
|
+
if name:
|
|
526
|
+
self._net_attrs.pop(name.lstrip("."), None)
|
|
527
|
+
self._aperture_attrs.pop(name.lstrip("."), None)
|
|
528
|
+
else:
|
|
529
|
+
self._net_attrs.clear()
|
|
530
|
+
self._aperture_attrs.clear()
|
|
531
|
+
|
|
532
|
+
elif prefix == "TF":
|
|
533
|
+
pass # File attribute -- informational, ignored
|
|
534
|
+
|
|
535
|
+
elif prefix in ("IA", "AS", "MI", "OF", "SF"):
|
|
536
|
+
self._info(f"Deprecated RS-274X command {prefix!r} ignored; transforms not applied")
|
|
537
|
+
|
|
538
|
+
else:
|
|
539
|
+
self._warn(f"Unknown extended command prefix {prefix!r}", line)
|
|
540
|
+
|
|
541
|
+
def _handle_ab(self, params: str, line: int) -> None:
|
|
542
|
+
"""Open or close a block aperture definition.
|
|
543
|
+
|
|
544
|
+
``%ABD<n>*%`` opens a block for D-code *n*; ``%AB*%`` closes it.
|
|
545
|
+
Nesting is supported up to depth 10 (matches the reference tool).
|
|
546
|
+
"""
|
|
547
|
+
body = params.strip()
|
|
548
|
+
if body:
|
|
549
|
+
# Open: %ABD<n>*%
|
|
550
|
+
if not body.upper().startswith("D"):
|
|
551
|
+
self._warn(f"Invalid aperture block spec: {body!r}", line)
|
|
552
|
+
return
|
|
553
|
+
try:
|
|
554
|
+
d_code = int(body[1:])
|
|
555
|
+
except ValueError:
|
|
556
|
+
self._warn(f"Invalid aperture block D-code: {body!r}", line)
|
|
557
|
+
return
|
|
558
|
+
if d_code < 10:
|
|
559
|
+
self._warn(f"Invalid aperture block D-code: D{d_code} (must be >=10)", line)
|
|
560
|
+
return
|
|
561
|
+
if len(self._block_stack) >= 10:
|
|
562
|
+
self._warn("Aperture block nesting too deep (max 10)", line)
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
block_ap = BlockAperture()
|
|
566
|
+
|
|
567
|
+
# Save parent state and redirect emission into the block.
|
|
568
|
+
self._block_stack.append(
|
|
569
|
+
_BlockFrame(
|
|
570
|
+
d_code=d_code,
|
|
571
|
+
block_ap=block_ap,
|
|
572
|
+
saved_nets=self._nets,
|
|
573
|
+
saved_layers=self._layers,
|
|
574
|
+
saved_apertures=self._apertures,
|
|
575
|
+
saved_bbox=self._bbox,
|
|
576
|
+
saved_layer_idx=self._current_layer_idx,
|
|
577
|
+
saved_net_state_idx=self._current_net_state_idx,
|
|
578
|
+
saved_current_aperture=self._current_aperture,
|
|
579
|
+
saved_aperture_state=self._aperture_state,
|
|
580
|
+
saved_interpolation=self._interpolation,
|
|
581
|
+
saved_multi_quadrant=self._multi_quadrant,
|
|
582
|
+
saved_unit=self._unit,
|
|
583
|
+
saved_macro_map=self._macro_map,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Block gets a fresh single-layer state and an empty bbox.
|
|
588
|
+
block_ap.layers.append(LayerState())
|
|
589
|
+
self._nets = block_ap.draw_ops
|
|
590
|
+
self._layers = block_ap.layers
|
|
591
|
+
self._current_layer_idx = 0
|
|
592
|
+
# Copy parent apertures so the block can reference them.
|
|
593
|
+
self._apertures = dict(self._apertures)
|
|
594
|
+
self._bbox = BoundingBox()
|
|
595
|
+
# Reset drawing cursor state to safe defaults inside the block.
|
|
596
|
+
self._current_aperture = 0
|
|
597
|
+
self._aperture_state = ApertureState.Off
|
|
598
|
+
self._interpolation = InterpolationMode.Linear
|
|
599
|
+
self._multi_quadrant = False
|
|
600
|
+
# Block gets a copy of the macro map; new macros defined inside
|
|
601
|
+
# the block do not leak back to the parent.
|
|
602
|
+
self._macro_map = dict(self._macro_map)
|
|
603
|
+
|
|
604
|
+
else:
|
|
605
|
+
# Close: %AB*%
|
|
606
|
+
if not self._block_stack:
|
|
607
|
+
self._warn("Unexpected AB close without matching open", line)
|
|
608
|
+
return
|
|
609
|
+
frame = self._block_stack.pop()
|
|
610
|
+
|
|
611
|
+
# Capture the block's accumulated state.
|
|
612
|
+
frame.block_ap.apertures = self._apertures
|
|
613
|
+
frame.block_ap.bounding_box = self._bbox
|
|
614
|
+
|
|
615
|
+
# Restore parent state.
|
|
616
|
+
self._nets = frame.saved_nets
|
|
617
|
+
self._layers = frame.saved_layers
|
|
618
|
+
self._apertures = frame.saved_apertures
|
|
619
|
+
self._bbox = frame.saved_bbox
|
|
620
|
+
self._current_layer_idx = frame.saved_layer_idx
|
|
621
|
+
self._current_net_state_idx = frame.saved_net_state_idx
|
|
622
|
+
self._current_aperture = frame.saved_current_aperture
|
|
623
|
+
self._aperture_state = frame.saved_aperture_state
|
|
624
|
+
self._interpolation = frame.saved_interpolation
|
|
625
|
+
self._multi_quadrant = frame.saved_multi_quadrant
|
|
626
|
+
self._unit = frame.saved_unit
|
|
627
|
+
self._macro_map = frame.saved_macro_map
|
|
628
|
+
|
|
629
|
+
# Register the completed block aperture in the parent aperture dict.
|
|
630
|
+
self._apertures[frame.d_code] = frame.block_ap
|
|
631
|
+
|
|
632
|
+
def _handle_sr(self, params: str, line: int) -> None:
|
|
633
|
+
"""Handle the SR body after stripping the 'SR' prefix."""
|
|
634
|
+
if not params.strip():
|
|
635
|
+
# Close SR block -- push a new layer that copies the parent's
|
|
636
|
+
# polarity/rotation/mirror/scale/name but resets step_and_repeat.
|
|
637
|
+
prev = self._current_layer()
|
|
638
|
+
new_layer = LayerState(
|
|
639
|
+
polarity=prev.polarity,
|
|
640
|
+
rotation=prev.rotation,
|
|
641
|
+
mirror=prev.mirror,
|
|
642
|
+
scale=prev.scale,
|
|
643
|
+
name=prev.name,
|
|
644
|
+
# step_and_repeat intentionally left at default (1, 1, 0, 0)
|
|
645
|
+
)
|
|
646
|
+
self._layers.append(new_layer)
|
|
647
|
+
self._current_layer_idx = len(self._layers) - 1
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
# Parse SRX<count>Y<count>I<step>J<step>
|
|
651
|
+
x_count = y_count = 1
|
|
652
|
+
step_x = step_y = 0.0
|
|
653
|
+
s = params.strip()
|
|
654
|
+
pos = 0
|
|
655
|
+
while pos < len(s):
|
|
656
|
+
letter = s[pos].upper()
|
|
657
|
+
if letter not in "XYIJ":
|
|
658
|
+
pos += 1
|
|
659
|
+
continue
|
|
660
|
+
pos += 1
|
|
661
|
+
j = pos
|
|
662
|
+
while j < len(s) and s[j] not in "XYIJxyij":
|
|
663
|
+
j += 1
|
|
664
|
+
try:
|
|
665
|
+
val = float(s[pos:j])
|
|
666
|
+
if letter == "X":
|
|
667
|
+
x_count = max(1, int(val))
|
|
668
|
+
elif letter == "Y":
|
|
669
|
+
y_count = max(1, int(val))
|
|
670
|
+
elif letter == "I":
|
|
671
|
+
step_x = val / 25.4 if self._unit == UnitType.Millimeter else val
|
|
672
|
+
elif letter == "J":
|
|
673
|
+
step_y = val / 25.4 if self._unit == UnitType.Millimeter else val
|
|
674
|
+
except ValueError:
|
|
675
|
+
self._warn(f"Invalid SR parameter {letter}={s[pos:j]!r}", line)
|
|
676
|
+
pos = j
|
|
677
|
+
|
|
678
|
+
self._current_layer().step_and_repeat = StepAndRepeat(
|
|
679
|
+
x=x_count,
|
|
680
|
+
y=y_count,
|
|
681
|
+
dist_x=step_x,
|
|
682
|
+
dist_y=step_y,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# ------------------------------------------------------------------
|
|
686
|
+
# Main parse loop
|
|
687
|
+
# ------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
def parse(self, content: str) -> ParsedImage:
|
|
690
|
+
for token in tokenize_gerber(content):
|
|
691
|
+
if self._done:
|
|
692
|
+
break
|
|
693
|
+
tt = token.type
|
|
694
|
+
line = token.line
|
|
695
|
+
|
|
696
|
+
if tt == TokenType.G:
|
|
697
|
+
if isinstance(token.value, int):
|
|
698
|
+
self._handle_g_code(token.value, line)
|
|
699
|
+
|
|
700
|
+
elif tt == TokenType.D:
|
|
701
|
+
if isinstance(token.value, int):
|
|
702
|
+
self._handle_d_code(token.value, line)
|
|
703
|
+
|
|
704
|
+
elif tt == TokenType.M:
|
|
705
|
+
if isinstance(token.value, int) and token.value == 2:
|
|
706
|
+
if self._in_region_fill:
|
|
707
|
+
self._warn("Region fill not closed at end of file", line)
|
|
708
|
+
self._done = True
|
|
709
|
+
break
|
|
710
|
+
|
|
711
|
+
elif tt == TokenType.X:
|
|
712
|
+
if isinstance(token.value, int):
|
|
713
|
+
self._raw_x_int = token.value
|
|
714
|
+
self._raw_x_str = token.raw or str(token.value)
|
|
715
|
+
self._x_in_block = True
|
|
716
|
+
self._coord_changed = True
|
|
717
|
+
|
|
718
|
+
elif tt == TokenType.Y:
|
|
719
|
+
if isinstance(token.value, int):
|
|
720
|
+
self._raw_y_int = token.value
|
|
721
|
+
self._raw_y_str = token.raw or str(token.value)
|
|
722
|
+
self._y_in_block = True
|
|
723
|
+
self._coord_changed = True
|
|
724
|
+
|
|
725
|
+
elif tt == TokenType.I:
|
|
726
|
+
if isinstance(token.value, int):
|
|
727
|
+
self._raw_i_int = token.value
|
|
728
|
+
self._raw_i_str = token.raw or str(token.value)
|
|
729
|
+
self._i_in_block = True
|
|
730
|
+
|
|
731
|
+
elif tt == TokenType.J:
|
|
732
|
+
if isinstance(token.value, int):
|
|
733
|
+
self._raw_j_int = token.value
|
|
734
|
+
self._raw_j_str = token.raw or str(token.value)
|
|
735
|
+
self._j_in_block = True
|
|
736
|
+
|
|
737
|
+
elif tt == TokenType.END_OF_BLOCK:
|
|
738
|
+
if self._coord_changed:
|
|
739
|
+
self._emit_net()
|
|
740
|
+
self._reset_block()
|
|
741
|
+
|
|
742
|
+
elif tt == TokenType.EXTENDED:
|
|
743
|
+
if isinstance(token.value, str):
|
|
744
|
+
self._handle_extended(token.value, line)
|
|
745
|
+
|
|
746
|
+
elif tt == TokenType.EOF:
|
|
747
|
+
if self._in_region_fill:
|
|
748
|
+
self._warn("Region fill not closed at end of file", line)
|
|
749
|
+
break
|
|
750
|
+
|
|
751
|
+
# Final cleanup
|
|
752
|
+
self._flush_macro()
|
|
753
|
+
|
|
754
|
+
if not self._fmt_seen:
|
|
755
|
+
self._info("No format statement found; using default FSLAX25Y25")
|
|
756
|
+
|
|
757
|
+
return ParsedImage(
|
|
758
|
+
draw_ops=self._nets,
|
|
759
|
+
apertures=self._apertures,
|
|
760
|
+
layers=self._layers,
|
|
761
|
+
coord_states=self._net_states,
|
|
762
|
+
bounding_box=self._bbox,
|
|
763
|
+
diagnostics=self._diagnostics,
|
|
764
|
+
source_path=self._source_path,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
# Public API
|
|
770
|
+
# ---------------------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def parse_gerber(content: str, source_path: Path | None = None) -> ParsedImage:
|
|
774
|
+
"""Parse a Gerber RS-274X file string into a ParsedImage.
|
|
775
|
+
|
|
776
|
+
All diagnostics (errors, warnings, info) are collected on
|
|
777
|
+
``image.diagnostics``. This function never raises for parse-level
|
|
778
|
+
problems; only genuine Python-level exceptions (e.g. MemoryError) propagate.
|
|
779
|
+
"""
|
|
780
|
+
return _GerberParser(source_path).parse(content)
|