diff options
Diffstat (limited to 'gerbonara')
-rw-r--r-- | gerbonara/apertures.py | 188 | ||||
-rwxr-xr-x | gerbonara/excellon.py | 4 | ||||
-rw-r--r-- | gerbonara/graphic_objects.py | 2 |
3 files changed, 141 insertions, 53 deletions
diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index 086d5b1..e22b702 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -10,23 +10,23 @@ from . import graphic_primitives as gp def _flash_hole(self, x, y, unit=None, polarity_dark=True): if getattr(self, 'hole_rect_h', None) is not None: - return [*self.primitives(x, y, unit, polarity_dark), + return [*self._primitives(x, y, unit, polarity_dark), gp.Rectangle((x, y), (self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)), rotation=self.rotation, polarity_dark=(not polarity_dark))] elif self.hole_dia is not None: - return [*self.primitives(x, y, unit, polarity_dark), + return [*self._primitives(x, y, unit, polarity_dark), gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))] else: - return self.primitives(x, y, unit, polarity_dark) + return self._primitives(x, y, unit, polarity_dark) -def strip_right(*args): +def _strip_right(*args): args = list(args) while args and args[-1] is None: args.pop() return args -def none_close(a, b): +def _none_close(a, b): if a is None and b is None: return True elif a is not None and b is not None: @@ -35,24 +35,37 @@ def none_close(a, b): return False class Length: + """ Marker indicating that a dataclass field of an :py:class:`.Aperture` contains a physical length or coordinate + measured in the :py:class:`.Aperture`'s native unit from :py:attr:`.Aperture.unit`. + """ def __init__(self, obj_type): self.type = obj_type @dataclass class Aperture: + """ Base class for all apertures. """ _ : KW_ONLY + #: :py:class:`gerbonara.utils.LengthUnit` used for all length fields of this aperture. unit : str = None + #: GerberX2 attributes of this aperture. Note that this will only contain aperture attributes, not file attributes. + #: File attributes are stored in the :py:attr:`~.GerberFile.attrs` of the :py:class:`.GerberFile`. attrs : dict = field(default_factory=dict) - original_number : str = None + #: Aperture index this aperture had when it was read from the Gerber file. This field is purely informational since + #: apertures are de-duplicated and re-numbered when writing a Gerber file. For `D10`, this field would be `10`. When + #: you programmatically create a new aperture, you do not have to set this. + original_number : int = None @property def hole_shape(self): - if hasattr(self, 'hole_rect_h') and self.hole_rect_h is not None: + """ Get shape of hole based on :py:attr:`hole_dia` and :py:attr:`hole_rect_h`: "rect" or "circle" or None. """ + if getattr(self, 'hole_rect_h') is not None: return 'rect' - else: + elif getattr(self, 'hole_dia') is not None: return 'circle' + else: + return None - def params(self, unit=None): + def _params(self, unit=None): out = [] for f in fields(self): if f.kw_only: @@ -66,24 +79,52 @@ class Aperture: return out def flash(self, x, y, unit=None, polarity_dark=True): - return self.primitives(x, y, unit, polarity_dark) + """ Render this aperture into a ``list`` of :py:class:`.GraphicPrimitive` instances in the given unit. If no + unit is given, use this aperture's local unit. + + :param float x: X coordinate of center of flash. + :param float y: Y coordinate of center of flash. + :param LengthUnit unit: Physical length unit to use for the returned primitives. + :param bool polarity_dark: Polarity of this flash. ``True`` renders this aperture as usual. ``False`` flips the polarity of all primitives. + + :returns: Rendered graphic primitivees. + :rtype: list(:py:class:`.GraphicPrimitive`) + """ + return self._primitives(x, y, unit, polarity_dark) def equivalent_width(self, unit=None): + """ Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`. + + :rtype: float + """ raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.') def to_gerber(self, settings=None): + """ Return the Gerber aperture definition for this aperture using the given :py:class:`.FileSettings`. + + :rtype: str + """ # Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use, # we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at # export time during to_gerber, this parameter is evaluated. unit = settings.unit if settings else None actual_inst = self._rotated() - params = 'X'.join(f'{float(par):.4}' for par in actual_inst.params(unit) if par is not None) + params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None) if params: - return f'{actual_inst.gerber_shape_code},{params}' + return f'{actual_inst._gerber_shape_code},{params}' else: - return actual_inst.gerber_shape_code + return actual_inst._gerber_shape_code + + def to_macro(self): + """ Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an + :py:class:`.ApertureMacroInstance`. + """ + raise NotImplementedError() def __eq__(self, other): + """ Compare two apertures. Apertures are compared based on their Gerber representation. Two apertures are + considered equal if their Gerber aperture definitions are identical. + """ # We need to choose some unit here. return hasattr(other, 'to_gerber') and self.to_gerber(MM) == other.to_gerber(MM) @@ -95,39 +136,44 @@ class Aperture: @dataclass(unsafe_hash=True) class ExcellonTool(Aperture): - gerber_shape_code = 'C' - human_readable_shape = 'drill' + """ Special Aperture_ subclass for use in :py:class:`.ExcellonFile`. Similar to :py:class:`.CircleAperture`, but + does not have :py:attr:`.CircleAperture.hole_dia` or :py:attr:`.CircleAperture.hole_rect_h`, and has the additional + :py:attr:`plated` attribute. + """ + _gerber_shape_code = 'C' + _human_readable_shape = 'drill' + #: float with diameter of this tool in :py:attr:`unit` units. diameter : Length(float) + #: bool or ``None`` for "unknown", indicating whether this tool creates plated (``True``) or non-plated (``False``) + #: holes. plated : bool = None - depth_offset : Length(float) = 0 - def primitives(self, x, y, unit=None, polarity_dark=True): + def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ] def to_xnc(self, settings): - z_off = 'Z' + settings.write_excellon_value(self.depth_offset, self.unit) if self.depth_offset is not None else '' - return 'C' + settings.write_excellon_value(self.diameter, self.unit) + z_off + return 'C' + settings.write_excellon_value(self.diameter, self.unit) def __eq__(self, other): + """ Compare two :py:class:`.ExcellonTool` instances. They are considered equal if their diameter and plating + match. + """ if not isinstance(other, ExcellonTool): return False if not self.plated == other.plated: return False - if not none_close(self.depth_offset, self.unit(other.depth_offset, other.unit)): - return False - - return none_close(self.diameter, self.unit(other.diameter, other.unit)) + return _none_close(self.diameter, self.unit(other.diameter, other.unit)) def __str__(self): plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated') - z_off = '' if self.depth_offset is None else f' z_offset={self.depth_offset}' - return f'<Excellon Tool d={self.diameter:.3f}{plated}{z_off} [{self.unit}]>' + return f'<Excellon Tool d={self.diameter:.3f}{plated} [{self.unit}]>' def equivalent_width(self, unit=MM): return unit(self.diameter, self.unit) + # Internal use, for layer dilation. def dilated(self, offset, unit=MM): offset = unit(offset, self.unit) return replace(self, diameter=self.diameter+2*offset) @@ -136,22 +182,29 @@ class ExcellonTool(Aperture): return self def to_macro(self): - return ApertureMacroInstance(GenericMacros.circle, self.params(unit=MM)) + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) - def params(self, unit=None): + def _params(self, unit=None): return [self.unit.convert_to(unit, self.diameter)] @dataclass class CircleAperture(Aperture): - gerber_shape_code = 'C' - human_readable_shape = 'circle' + """ Besides flashing circles or rings, CircleApertures are used to set the width of a + :py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc`. + """ + _gerber_shape_code = 'C' + _human_readable_shape = 'circle' + #: float with diameter of the circle in :py:attr:`unit` units. diameter : Length(float) + #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None + #: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole. hole_rect_h : Length(float) = None - rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber + # float with radians. This is only used for rectangular holes (as circles are rotationally symmetric). + rotation : float = 0 - def primitives(self, x, y, unit=None, polarity_dark=True): + def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ] def __str__(self): @@ -173,10 +226,10 @@ class CircleAperture(Aperture): return self.to_macro(self.rotation) def to_macro(self): - return ApertureMacroInstance(GenericMacros.circle, self.params(unit=MM)) + return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM)) - def params(self, unit=None): - return strip_right( + def _params(self, unit=None): + return _strip_right( self.unit.convert_to(unit, self.diameter), self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)) @@ -184,15 +237,20 @@ class CircleAperture(Aperture): @dataclass class RectangleAperture(Aperture): - gerber_shape_code = 'R' - human_readable_shape = 'rect' + _gerber_shape_code = 'R' + _human_readable_shape = 'rect' + #: float with the width of the rectangle in :py:attr:`unit` units. w : Length(float) + #: float with the height of the rectangle in :py:attr:`unit` units. h : Length(float) + #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None + #: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole. hole_rect_h : Length(float) = None + # Rotation in radians. This rotates both the aperture and the rectangular hole if it has one. rotation : float = 0 # radians - def primitives(self, x, y, unit=None, polarity_dark=True): + def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation, polarity_dark=polarity_dark) ] @@ -224,8 +282,8 @@ class RectangleAperture(Aperture): MM(self.hole_rect_h, self.unit) or 0, self.rotation]) - def params(self, unit=None): - return strip_right( + def _params(self, unit=None): + return _strip_right( self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), self.unit.convert_to(unit, self.hole_dia), @@ -234,15 +292,26 @@ class RectangleAperture(Aperture): @dataclass class ObroundAperture(Aperture): - gerber_shape_code = 'O' - human_readable_shape = 'obround' + """ Aperture whose shape is the convex hull of two circles of equal radii. + + Obrounds are specified through width and height of their bounding rectangle.. The smaller one of these will be the + diameter of the obround's ends. If :py:attr:`w` is larger, the result will be a landscape obround. If :py:attr:`h` + is larger, it will be a portrait obround. + """ + _gerber_shape_code = 'O' + _human_readable_shape = 'obround' + #: float with the width of the bounding rectangle of this obround in :py:attr:`unit` units. w : Length(float) + #: float with the height of the bounding rectangle of this obround in :py:attr:`unit` units. h : Length(float) + #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None + #: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole. hole_rect_h : Length(float) = None + #: Rotation in radians. This rotates both the aperture and the rectangular hole if it has one. rotation : float = 0 - def primitives(self, x, y, unit=None, polarity_dark=True): + def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.Obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation, polarity_dark=polarity_dark) ] @@ -273,8 +342,8 @@ class ObroundAperture(Aperture): MM(inst.hole_rect_h, self.unit), inst.rotation]) - def params(self, unit=None): - return strip_right( + def _params(self, unit=None): + return _strip_right( self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), self.unit.convert_to(unit, self.hole_dia), @@ -283,16 +352,24 @@ class ObroundAperture(Aperture): @dataclass class PolygonAperture(Aperture): - gerber_shape_code = 'P' + """ Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports + round holes. + """ + _gerber_shape_code = 'P' + #: Diameter of circumscribing circle, i.e. the circle that all the polygon's corners lie on. In + #: :py:attr:`unit` units. diameter : Length(float) + #: Number of corners of this polygon. Three for a triangle, four for a square, five for a pentagon etc. n_vertices : int + #: Rotation in radians. rotation : float = 0 + #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. hole_dia : Length(float) = None def __post_init__(self): self.n_vertices = int(self.n_vertices) - def primitives(self, x, y, unit=None, polarity_dark=True): + def _primitives(self, x, y, unit=None, polarity_dark=True): return [ gp.RegularPolygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices, rotation=self.rotation, polarity_dark=polarity_dark) ] @@ -309,7 +386,7 @@ class PolygonAperture(Aperture): return self def to_macro(self): - return ApertureMacroInstance(GenericMacros.polygon, self.params(MM)) + return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) def params(self, unit=None): rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None @@ -322,15 +399,26 @@ class PolygonAperture(Aperture): @dataclass class ApertureMacroInstance(Aperture): + """ One instance of an aperture macro. An aperture macro defined with an ``AM`` statement can be instantiated by + multiple ``AD`` aperture definition statements using different parameters. An :py:class:`.ApertureMacroInstance` is + one such binding of a macro to a particular set of parameters. Note that you still need an + :py:class:`.ApertureMacroInstance` even if your :py:class:`.ApertureMacro` has no parameters since an + :py:class:`.ApertureMacro` is not an :py:class:`.Aperture` by itself. + """ + #: The :py:class:`.ApertureMacro` bound in this instance macro : object - parameters : [float] + #: The parameters to the :py:class:`.ApertureMacro`. All elements should be floats or ints. The first item in the + #: list is parameter ``$1``, the second is ``$2`` etc. + parameters : list + #: Aperture rotation in radians. When saving, a copy of the :py:class:`.ApertureMacro` is re-written with this + #: rotation. rotation : float = 0 @property - def gerber_shape_code(self): + def _gerber_shape_code(self): return self.macro.name - def primitives(self, x, y, unit=None, polarity_dark=True): + def _primitives(self, x, y, unit=None, polarity_dark=True): out = list(self.macro.to_graphic_primitives( offset=(x, y), rotation=self.rotation, parameters=self.parameters, unit=unit, polarity_dark=polarity_dark)) diff --git a/gerbonara/excellon.py b/gerbonara/excellon.py index 96a78be..a6e9566 100755 --- a/gerbonara/excellon.py +++ b/gerbonara/excellon.py @@ -268,7 +268,7 @@ class ExcellonFile(CamFile): # Build tool index tool_map = { id(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, id_tool[1].depth_offset)) + tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter)) tools = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) } # FIXME dedup tools @@ -526,7 +526,7 @@ class ExcellonParser(object): params = { m[0]: self.settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) } - self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated, + self.tools[index] = ExcellonTool(diameter=params.get('C'), plated=self.is_plated, unit=self.settings.unit) if set(params.keys()) == set('TFSC'): diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index 99f990f..1ab03dd 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -37,7 +37,7 @@ class GraphicObject: #: for features of an :py:class:`gerbonara.excellon.ExcellonFile`. polarity_dark : bool = True - #: :py:class:`gerbonara.utils.LengthUnit` used for all coordinate fields of this feature (such as `x` or `y`). + #: :py:class:`gerbonara.utils.LengthUnit` used for all coordinate fields of this object (such as ``x`` or ``y``). unit : str = None |