summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-04-29 17:25:32 +0200
committerjaseg <git@jaseg.de>2023-04-30 11:07:29 +0200
commit26c2460490b6e64790c94e00be848465a6a5fa96 (patch)
treec23288bc30772615a758a856a456cc1d74affb31
parentaf3458b1e22f92f51606cff8f771d03551af4cc0 (diff)
downloadgerbonara-26c2460490b6e64790c94e00be848465a6a5fa96.tar.gz
gerbonara-26c2460490b6e64790c94e00be848465a6a5fa96.tar.bz2
gerbonara-26c2460490b6e64790c94e00be848465a6a5fa96.zip
Fix remaining unit tests
-rw-r--r--gerbonara/aperture_macros/parse.py19
-rw-r--r--gerbonara/aperture_macros/primitive.py12
-rw-r--r--gerbonara/apertures.py6
-rw-r--r--gerbonara/cli.py2
-rwxr-xr-xgerbonara/excellon.py16
-rw-r--r--gerbonara/graphic_objects.py6
-rw-r--r--gerbonara/layers.py4
-rw-r--r--gerbonara/rs274x.py229
8 files changed, 140 insertions, 154 deletions
diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py
index 5dda931..d38a83d 100644
--- a/gerbonara/aperture_macros/parse.py
+++ b/gerbonara/aperture_macros/parse.py
@@ -49,18 +49,21 @@ def _parse_expression(expr):
@dataclass(frozen=True, slots=True)
class ApertureMacro:
- name: str = None
+ name: str = field(default=None, hash=False, compare=False)
primitives: tuple = ()
variables: tuple = ()
- comments: tuple = ()
+ comments: tuple = field(default=(), hash=False, compare=False)
def __post_init__(self):
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
# We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance.
- object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
+ self._reset_name()
+
+ def _reset_name(self):
+ object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
@classmethod
- def parse_macro(cls, name, body, unit):
+ def parse_macro(kls, name, body, unit):
comments = []
variables = {}
primitives = []
@@ -86,11 +89,10 @@ class ApertureMacro:
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg) for arg in args ]
- primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
- primitives.append(primitive)
+ primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args))
- variables = [variables.get(i+1) for i in range(max(variables.keys()))]
- return kls(name, tuple(primitives), tuple(variables), tuple(primitives))
+ variables = [variables.get(i+1) for i in range(max(variables.keys(), default=0))]
+ return kls(name, tuple(primitives), tuple(variables), tuple(comments))
def __str__(self):
return f'<Aperture macro {self.name}, variables {str(self.variables)}, primitives {self.primitives}>'
@@ -110,6 +112,7 @@ class ApertureMacro:
return replace(self, primitives=tuple(new_primitives))
def to_gerber(self, unit=None):
+ """ Serialize this macro's content (without the name) into Gerber using the given file unit """
comments = [ str(c) for c in self.comments ]
variable_defs = [ f'${var}={str(expr)[1:-1]}' for var, expr in enumerate(self.variables, start=1) if expr is not None ]
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py
index a12a33c..f575b0c 100644
--- a/gerbonara/aperture_macros/primitive.py
+++ b/gerbonara/aperture_macros/primitive.py
@@ -58,8 +58,8 @@ class Primitive:
return str(self)
@classmethod
- def from_arglist(kls, arglist):
- return kls(*arglist)
+ def from_arglist(kls, unit, arglist):
+ return kls(unit, *arglist)
class Calculator:
def __init__(self, instance, variable_binding={}, unit=None):
@@ -267,11 +267,11 @@ class Outline(Primitive):
yield x, y
@classmethod
- def from_arglist(kls, arglist):
- if len(arglist[3:]) % 2 == 0:
- return kls(unit=arglist[0], exposure=arglist[1], length=arglist[2], coords=arglist[3:], rotation=0)
+ def from_arglist(kls, unit, arglist):
+ if len(arglist[2:]) % 2 == 0:
+ return kls(unit=unit, exposure=arglist[0], length=arglist[1], coords=arglist[2:], rotation=0)
else:
- return kls(unit=arglist[0], exposure=arglist[1], length=arglist[2], coords=arglist[3:-1], rotation=arglist[-1])
+ return kls(unit=unit, exposure=arglist[0], length=arglist[1], coords=arglist[2:-1], rotation=arglist[-1])
def __str__(self):
return f'<Outline {len(self.coords)} points>'
diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py
index 4582b76..c49b599 100644
--- a/gerbonara/apertures.py
+++ b/gerbonara/apertures.py
@@ -60,8 +60,8 @@ class Aperture:
_ : KW_ONLY
unit: LengthUnit = None
attrs: tuple = None
- original_number: int = None
- _bounding_box: tuple = None
+ original_number: int = field(default=None, hash=False, compare=False)
+ _bounding_box: tuple = field(default=None, hash=False, compare=False)
def _params(self, unit=None):
out = []
@@ -351,7 +351,7 @@ class PolygonAperture(Aperture):
hole_dia : Length(float) = None
def __post_init__(self):
- self.n_vertices = int(self.n_vertices)
+ object.__setattr__(self, 'n_vertices', int(self.n_vertices))
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
diff --git a/gerbonara/cli.py b/gerbonara/cli.py
index a27a0b6..bcc4f11 100644
--- a/gerbonara/cli.py
+++ b/gerbonara/cli.py
@@ -494,7 +494,7 @@ def meta(path, force_zip, format_warnings):
d[function] = {
'format': 'Gerber',
'path': str(layer.original_path),
- 'apertures': len(layer.apertures),
+ 'apertures': len(list(layer.apertures())),
'objects': len(layer.objects),
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
'format_settings': format_settings,
diff --git a/gerbonara/excellon.py b/gerbonara/excellon.py
index d395ecf..9b75a69 100755
--- a/gerbonara/excellon.py
+++ b/gerbonara/excellon.py
@@ -46,8 +46,8 @@ class ExcellonContext:
def select_tool(self, tool):
""" Select the current tool. Retract drill first if necessary. """
- current_id = self.tools.get(id(self.current_tool))
- new_id = self.tools[id(tool)]
+ current_id = self.tools.get(self.current_tool)
+ new_id = self.tools[tool]
if new_id != current_id:
if self.drill_down:
yield 'M16' # drill up
@@ -270,17 +270,15 @@ class ExcellonFile(CamFile):
def to_gerber(self, errros='raise'):
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
- apertures = {}
out = GerberFile()
out.comments = self.comments
+ apertures = {}
for obj in self.objects:
- if id(obj.tool) not in apertures:
- apertures[id(obj.tool)] = CircleAperture(obj.tool.diameter)
-
- out.objects.append(dataclasses.replace(obj, aperture=apertures[id(obj.tool)]))
+ if not (ap := apertures[obj.tool]):
+ ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter)
- out.apertures = list(apertures.values())
+ out.objects.append(dataclasses.replace(obj, aperture=ap))
@property
def generator(self):
@@ -373,7 +371,7 @@ class ExcellonFile(CamFile):
yield 'METRIC' if settings.unit == MM else 'INCH'
# Build tool index
- tool_map = { id(obj.tool): obj.tool for obj in self.objects }
+ tool_map = { obj.tool: obj.tool for obj in self.objects }
tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter))
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py
index 80590a7..2b9dc3e 100644
--- a/gerbonara/graphic_objects.py
+++ b/gerbonara/graphic_objects.py
@@ -216,7 +216,8 @@ class Flash(GraphicObject):
def bounding_box(self, unit=None):
(min_x, min_y), (max_x, max_y) = self.aperture.bounding_box(unit)
- return (min_x+self.x, min_y+self.y), (max_x+self.x, max_x+self.y)
+ x, y = self.unit.convert_to(unit, self.x), self.unit.convert_to(unit, self.y)
+ return (min_x+x, min_y+y), (max_x+x, max_y+y)
@property
def plated(self):
@@ -398,6 +399,9 @@ class Region(GraphicObject):
yield from gs.set_current_point(self.outline[0], unit=self.unit)
for point, arc_center in zip_longest(self.outline[1:], self.arc_centers):
+ if point is None and arc_center is None:
+ break
+
if arc_center is None:
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
diff --git a/gerbonara/layers.py b/gerbonara/layers.py
index f4c8279..e8ce1a3 100644
--- a/gerbonara/layers.py
+++ b/gerbonara/layers.py
@@ -838,12 +838,12 @@ class LayerStack:
if use_use:
layer.dedup_apertures()
for obj in layer.objects:
- if hasattr(obj, 'aperture') and obj.polarity_dark and id(obj.aperture) not in use_map:
+ if hasattr(obj, 'aperture') and obj.polarity_dark and obj.aperture not in use_map:
children = [prim.to_svg(fg, bg, tag=tag)
for prim in obj.aperture.flash(0, 0, svg_unit, polarity_dark=True)]
use_id = f'a{len(use_defs)}'
use_defs.append(tag('g', children, id=use_id))
- use_map[id(obj.aperture)] = use_id
+ use_map[obj.aperture] = use_id
objects = []
for obj in layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, aperture_map=use_map, tag=Tag):
diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py
index c559029..90c1783 100644
--- a/gerbonara/rs274x.py
+++ b/gerbonara/rs274x.py
@@ -24,6 +24,7 @@ import math
import warnings
from pathlib import Path
import dataclasses
+import functools
from .cam import CamFile, FileSettings
from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning
@@ -58,7 +59,6 @@ class GerberFile(CamFile):
:ivar layer_hints: Similar to ``generator_hints``, this is a list containing hints which layer type this file could
belong to. Usually, this will be empty, but some EDA tools automatically include layer
information inside tool-specific comments in the Gerber files they generate.
- :ivar apertures: List of apertures used in this file. Make sure you keep this in sync when adding new objects.
:ivar file_attrs: List of strings with Gerber X3 file attributes. Each list item corresponds to one file attribute.
"""
@@ -70,11 +70,87 @@ class GerberFile(CamFile):
self.generator_hints = generator_hints or []
self.layer_hints = layer_hints or []
self.import_settings = import_settings
- self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
self.file_attrs = file_attrs or {}
- def sync_apertures(self):
- self.apertures = list({id(obj.aperture): obj.aperture for obj in self.objects if hasattr(obj, 'aperture')}.values())
+ def apertures(self):
+ """ Iterate through all apertures in this layer. """
+ found = set()
+ for obj in self.objects:
+ if hasattr(obj, 'aperture'):
+ ap = obj.aperture
+ if ap not in found:
+ found.add(ap)
+ yield ap
+
+ def aperture_macros(self):
+ found = set()
+ for aperture in self.apertures():
+ if isinstance(aperture, apertures.ApertureMacroInstance):
+ macro = aperture.macro
+ if (macro.name, macro) not in found:
+ found.add((macro.name, macro))
+ yield macro
+
+ def map_apertures(self, map_or_callable, cache=True):
+ """ Replace all apertures in all objects in this layer according to the given map or callable.
+
+ When a map is passed, apertures that are not in the map are left alone. When a callable is given, it is called
+ with the old aperture as its argument.
+
+ :param map_or_callable: A dict-like object, or a callable mapping old to new apertures
+ :param cache: When True (default) and a callable is passed, caches the output of callable, only calling it once
+ for each old aperture.
+ """
+
+ if callable(map_or_callable):
+ if cache:
+ map_or_callable = functools.cache(map_or_callable)
+ else:
+ d = map_or_callable
+ map_or_callable = lambda ap: d.get(ap, ap)
+
+ for obj in self.objects:
+ if (aperture := getattr(obj, 'aperture', None)):
+ obj.aperture = map_or_callable(aperture)
+
+ def dedup_apertures(self, settings=None):
+ """ Merge all apertures and aperture macros in this layer that result in the same Gerber definition under the
+ given :py:class:~.FileSettings:.
+
+ When no explicit settings are given, uses Gerbonara's default settings.
+
+ :param settings: settings under which to de-duplicate the apertures.
+ """
+
+ if settings is None:
+ settings = FileSettings.defaults()
+
+ cache = {}
+ macro_cache = {}
+ macro_names = set()
+ def lookup(aperture):
+ nonlocal cache, settings
+ if isinstance(aperture, apertures.ApertureMacroInstance):
+ macro = aperture.macro
+ macro_def = macro.to_gerber(unit=settings.unit)
+ if macro_def not in cache:
+ cache[macro_def] = macro
+
+ if macro.name in macro_names:
+ macro._reset_name()
+ macro_names.add(macro.name)
+
+ else:
+ macro = cache[macro_def]
+ aperture = dataclasses.replace(aperture, macro=macro)
+
+ code = aperture.to_gerber(settings)
+ if code not in cache:
+ cache[code] = aperture
+
+ return cache[code]
+
+ self.map_apertures(lookup)
def to_excellon(self, plated=None, errors='raise'):
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
@@ -85,7 +161,7 @@ class GerberFile(CamFile):
new_tools = {}
for obj in self.objects:
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
- not isinstance(obj.aperture, apertures.CircleAperture):
+ not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.')
elif errors == 'warn':
@@ -96,9 +172,9 @@ class GerberFile(CamFile):
else:
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
- if not (new_tool := new_tools.get(id(obj.aperture))):
+ if not (new_tool := new_tools.get(obj.aperture)):
# TODO plating?
- new_tool = new_tools[id(obj.aperture)] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
+ new_tool = new_tools[obj.aperture] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
new_objs.append(dataclasses.replace(obj, aperture=new_tool))
return ExcellonFile(objects=new_objs, comments=self.comments)
@@ -127,18 +203,6 @@ class GerberFile(CamFile):
self.import_settings = None
self.comments += other.comments
- # dedup apertures
- new_apertures = {}
- replace_apertures = {}
- mock_settings = FileSettings.defaults()
- for ap in self.apertures + other.apertures:
- gbr = ap.to_gerber(mock_settings)
- if gbr not in new_apertures:
- new_apertures[gbr] = ap
- else:
- replace_apertures[id(ap)] = new_apertures[gbr]
- self.apertures = list(new_apertures.values())
-
# Join objects
if mode == 'below':
self.objects = other.objects + self.objects
@@ -147,57 +211,23 @@ class GerberFile(CamFile):
else:
raise ValueError(f'Invalid mode "{mode}", must be one of "above" or "below".')
- for obj in self.objects:
- # If object has an aperture attribute, replace that aperture.
- if (ap := replace_apertures.get(id(getattr(obj, 'aperture', None)))):
- obj.aperture = ap
-
- # dedup aperture macros
- macros = { m.to_gerber(): m
- for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] }
- for ap in new_apertures.values():
- if isinstance(ap, apertures.ApertureMacroInstance):
- macro_grb = ap.macro.to_gerber() # use native unit to compare macros
- if macro_grb in macros:
- ap.macro = macros[macro_grb]
- else:
- macros[macro_grb] = ap.macro
-
- # make macro names unique
- seen_macro_names = set()
- for macro in macros.values():
- i = 2
- while (new_name := f'{macro.name}{i}') in seen_macro_names:
- i += 1
- macro.name = new_name
- seen_macro_names.add(new_name)
+ self.dedup_apertures()
def dilate(self, offset, unit=MM, polarity_dark=True):
# TODO add tests for this
- self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ]
+ self.map_apertures(lambda ap: ap.dilated(offset, unit))
offset_circle = apertures.CircleAperture(offset, unit=unit)
- self.apertures.append(offset_circle)
-
- new_primitives = []
- for p in self.primitives:
-
- p.polarity_dark = polarity_dark
+ new_objects = []
+ for obj in self.objects:
+ obj.polarity_dark = polarity_dark
# Ignore Line, Arc, Flash. Their actual dilation has already been done by dilating the apertures above.
- if isinstance(p, Region):
- ol = p.poly.outline
- for start, end, arc_center in zip(ol, ol[1:] + ol[0], p.poly.arc_centers):
- if arc_center is not None:
- new_primitives.append(Arc(*start, *end, *arc_center,
- polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
-
- else:
- new_primitives.append(Line(*start, *end,
- polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
+ if isinstance(obj, Region):
+ new_objects.extend(obj.outline_objects(offset_circle))
# it's safe to append these at the end since we compute a logical OR of opaque areas anyway.
- self.primitives.extend(new_primitives)
+ self.objects.extend(new_objects)
@classmethod
def open(kls, filename, enable_includes=False, enable_include_dir=None, override_settings=None):
@@ -228,29 +258,8 @@ class GerberFile(CamFile):
parser.parse(data, filename=filename)
return obj
- def dedup_apertures(self, settings=None):
- settings = settings or FileSettings.defaults()
-
- defined_apertures = {}
- ap_map = {}
- for obj in self.objects:
- if not hasattr(obj, 'aperture'):
- continue
-
- if id(obj.aperture) in ap_map:
- obj.aperture = ap_map[id(obj.aperture)]
-
- ap_def = obj.aperture.to_gerber(settings)
- if ap_def in defined_apertures:
- ap_map[id(obj.aperture)] = obj.aperture = defined_apertures[ap_def]
- else:
- ap_map[id(obj.aperture)] = defined_apertures[ap_def] = obj.aperture
-
- self.apertures = list(ap_map.values())
-
def _generate_statements(self, settings, drop_comments=True):
""" Export this file as Gerber code, yields one str per line. """
- self.sync_apertures()
yield 'G04 Gerber file generated by Gerbonara*'
for name, value in self.file_attrs.items():
@@ -273,33 +282,15 @@ class GerberFile(CamFile):
for cmt in self.comments:
yield f'G04{cmt}*'
- # Always emit gerbonara's generic, rotation-capable aperture macro replacements for the standard C/R/O/P shapes.
- # Unconditionally emitting these here is easier than first trying to figure out if we need them later,
- # and they are only a few bytes anyway.
+ self.dedup_apertures()
+
am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(unit=settings.unit)}*\n%'
- for macro in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon ]:
+ for macro in self.aperture_macros():
yield am_stmt(macro)
- processed_macros = set()
- aperture_map = {}
- defined_apertures = {}
- number = 10
- for aperture in self.apertures:
- if isinstance(aperture, apertures.ApertureMacroInstance):
- macro_def = am_stmt(aperture.macro)
- if macro_def not in processed_macros:
- processed_macros.add(macro_def)
- yield macro_def
-
- ap_def = aperture.to_gerber(settings)
- if ap_def in defined_apertures:
- aperture_map[id(aperture)] = defined_apertures[ap_def]
-
- else:
- yield f'%ADD{number}{ap_def}*%'
- defined_apertures[ap_def] = number
- aperture_map[id(aperture)] = number
- number += 1
+ aperture_map = {ap: num for num, ap in enumerate(self.apertures(), start=10)}
+ for aperture, number in aperture_map.items():
+ yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
def warn(msg, kls=SyntaxWarning):
warnings.warn(msg, kls)
@@ -312,7 +303,7 @@ class GerberFile(CamFile):
def __str__(self):
name = f'{self.original_path.name} ' if self.original_path else ''
- return f'<GerberFile {name}with {len(self.apertures)} apertures, {len(self.objects)} objects>'
+ return f'<GerberFile {name}with {len(list(self.apertures()))} apertures, {len(self.objects)} objects>'
def __repr__(self):
return str(self)
@@ -348,17 +339,11 @@ class GerberFile(CamFile):
def scale(self, factor, unit=MM):
scaled_apertures = {}
- for ap in self.apertures:
- scaled_apertures[id(ap)] = ap.scaled(factor)
+ self.map_apertures(lambda ap: ap.scaled(factor))
for obj in self.objects:
obj.scale(factor)
- if (obj_ap := getattr(obj, 'aperture', None)):
- obj.aperture = scaled_apertures[id(obj_ap)]
-
- self.apertures = list(scaled_apertures.values())
-
def offset(self, dx=0, dy=0, unit=MM):
# TODO round offset to file resolution
for obj in self.objects:
@@ -368,10 +353,7 @@ class GerberFile(CamFile):
if math.isclose(angle % (2*math.pi), 0):
return
- # First, rotate apertures. We do this separately from rotating the individual objects below to rotate each
- # aperture exactly once.
- for ap in self.apertures:
- ap.rotation += angle
+ self.map_apertures(lambda ap: ap.rotated(angle))
for obj in self.objects:
obj.rotate(angle, cx, cy, unit)
@@ -568,8 +550,8 @@ class GraphicsState:
yield '%LPD*%' if polarity_dark else '%LPC*%'
def set_aperture(self, aperture):
- ap_id = self.aperture_map[id(aperture)]
- old_ap_id = self.aperture_map.get(id(self.aperture), None)
+ ap_id = self.aperture_map[aperture]
+ old_ap_id = self.aperture_map.get(self.aperture, None)
if ap_id != old_ap_id:
self.aperture = aperture
yield f'D{ap_id}*'
@@ -711,7 +693,6 @@ class GerberParser:
self.warn(f'Unknown statement found: "{self._shorten_line()}", ignoring.', UnknownStatementWarning)
self.target.comments.append(f'Unknown statement found: "{self._shorten_line()}", ignoring.')
- self.target.apertures = list(self.aperture_map.values())
self.target.import_settings = self.file_settings
self.target.unit = self.file_settings.unit
self.target.file_attrs = self.file_attrs
@@ -852,12 +833,12 @@ class GerberParser:
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
- new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy(),
+ new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
original_number=number)
elif (macro := self.aperture_macros.get(match['shape'])):
- new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit,
- attrs=self.aperture_attrs.copy(), original_number=number)
+ new_aperture = apertures.ApertureMacroInstance(macro, tuple(modifiers), unit=self.file_settings.unit,
+ attrs=tuple(self.aperture_attrs.items()), original_number=number)
else:
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')