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.
Files changed (38) hide show
  1. gerberdiff/__init__.py +86 -0
  2. gerberdiff/cli.py +527 -0
  3. gerberdiff/diff/__init__.py +0 -0
  4. gerberdiff/diff/diff_engine.py +409 -0
  5. gerberdiff/diff/layer_matcher.py +202 -0
  6. gerberdiff/export/__init__.py +0 -0
  7. gerberdiff/export/json_report.py +184 -0
  8. gerberdiff/export/png_export.py +92 -0
  9. gerberdiff/export/svg_export.py +187 -0
  10. gerberdiff/geometry/__init__.py +37 -0
  11. gerberdiff/geometry/attribute.py +203 -0
  12. gerberdiff/geometry/driver.py +227 -0
  13. gerberdiff/geometry/expand.py +232 -0
  14. gerberdiff/geometry/geom_diff.py +153 -0
  15. gerberdiff/geometry/layer_geometry.py +665 -0
  16. gerberdiff/geometry/macro_geom.py +215 -0
  17. gerberdiff/geometry/primitives.py +108 -0
  18. gerberdiff/geometry/types.py +85 -0
  19. gerberdiff/parse/__init__.py +0 -0
  20. gerberdiff/parse/arc_math.py +162 -0
  21. gerberdiff/parse/excellon_parser.py +338 -0
  22. gerberdiff/parse/gerber_parser.py +244 -0
  23. gerberdiff/parse/gerber_state.py +780 -0
  24. gerberdiff/parse/macro_parser.py +604 -0
  25. gerberdiff/parse/tokenizer.py +153 -0
  26. gerberdiff/py.typed +0 -0
  27. gerberdiff/render/__init__.py +0 -0
  28. gerberdiff/render/compiled_render.py +240 -0
  29. gerberdiff/render/draw_ops.py +205 -0
  30. gerberdiff/render/macro_renderer.py +343 -0
  31. gerberdiff/render/renderer.py +283 -0
  32. gerberdiff/render/viewport.py +85 -0
  33. gerberdiff/types.py +360 -0
  34. gerberdiff-0.29.0.dist-info/METADATA +105 -0
  35. gerberdiff-0.29.0.dist-info/RECORD +38 -0
  36. gerberdiff-0.29.0.dist-info/WHEEL +4 -0
  37. gerberdiff-0.29.0.dist-info/entry_points.txt +2 -0
  38. 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
+ )