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,338 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from gerberdiff.types import (
|
|
8
|
+
ApertureState,
|
|
9
|
+
BoundingBox,
|
|
10
|
+
CircleAperture,
|
|
11
|
+
CoordState,
|
|
12
|
+
Diagnostic,
|
|
13
|
+
DiagnosticSeverity,
|
|
14
|
+
DrawOp,
|
|
15
|
+
InterpolationMode,
|
|
16
|
+
LayerState,
|
|
17
|
+
ParsedImage,
|
|
18
|
+
RegionFill,
|
|
19
|
+
UnitType,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_TOOL_DEF_RE = re.compile(r"T(\d+)C([\d.]+)", re.IGNORECASE)
|
|
23
|
+
_TOOL_SEL_RE = re.compile(r"^T(\d+)$", re.IGNORECASE)
|
|
24
|
+
# Matches explicit digit-count specifiers, e.g. "000.000" or "0000.0000"
|
|
25
|
+
_FORMAT_DIGITS_RE = re.compile(r"(\d+)\.(\d+)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _FormatSpec:
|
|
30
|
+
"""Excellon coordinate-format specification."""
|
|
31
|
+
|
|
32
|
+
unit: UnitType
|
|
33
|
+
zero_suppression: str # "LZ" (leading zeros suppressed) or "TZ" (trailing zeros suppressed)
|
|
34
|
+
integer_digits: int # digits before the implied decimal point
|
|
35
|
+
decimal_digits: int # digits after the implied decimal point
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_unit_line(upper: str) -> _FormatSpec:
|
|
39
|
+
"""Parse a METRIC or INCH header line into a _FormatSpec.
|
|
40
|
+
|
|
41
|
+
Handles the unit keyword (``METRIC`` / ``INCH``), zero-suppression keyword
|
|
42
|
+
(``,LZ`` / ``,TZ``), and optional explicit digit counts expressed as a
|
|
43
|
+
dot-delimited pattern such as ``,000.000`` (3 integer + 3 decimal digits).
|
|
44
|
+
"""
|
|
45
|
+
if upper.startswith("METRIC"):
|
|
46
|
+
unit = UnitType.Millimeter
|
|
47
|
+
int_d, dec_d = 3, 3
|
|
48
|
+
else: # INCH
|
|
49
|
+
unit = UnitType.Inch
|
|
50
|
+
int_d, dec_d = 2, 4
|
|
51
|
+
|
|
52
|
+
zs = "LZ" if ",LZ" in upper else "TZ"
|
|
53
|
+
|
|
54
|
+
m = _FORMAT_DIGITS_RE.search(upper)
|
|
55
|
+
if m:
|
|
56
|
+
int_d = len(m.group(1))
|
|
57
|
+
dec_d = len(m.group(2))
|
|
58
|
+
|
|
59
|
+
return _FormatSpec(unit=unit, zero_suppression=zs, integer_digits=int_d, decimal_digits=dec_d)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _apply_format(raw: str, spec: _FormatSpec) -> tuple[float, bool]:
|
|
63
|
+
"""Convert a raw coordinate token to a float in *spec.unit*'s native units.
|
|
64
|
+
|
|
65
|
+
If the token contains a decimal point it is used directly (KiCad modern
|
|
66
|
+
output and any explicit-decimal generator). Otherwise the integer string
|
|
67
|
+
is padded according to the zero-suppression convention and the decimal
|
|
68
|
+
point is inserted at the configured position.
|
|
69
|
+
|
|
70
|
+
Returns a ``(value, truncated)`` pair. *truncated* is ``True`` when the
|
|
71
|
+
input had more digits than the format allows; callers should emit a
|
|
72
|
+
diagnostic warning in that case.
|
|
73
|
+
"""
|
|
74
|
+
if "." in raw:
|
|
75
|
+
return float(raw), False
|
|
76
|
+
|
|
77
|
+
total = spec.integer_digits + spec.decimal_digits
|
|
78
|
+
sign = ""
|
|
79
|
+
digits = raw
|
|
80
|
+
if raw and raw[0] in ("+", "-"):
|
|
81
|
+
sign = raw[0]
|
|
82
|
+
digits = raw[1:]
|
|
83
|
+
|
|
84
|
+
truncated = len(digits) > total
|
|
85
|
+
|
|
86
|
+
if spec.zero_suppression == "TZ":
|
|
87
|
+
# Trailing zeros suppressed in file -> right-pad to restore them
|
|
88
|
+
digits = digits.ljust(total, "0")
|
|
89
|
+
else:
|
|
90
|
+
# Leading zeros suppressed in file (LZ) -> left-pad to restore them
|
|
91
|
+
digits = digits.zfill(total)
|
|
92
|
+
|
|
93
|
+
if truncated:
|
|
94
|
+
digits = digits[:total]
|
|
95
|
+
|
|
96
|
+
if spec.decimal_digits > 0:
|
|
97
|
+
int_part = digits[: -spec.decimal_digits] or "0"
|
|
98
|
+
dec_part = digits[-spec.decimal_digits :]
|
|
99
|
+
return float(f"{sign}{int_part}.{dec_part}"), truncated
|
|
100
|
+
return float(f"{sign}{digits}"), truncated
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _to_inches(value: float, unit: UnitType) -> float:
|
|
104
|
+
return value / 25.4 if unit == UnitType.Millimeter else value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_tool_def(
|
|
108
|
+
line: str,
|
|
109
|
+
format_spec: _FormatSpec,
|
|
110
|
+
apertures: dict[int, CircleAperture],
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Parse a tool-definition line (``T<n>C<dia>``) and record it in *apertures*."""
|
|
113
|
+
m = _TOOL_DEF_RE.match(line)
|
|
114
|
+
if m:
|
|
115
|
+
tool_num = int(m.group(1))
|
|
116
|
+
dia_raw = float(m.group(2))
|
|
117
|
+
dia_in = _to_inches(dia_raw, format_spec.unit)
|
|
118
|
+
apertures[tool_num] = CircleAperture(diameter=dia_in)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_coord_line(
|
|
122
|
+
line: str,
|
|
123
|
+
format_spec: _FormatSpec,
|
|
124
|
+
current_tool: int,
|
|
125
|
+
apertures: dict[int, CircleAperture],
|
|
126
|
+
nets: list[DrawOp | RegionFill],
|
|
127
|
+
bbox: BoundingBox,
|
|
128
|
+
diagnostics: list[Diagnostic],
|
|
129
|
+
lineno: int,
|
|
130
|
+
) -> int:
|
|
131
|
+
"""Parse a coordinate line and append a flash DrawOp.
|
|
132
|
+
|
|
133
|
+
Returns the (possibly updated) current tool number.
|
|
134
|
+
"""
|
|
135
|
+
# Some generators include T<n> on the same line as XY
|
|
136
|
+
tool_m = re.search(r"T(\d+)", line, re.IGNORECASE)
|
|
137
|
+
if tool_m and not line.strip().upper().startswith("T"):
|
|
138
|
+
current_tool = int(tool_m.group(1))
|
|
139
|
+
|
|
140
|
+
x_val: float | None = None
|
|
141
|
+
y_val: float | None = None
|
|
142
|
+
for letter_match in re.finditer(r"([XY])([+-]?\d+(?:\.\d+)?)", line, re.IGNORECASE):
|
|
143
|
+
letter = letter_match.group(1).upper()
|
|
144
|
+
val, truncated = _apply_format(letter_match.group(2), format_spec)
|
|
145
|
+
if truncated:
|
|
146
|
+
diagnostics.append(
|
|
147
|
+
Diagnostic(
|
|
148
|
+
DiagnosticSeverity.Warning,
|
|
149
|
+
f"Coordinate field has more digits than format allows; truncated (line {lineno})",
|
|
150
|
+
lineno,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
if letter == "X":
|
|
154
|
+
x_val = val
|
|
155
|
+
elif letter == "Y":
|
|
156
|
+
y_val = val
|
|
157
|
+
|
|
158
|
+
if x_val is None and y_val is None:
|
|
159
|
+
return current_tool
|
|
160
|
+
if current_tool == 0:
|
|
161
|
+
diagnostics.append(
|
|
162
|
+
Diagnostic(
|
|
163
|
+
DiagnosticSeverity.Warning,
|
|
164
|
+
f"Drill hit with no tool selected (line {lineno})",
|
|
165
|
+
lineno,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return current_tool
|
|
169
|
+
|
|
170
|
+
x_in = _to_inches(x_val if x_val is not None else 0.0, format_spec.unit)
|
|
171
|
+
y_in = _to_inches(y_val if y_val is not None else 0.0, format_spec.unit)
|
|
172
|
+
|
|
173
|
+
nets.append(
|
|
174
|
+
DrawOp(
|
|
175
|
+
start_x=x_in,
|
|
176
|
+
start_y=y_in,
|
|
177
|
+
stop_x=x_in,
|
|
178
|
+
stop_y=y_in,
|
|
179
|
+
aperture_index=current_tool,
|
|
180
|
+
aperture_state=ApertureState.Flash,
|
|
181
|
+
interpolation=InterpolationMode.Linear,
|
|
182
|
+
layer_index=0,
|
|
183
|
+
net_state_index=0,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
ap = apertures.get(current_tool)
|
|
188
|
+
r = ap.diameter / 2.0 if ap is not None else 0.0
|
|
189
|
+
bbox.expand(x_in, y_in, r)
|
|
190
|
+
|
|
191
|
+
return current_tool
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_excellon(content: str, source_path: Path | None = None) -> ParsedImage:
|
|
195
|
+
"""Parse an Excellon drill file into a ParsedImage.
|
|
196
|
+
|
|
197
|
+
Tool definitions become ``CircleAperture`` entries in ``image.apertures``.
|
|
198
|
+
Drill hits become ``DrawOp(aperture_state=Flash)`` entries.
|
|
199
|
+
All coordinates are normalised to inches.
|
|
200
|
+
|
|
201
|
+
Both decimal-format (KiCad modern) and integer-format (Altium, older KiCad,
|
|
202
|
+
most CAM systems) coordinate encodings are supported. The zero-suppression
|
|
203
|
+
convention and digit counts are read from the ``METRIC``/``INCH`` header
|
|
204
|
+
line. If no format header is present, ``METRIC,TZ`` with 3.3 digit counts
|
|
205
|
+
is assumed and a ``DiagnosticSeverity.Warning`` is emitted.
|
|
206
|
+
"""
|
|
207
|
+
lines = content.splitlines()
|
|
208
|
+
|
|
209
|
+
# ---- mutable state ----
|
|
210
|
+
# Default: METRIC,TZ 3.3; overwritten when the header declares a format.
|
|
211
|
+
format_spec = _FormatSpec(
|
|
212
|
+
unit=UnitType.Millimeter,
|
|
213
|
+
zero_suppression="TZ",
|
|
214
|
+
integer_digits=3,
|
|
215
|
+
decimal_digits=3,
|
|
216
|
+
)
|
|
217
|
+
format_seen: bool = False
|
|
218
|
+
apertures: dict[int, CircleAperture] = {}
|
|
219
|
+
nets: list[DrawOp | RegionFill] = []
|
|
220
|
+
bbox = BoundingBox()
|
|
221
|
+
diagnostics: list[Diagnostic] = []
|
|
222
|
+
current_tool: int = 0
|
|
223
|
+
layer = LayerState()
|
|
224
|
+
net_state = CoordState()
|
|
225
|
+
|
|
226
|
+
in_header: bool = False
|
|
227
|
+
lineno: int = 0
|
|
228
|
+
|
|
229
|
+
for lineno, raw_line in enumerate(lines, start=1):
|
|
230
|
+
line = raw_line.strip()
|
|
231
|
+
|
|
232
|
+
if not line or line.startswith(";"):
|
|
233
|
+
continue # blank or comment
|
|
234
|
+
|
|
235
|
+
# ---- header start ----
|
|
236
|
+
if line == "M48":
|
|
237
|
+
in_header = True
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# ---- header end ----
|
|
241
|
+
if in_header and line in ("%", "M95"):
|
|
242
|
+
in_header = False
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if in_header:
|
|
246
|
+
upper = line.upper()
|
|
247
|
+
|
|
248
|
+
# Unit + zero-suppression + optional digit-count
|
|
249
|
+
if upper.startswith("METRIC") or upper.startswith("INCH"):
|
|
250
|
+
format_spec = _parse_unit_line(upper)
|
|
251
|
+
format_seen = True
|
|
252
|
+
elif upper.startswith("FMAT"):
|
|
253
|
+
pass # Excellon format version -- informational
|
|
254
|
+
# Tool definition in header
|
|
255
|
+
elif _TOOL_DEF_RE.match(line):
|
|
256
|
+
_parse_tool_def(line, format_spec, apertures)
|
|
257
|
+
# Ignore all other header lines
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# ---- body ----
|
|
261
|
+
upper = line.upper()
|
|
262
|
+
|
|
263
|
+
# End-of-program codes
|
|
264
|
+
if upper in ("M00", "M01", "M30"):
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
# G-codes
|
|
268
|
+
if upper.startswith("G"):
|
|
269
|
+
code = upper[1:3].lstrip("0") or "0"
|
|
270
|
+
if code in ("0", "00", "5", "05", "90"):
|
|
271
|
+
pass # drill mode / absolute -- ignore
|
|
272
|
+
elif code in ("1", "01"):
|
|
273
|
+
diagnostics.append(
|
|
274
|
+
Diagnostic(
|
|
275
|
+
DiagnosticSeverity.Warning,
|
|
276
|
+
"G01 linear rout mode encountered (not drill)",
|
|
277
|
+
lineno,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
elif code in ("2", "02", "3", "03"):
|
|
281
|
+
diagnostics.append(
|
|
282
|
+
Diagnostic(
|
|
283
|
+
DiagnosticSeverity.Warning,
|
|
284
|
+
"G02/G03 arc rout mode encountered (not drill)",
|
|
285
|
+
lineno,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
# Other G codes ignored silently
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# M-codes in body (M71/M72 = metric/inch switches)
|
|
292
|
+
if upper.startswith("M"):
|
|
293
|
+
code_s = upper[1:].lstrip("0") or "0"
|
|
294
|
+
if code_s == "71":
|
|
295
|
+
format_spec.unit = UnitType.Millimeter
|
|
296
|
+
elif code_s == "72":
|
|
297
|
+
format_spec.unit = UnitType.Inch
|
|
298
|
+
# M30 already handled above; ignore rest
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
# Tool definition in body (some generators emit T<n>C<dia> here)
|
|
302
|
+
if _TOOL_DEF_RE.match(line):
|
|
303
|
+
_parse_tool_def(line, format_spec, apertures)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Tool select: bare T<n>
|
|
307
|
+
m = _TOOL_SEL_RE.match(line)
|
|
308
|
+
if m:
|
|
309
|
+
current_tool = int(m.group(1))
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Coordinate line
|
|
313
|
+
if re.search(r"[XY]", line, re.IGNORECASE):
|
|
314
|
+
current_tool = _parse_coord_line(
|
|
315
|
+
line, format_spec, current_tool, apertures, nets, bbox, diagnostics, lineno
|
|
316
|
+
)
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
# Anything else: ignore silently (R-codes, comments without ';', etc.)
|
|
320
|
+
|
|
321
|
+
if not format_seen:
|
|
322
|
+
diagnostics.append(
|
|
323
|
+
Diagnostic(
|
|
324
|
+
DiagnosticSeverity.Warning,
|
|
325
|
+
"No unit declaration found; defaulting to METRIC,TZ 3.3",
|
|
326
|
+
None,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return ParsedImage(
|
|
331
|
+
draw_ops=nets,
|
|
332
|
+
apertures=dict(apertures),
|
|
333
|
+
layers=[layer],
|
|
334
|
+
coord_states=[net_state],
|
|
335
|
+
bounding_box=bbox,
|
|
336
|
+
diagnostics=diagnostics,
|
|
337
|
+
source_path=source_path,
|
|
338
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from gerberdiff.types import (
|
|
7
|
+
CircleAperture,
|
|
8
|
+
CoordinateMode,
|
|
9
|
+
MacroAperture,
|
|
10
|
+
ObroundAperture,
|
|
11
|
+
PolygonAperture,
|
|
12
|
+
RectangleAperture,
|
|
13
|
+
UnitType,
|
|
14
|
+
ZeroOmission,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from gerberdiff.parse.macro_parser import MacroDef
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FormatStatement:
|
|
23
|
+
"""Parsed result of a Gerber FS (format statement) extended command."""
|
|
24
|
+
|
|
25
|
+
zero_omission: ZeroOmission
|
|
26
|
+
coordinate_mode: CoordinateMode
|
|
27
|
+
x_integer: int # number of integer digits for X coordinates
|
|
28
|
+
x_decimal: int # number of decimal digits for X coordinates
|
|
29
|
+
y_integer: int
|
|
30
|
+
y_decimal: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert_coordinate(
|
|
34
|
+
raw_int: int,
|
|
35
|
+
raw_str: str,
|
|
36
|
+
int_digits: int,
|
|
37
|
+
dec_digits: int,
|
|
38
|
+
zero_omission: ZeroOmission,
|
|
39
|
+
unit: UnitType,
|
|
40
|
+
) -> float:
|
|
41
|
+
"""Convert a raw integer coordinate value to inches.
|
|
42
|
+
|
|
43
|
+
Leading zero omission (default, most common):
|
|
44
|
+
Divide raw_int directly by 10^dec_digits.
|
|
45
|
+
|
|
46
|
+
Trailing zero omission:
|
|
47
|
+
The digit string has trailing zeros omitted. Pad (excluding sign) to
|
|
48
|
+
int_digits+dec_digits with '0' on the right, then divide by 10^dec_digits.
|
|
49
|
+
|
|
50
|
+
Always: if unit == Millimeter, divide result by 25.4 to convert to inches.
|
|
51
|
+
"""
|
|
52
|
+
value: float
|
|
53
|
+
if zero_omission == ZeroOmission.Trailing:
|
|
54
|
+
total_digits = int_digits + dec_digits
|
|
55
|
+
negative = raw_str.startswith("-")
|
|
56
|
+
digits = raw_str.lstrip("+-")
|
|
57
|
+
padded = digits.ljust(total_digits, "0")
|
|
58
|
+
value = int(padded) / (10**dec_digits)
|
|
59
|
+
if negative:
|
|
60
|
+
value = -value
|
|
61
|
+
else:
|
|
62
|
+
# Leading or Explicit: raw_int is already the full integer representation
|
|
63
|
+
value = raw_int / (10**dec_digits)
|
|
64
|
+
|
|
65
|
+
if unit == UnitType.Millimeter:
|
|
66
|
+
value /= 25.4
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_format_statement(s: str) -> FormatStatement | None:
|
|
71
|
+
"""Parse a FS... extended command string (without the % delimiters).
|
|
72
|
+
|
|
73
|
+
Expected form: FSLAXnnYnn
|
|
74
|
+
- Optional FS prefix
|
|
75
|
+
- Zero omission: L (leading, most common) | T (trailing) | D (explicit)
|
|
76
|
+
- Coordinate mode: A (absolute) | I (incremental)
|
|
77
|
+
- Xij: i = integer digits, j = decimal digits
|
|
78
|
+
- Yij: same for Y axis
|
|
79
|
+
|
|
80
|
+
Returns None if the string cannot be parsed.
|
|
81
|
+
"""
|
|
82
|
+
# Strip optional "FS" prefix
|
|
83
|
+
body = s[2:] if s.startswith("FS") else s
|
|
84
|
+
idx = 0
|
|
85
|
+
|
|
86
|
+
# Zero omission
|
|
87
|
+
zero_omission: ZeroOmission
|
|
88
|
+
if idx < len(body) and body[idx] == "L":
|
|
89
|
+
zero_omission = ZeroOmission.Leading
|
|
90
|
+
idx += 1
|
|
91
|
+
elif idx < len(body) and body[idx] == "T":
|
|
92
|
+
zero_omission = ZeroOmission.Trailing
|
|
93
|
+
idx += 1
|
|
94
|
+
elif idx < len(body) and body[idx] == "D":
|
|
95
|
+
zero_omission = ZeroOmission.Explicit
|
|
96
|
+
idx += 1
|
|
97
|
+
else:
|
|
98
|
+
zero_omission = ZeroOmission.Leading # default per RS-274X spec
|
|
99
|
+
|
|
100
|
+
# Coordinate mode
|
|
101
|
+
coord_mode: CoordinateMode
|
|
102
|
+
if idx < len(body) and body[idx] == "A":
|
|
103
|
+
coord_mode = CoordinateMode.Absolute
|
|
104
|
+
idx += 1
|
|
105
|
+
elif idx < len(body) and body[idx] == "I":
|
|
106
|
+
coord_mode = CoordinateMode.Incremental
|
|
107
|
+
idx += 1
|
|
108
|
+
else:
|
|
109
|
+
coord_mode = CoordinateMode.Absolute
|
|
110
|
+
|
|
111
|
+
# X digits -- expect 'X' followed by two digit characters
|
|
112
|
+
if idx >= len(body) or body[idx] != "X":
|
|
113
|
+
return None
|
|
114
|
+
idx += 1
|
|
115
|
+
if idx + 1 >= len(body):
|
|
116
|
+
return None
|
|
117
|
+
x_int = int(body[idx], 10)
|
|
118
|
+
idx += 1
|
|
119
|
+
x_dec = int(body[idx], 10)
|
|
120
|
+
idx += 1
|
|
121
|
+
if not (0 <= x_int <= 9 and 0 <= x_dec <= 9):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
# Y digits -- expect 'Y' followed by two digit characters
|
|
125
|
+
if idx >= len(body) or body[idx] != "Y":
|
|
126
|
+
return None
|
|
127
|
+
idx += 1
|
|
128
|
+
if idx + 1 >= len(body):
|
|
129
|
+
return None
|
|
130
|
+
y_int = int(body[idx], 10)
|
|
131
|
+
idx += 1
|
|
132
|
+
y_dec = int(body[idx], 10)
|
|
133
|
+
if not (0 <= y_int <= 9 and 0 <= y_dec <= 9):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
return FormatStatement(
|
|
137
|
+
zero_omission=zero_omission,
|
|
138
|
+
coordinate_mode=coord_mode,
|
|
139
|
+
x_integer=x_int,
|
|
140
|
+
x_decimal=x_dec,
|
|
141
|
+
y_integer=y_int,
|
|
142
|
+
y_decimal=y_dec,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_aperture_definition(
|
|
147
|
+
s: str,
|
|
148
|
+
unit: UnitType,
|
|
149
|
+
macro_map: dict[str, MacroDef],
|
|
150
|
+
) -> (
|
|
151
|
+
tuple[
|
|
152
|
+
int, CircleAperture | RectangleAperture | ObroundAperture | PolygonAperture | MacroAperture
|
|
153
|
+
]
|
|
154
|
+
| str
|
|
155
|
+
| None
|
|
156
|
+
):
|
|
157
|
+
"""Parse an AD... extended command string (without the % delimiters).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
``(d_code, aperture)`` on success.
|
|
161
|
+
``"MACRO_NOT_FOUND:<name>"`` when the aperture type is a macro name not
|
|
162
|
+
present in *macro_map* (the aperture is unrecoverable; callers should
|
|
163
|
+
emit an Error diagnostic).
|
|
164
|
+
``None`` if the definition string is malformed/unparseable.
|
|
165
|
+
|
|
166
|
+
d_code must be >= 10 (codes 1-9 are reserved per RS-274X spec).
|
|
167
|
+
|
|
168
|
+
Standard aperture types: C (Circle), R (Rectangle), O (Obround), P (Polygon).
|
|
169
|
+
Parameters are separated by X. Hole diameter is the last optional parameter.
|
|
170
|
+
|
|
171
|
+
Unit scale: parameters in mm files are divided by 25.4; all output is in inches.
|
|
172
|
+
|
|
173
|
+
Macro apertures: look up name in macro_map; store raw params (NOT scaled) and
|
|
174
|
+
unit_scale on the aperture -- the renderer applies scaling at draw time.
|
|
175
|
+
"""
|
|
176
|
+
# Strip optional "AD" prefix
|
|
177
|
+
body = s[2:] if s.startswith("AD") else s
|
|
178
|
+
|
|
179
|
+
if not body or body[0] != "D":
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
# Read d_code digits (must be >= 10)
|
|
183
|
+
i = 1
|
|
184
|
+
while i < len(body) and body[i].isdigit():
|
|
185
|
+
i += 1
|
|
186
|
+
d_code_str = body[1:i]
|
|
187
|
+
if not d_code_str:
|
|
188
|
+
return None
|
|
189
|
+
d_code = int(d_code_str)
|
|
190
|
+
if d_code < 10:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# Split remainder into aperture-type name and parameter string
|
|
194
|
+
remainder = body[i:]
|
|
195
|
+
comma_pos = remainder.find(",")
|
|
196
|
+
if comma_pos == -1:
|
|
197
|
+
aperture_name = remainder
|
|
198
|
+
params_str = ""
|
|
199
|
+
else:
|
|
200
|
+
aperture_name = remainder[:comma_pos]
|
|
201
|
+
params_str = remainder[comma_pos + 1 :]
|
|
202
|
+
|
|
203
|
+
params = [float(p) for p in params_str.split("X")] if params_str else []
|
|
204
|
+
|
|
205
|
+
# Unit scale factor: mm -> inch
|
|
206
|
+
unit_scale = 1.0 / 25.4 if unit == UnitType.Millimeter else 1.0
|
|
207
|
+
|
|
208
|
+
if aperture_name == "C":
|
|
209
|
+
return d_code, CircleAperture(
|
|
210
|
+
diameter=(params[0] if params else 0.0) * unit_scale,
|
|
211
|
+
hole_diameter=params[1] * unit_scale if len(params) > 1 else None,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if aperture_name == "R":
|
|
215
|
+
return d_code, RectangleAperture(
|
|
216
|
+
width=(params[0] if params else 0.0) * unit_scale,
|
|
217
|
+
height=(params[1] if len(params) > 1 else 0.0) * unit_scale,
|
|
218
|
+
hole_diameter=params[2] * unit_scale if len(params) > 2 else None,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if aperture_name == "O":
|
|
222
|
+
return d_code, ObroundAperture(
|
|
223
|
+
width=(params[0] if params else 0.0) * unit_scale,
|
|
224
|
+
height=(params[1] if len(params) > 1 else 0.0) * unit_scale,
|
|
225
|
+
hole_diameter=params[2] * unit_scale if len(params) > 2 else None,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if aperture_name == "P":
|
|
229
|
+
return d_code, PolygonAperture(
|
|
230
|
+
outer_diameter=(params[0] if params else 0.0) * unit_scale,
|
|
231
|
+
num_vertices=int(params[1]) if len(params) > 1 else 4,
|
|
232
|
+
rotation=params[2] if len(params) > 2 else 0.0,
|
|
233
|
+
hole_diameter=params[3] * unit_scale if len(params) > 3 else None,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Macro aperture -- look up definition by name
|
|
237
|
+
macro_def = macro_map.get(aperture_name)
|
|
238
|
+
if macro_def is None:
|
|
239
|
+
return f"MACRO_NOT_FOUND:{aperture_name}"
|
|
240
|
+
return d_code, MacroAperture(
|
|
241
|
+
macro_def=macro_def,
|
|
242
|
+
params=params,
|
|
243
|
+
unit_scale=unit_scale,
|
|
244
|
+
)
|