From 7473e471dc69d09a35bb0762549cc4f3ab8b04b3 Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 1 Feb 2022 22:08:54 +0100 Subject: Add some documentation --- gerbonara/graphic_objects.py | 298 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 254 insertions(+), 44 deletions(-) (limited to 'gerbonara/graphic_objects.py') diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index 1f475a6..99f990f 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -1,8 +1,9 @@ import math +import copy from dataclasses import dataclass, KW_ONLY, astuple, replace, field, fields -from .utils import MM, InterpMode +from .utils import MM, InterpMode, to_unit from . import graphic_primitives as gp @@ -18,27 +19,94 @@ class Length: def __init__(self, obj_type): self.type = obj_type + def __repr__(self): + # This makes the automatically generated method signatures in the Sphinx docs look nice + return 'float' + @dataclass -class GerberObject: +class GraphicObject: + """ Base class for the graphic objects that make up a :py:class:`gerbonara.rs274x.GerberFile` or + :py:class:`gerbonara.excellon.ExcellonFile`. """ _ : KW_ONLY + + #: bool representing the *color* of this feature: whether this is a *dark* or *clear* feature. Clear and dark are + #: meant in the sense that they are used in the Gerber spec and refer to whether the transparency film that this + #: file describes ends up black or clear at this spot. In a standard green PCB, a *polarity_dark=True* line will + #: show up as copper on the copper layer, white ink on the silkscreen layer, or an opening on the soldermask layer. + #: Clear features erase dark features, they are not transparent in the colloquial meaning. This property is ignored + #: 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`). unit : str = None + + + #: `dict` containing GerberX2 attributes attached to this feature. Note that this does not include file attributes, + #: which are stored in the :py:class:`gerbonara.rs274x.GerberFile` object instead. attrs : dict = field(default_factory=dict) def converted(self, unit): - return replace(self, - **{ f.name: self.unit.convert_to(unit, getattr(self, f.name)) - for f in fields(self) if type(f.type) is Length }) + """ Convert this gerber object to another :py:class:`gerbonara.utils.LengthUnit`. + + :param unit: Either a :py:class:`gerbonara.utils.LengthUnit` instance or one of the strings ``'mm'`` or ``'inch'``. + + :returns: A copy of this object using the new unit. + """ + copy = copy.copy(self) + copy.convert_to(unit) + + def convert_to(self, unit): + """ Convert this gerber object to another :py:class:`gerbonara.utils.LengthUnit` in-place. + + :param unit: Either a :py:class:`gerbonara.utils.LengthUnit` instance or one of the strings ``'mm'`` or ``'inch'``. + """ + + for f in fields(self): + if type(f.type) is Length: + setattr(self, f.name, self.unit.convert_to(unit, getattr(self, f.name))) + + self.unit = to_unit(unit) + + def offset(self, dx, dy, unit=MM): + """ Add an offset to the location of this feature. The location can be given in either unit, and is + automatically converted into this object's local unit. + + :param float dx: X offset, positive values move the object right. + :param float dy: Y offset, positive values move the object up. This is the opposite of the normal screen + coordinate system used in SVG and other computer graphics APIs. + """ - def with_offset(self, dx, dy, unit=MM): dx, dy = self.unit(dx, unit), self.unit(dy, unit) - return self._with_offset(dx, dy) + self._offset(dx, dy) def rotate(self, rotation, cx=0, cy=0, unit=MM): + """ Rotate this object. The center of rotation can be given in either unit, and is automatically converted into + this object's local unit. + + .. note:: The center's Y coordinate as well as the angle's polarity are flipped compared to computer graphics + convention since Gerber uses a bottom-to-top Y axis. + + :param float rotation: rotation in radians clockwise. + :param float cx: X coordinate of center of rotation in *unit* units. + :param float cy: Y coordinate of center of rotation. (0,0) is at the bottom left of the image. + :param unit: :py:class:`gerbonara.utils.LengthUnit` or str with unit for *cx* and *cy* + """ + cx, cy = self.unit(cx, unit), self.unit(cy, unit) self._rotate(rotation, cx, cy) def bounding_box(self, unit=None): + """ Return axis-aligned bounding box of this object in given unit. If no unit is given, return the bounding box + in the object's local unit (``self.unit``). + + .. note:: This method returns bounding boxes in a different format than legacy pcb-tools_, which used + ``(min_x, max_x), (min_y, max_y)`` + + :param unit: :py:class:`gerbonara.utils.LengthUnit` or str with unit for return value. + + :returns: tuple of tuples of floats: ``(min_x, min_y), (max_x, max_y)`` + """ + bboxes = [ p.bounding_box() for p in self.to_primitives(unit) ] min_x = min(min_x for (min_x, _min_y), _ in bboxes) min_y = min(min_y for (_min_x, min_y), _ in bboxes) @@ -47,16 +115,62 @@ class GerberObject: return ((min_x, min_y), (max_x, max_y)) def to_primitives(self, unit=None): - raise NotImplementedError() + """ Render this object into low-level graphical primitives (subclasses of :py:class:`GraphicPrimitive`). This + computes out all coordinates in case aperture macros are involved, and resolves units. The output primitives are + converted into the given unit, and will be stripped of unit information. If no unit is given, use this object's + native unit (``self.unit``). + + :param unit: :py:class:`gerbonara.utils.LengthUnit` or str with unit for return value. + + :rtype: Iterator[:py:class:`GraphicPrimitive`] + """ + return self._to_primitives(unit) + + def _to_statements(self, gs): + """ Serialize this object into Gerber statements. + + :param gs: :py:class:`rs274x.GraphicsState` object containing current Gerber state (polarity, selected aperture, + interpolation mode etc.). + + :returns: Iterator yielding one string per line of output Gerber + :rtype: Iterator[str] + """ + self._to_statements(gs) + + def _to_xnc(self, ctx): + """ Serialize this object into XNC Excellon statements. + + :param ctx: :py:class:`excellon.ExcellonContext` object containing current Excellon state (selected tool, + interpolation mode etc.). + + :returns: Iterator yielding one string per line of output XNC code + :rtype: Iterator[str] + """ + self._to_xnc(ctx) + @dataclass -class Flash(GerberObject): +class Flash(GraphicObject): + """ A flash is what happens when you "stamp" a Gerber aperture at some location. The :py:attr:`polarity_dark` + attribute that Flash inherits from :py:class:`GraphicObject` is ``True`` for normal flashes. If you set a Flash's + ``polarity_dark`` to ``False``, you invert the polarity of all of its features. + + Flashes are also used to represent drilled holes in an :py:class:`gerbonara.excellon.ExcellonFile`. In this case, + :py:attr:`aperture` should be an instance of :py:class:`ExcellonTool`. + """ + + #: float with X coordinate of the center of this flash. x : Length(float) + + #: float with Y coordinate of the center of this flash. y : Length(float) + + #: Flashed Aperture. must be a subclass of :py:class:`Aperture`. aperture : object @property def tool(self): + """ Alias for :py:attr:`aperture` for use inside an :py:class:`gerbonara.excellon.ExcellonFile`. """ return self.aperture @tool.setter @@ -65,19 +179,23 @@ class Flash(GerberObject): @property def plated(self): - return self.tool.plated + """ (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None`` + (plating undefined) + """ + return getattr(self.tool, 'plated', None) - def _with_offset(self, dx, dy): - return replace(self, x=self.x+dx, y=self.y+dy) + def __offset(self, dx, dy): + self.x += dx + self.y += dy def _rotate(self, rotation, cx=0, cy=0): self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy) - def to_primitives(self, unit=None): + def _to_primitives(self, unit=None): conv = self.converted(unit) yield from self.aperture.flash(conv.x, conv.y, unit, self.polarity_dark) - def to_statements(self, gs): + def _to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) yield from gs.set_aperture(self.aperture) @@ -87,7 +205,7 @@ class Flash(GerberObject): gs.update_point(self.x, self.y, unit=self.unit) - def to_xnc(self, ctx): + def _to_xnc(self, ctx): yield from ctx.select_tool(self.tool) yield from ctx.drill_mode() @@ -97,11 +215,31 @@ class Flash(GerberObject): ctx.set_current_point(self.unit, self.x, self.y) + # internally used to compute Excellon file path length def curve_length(self, unit=MM): return 0 -class Region(GerberObject): +class Region(GraphicObject): + """ Gerber "region", roughly equivalent to what in computer graphics you would call a polygon. A region is a single + filled area defined by a list of coordinates on its contour. A region's polarity is its "fill". A region does not + have a "stroke", and thus does not have an `aperture` field. Note that regions are a strict subset of what modern + computer graphics considers a polygon or path. Be careful when converting shapes from somewhere else into Gerber + regions. For arbitrary shapes (e.g. SVG paths) this is non-trivial, and I recommend you hava look at Gerbolyze_ / + svg-flatten_. Here's a list of special features of Gerber regions: + + * A region's outline consists of straigt line segments and circular arcs and must always be closed. + * A region is always exactly one connected component. + * A region must not overlap itself anywhere. + * A region cannot have holes. + + There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a + cut-in, the region is allowed to touch (but never overlap!) itself. + + :attr poly: :py:class:`graphic_primitives.ArcPoly` describing the actual outline of this Region. The coordinates of + this poly are in the unit of this instance's :py:attr:`unit` field. + """ + def __init__(self, outline=None, arc_centers=None, *, unit, polarity_dark): super().__init__(unit=unit, polarity_dark=polarity_dark) outline = [] if outline is None else outline @@ -114,11 +252,8 @@ class Region(GerberObject): def __bool__(self): return bool(self.poly) - def _with_offset(self, dx, dy): - return Region([ (x+dx, y+dy) for x, y in self.poly.outline ], - self.poly.arc_centers, - polarity_dark=self.polarity_dark, - unit=self.unit) + def _offset(self, dx, dy): + self.poly.outline = [ (x+dx, y+dy) for x, y in self.poly.outline ] def _rotate(self, angle, cx=0, cy=0): self.poly.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.poly.outline ] @@ -138,7 +273,7 @@ class Region(GerberObject): else: self.poly.arc_centers.append(None) - def to_primitives(self, unit=None): + def _to_primitives(self, unit=None): self.poly.polarity_dark = self.polarity_dark # FIXME: is this the right spot to do this? if unit == self.unit: yield self.poly @@ -188,17 +323,37 @@ class Region(GerberObject): yield 'G37*' @dataclass -class Line(GerberObject): - # Line with *round* end caps. +class Line(GraphicObject): + """ A line is what happens when you "drag" a Gerber :py:class:`Aperture` from one point to another. Note that Gerber + lines are substantially funkier than normal lines as we know them from modern computer graphics such as SVG. A + Gerber line is defined as the area that is covered when you drag its aperture along. This means that for a + rectangular aperture, a horizontal line and a vertical line using the same aperture will have different widths. + + .. warning:: Try to only ever use :py:class:`CircleAperture` with :py:class:`Line` and :py:class:`Arc` since other + aperture types are not widely supported by renderers / photoplotters even though they are part of the + spec. + + .. note:: If you manipulate a :py:class:`Line`, it is okay to assume that it has round end caps and a defined width + as exceptions are really rare. + """ + #: X coordinate of start point x1 : Length(float) + #: Y coordinate of start point y1 : Length(float) + #: X coordinate of end point x2 : Length(float) + #: Y coordinate of end point y2 : Length(float) + #: Aperture for this line. Should be a subclass of :py:class:`CircleAperture`, whose diameter determines the line + #: width. aperture : object - def _with_offset(self, dx, dy): - return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) + def _offset(self, dx, dy): + self.x1 += dx + self.y1 += dy + self.x2 += dx + self.y2 += dy def _rotate(self, rotation, cx=0, cy=0): self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy) @@ -206,18 +361,17 @@ class Line(GerberObject): @property def p1(self): + """ Convenience alias for ``(self.x1, self.y1)`` returning start point of the line. """ return self.x1, self.y1 @property def p2(self): + """ Convenience alias for ``(self.x2, self.y2)`` returning end point of the line. """ return self.x2, self.y2 - @property - def end_point(self): - return self.p2 - @property def tool(self): + """ Alias for :py:attr:`aperture` for use inside an :py:class:`gerbonara.excellon.ExcellonFile`. """ return self.aperture @tool.setter @@ -226,14 +380,17 @@ class Line(GerberObject): @property def plated(self): + """ (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None`` + (plating undefined) + """ return self.tool.plated - def to_primitives(self, unit=None): + def _to_primitives(self, unit=None): conv = self.converted(unit) w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging yield gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark) - def to_statements(self, gs): + def _to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) yield from gs.set_aperture(self.aperture) yield from gs.set_interpolation_mode(InterpMode.LINEAR) @@ -245,7 +402,7 @@ class Line(GerberObject): gs.update_point(*self.p2, unit=self.unit) - def to_xnc(self, ctx): + def _to_xnc(self, ctx): yield from ctx.select_tool(self.tool) yield from ctx.route_mode(self.unit, *self.p1) @@ -255,26 +412,61 @@ class Line(GerberObject): ctx.set_current_point(self.unit, *self.p2) + # internally used to compute Excellon file path length def curve_length(self, unit=MM): return self.unit.convert_to(unit, math.dist(self.p1, self.p2)) @dataclass -class Arc(GerberObject): +class Arc(GraphicObject): + """ Like :py:class:`Line`, but a circular arc. Has start ``(x1, y1)`` and end ``(x2, y2)`` attributes like a + :py:class:`Line`, but additionally has a center ``(cx, cy)`` specified relative to the start point ``(x1, y1)``, as + well as a ``clockwise`` attribute indicating the arc's direction. + + .. note:: The same warning on apertures that applies to :py:class:`Line` applies to :py:class:`Arc`, too. + + .. warning:: When creating your own circles, you have to take care yourself that the center is actually the center + of a circle that goes through both (x1,y1) and (x2,y2). Elliptical arcs are *not* supported by either + us or the Gerber standard. + """ + #: X coordinate of start point x1 : Length(float) + #: Y coordinate of start point y1 : Length(float) + #: X coordinate of end point x2 : Length(float) + #: Y coordinate of end point y2 : Length(float) - # relative to (x1, x2) + #: X coordinate of arc center relative to ``x1`` cx : Length(float) + #: Y coordinate of arc center relative to ``x1`` cy : Length(float) + #: Direction of arc. ``True`` means clockwise. For a given center coordinate and endpoints there are always two + #: possible arcs, the large one and the small one. Flipping this switches between them. clockwise : bool + #: Aperture for this arc. Should be a subclass of :py:class:`CircleAperture`, whose diameter determines the line + #: width. aperture : object - def _with_offset(self, dx, dy): - return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) + def _offset(self, dx, dy): + self.x1 += dx + self.y1 += dy + self.x2 += dx + self.y2 += dy def numeric_error(self, unit=None): + """ Gerber arcs are sligtly over-determined. Since we have not just a radius, but center X and Y coordinates, an + "impossible" arc can be specified, where the start and end points do not lie on a circle around its center. This + function returns the absolute difference between the two radii (start - center) and (end - center) as an + indication on how bad this arc is. + + .. note:: For arcs read from a Gerber file, this value can easily be in the order of magnitude of 1e-4. Gerber + files have very limited numerical resolution, and rounding errors will necessarily lead to numerical + accuracy issues with arcs. + + :rtype: float + """ + # This function is used internally to determine the right arc in multi-quadrant mode conv = self.converted(unit) cx, cy = conv.cx + conv.x1, conv.cy + conv.y1 r1 = math.dist((cx, cy), conv.p1) @@ -282,6 +474,11 @@ class Arc(GerberObject): return abs(r1 - r2) def sweep_angle(self): + """ Calculate absolute sweep angle of arc. This is always a positive number. + + :returns: Angle in clockwise radian between ``0`` and ``2*math.pi`` + :rtype: float + """ cx, cy = self.cx + self.x1, self.cy + self.y1 x1, y1 = self.x1 - cx, self.y1 - cy x2, y2 = self.x2 - cx, self.y2 - cy @@ -301,26 +498,35 @@ class Arc(GerberObject): @property def p1(self): + """ Convenience alias for ``(self.x1, self.y1)`` returning start point of the arc. """ return self.x1, self.y1 @property def p2(self): + """ Convenience alias for ``(self.x2, self.y2)`` returning end point of the arc. """ return self.x2, self.y2 @property def center(self): + """ Returns the center of the arc in **absolute** coordinates. + + :returns: ``(self.x1 + self.cx, self.y1 + self.cy)`` + :rtype: tuple(float) + """ return self.cx + self.x1, self.cy + self.y1 @property def center_relative(self): - return self.cx, self.cy + """ Returns the center of the arc in relative coordinates. - @property - def end_point(self): - return self.p2 + :returns: ``(self.cx, self.cy)`` + :rtype: tuple(float) + """ + return self.cx, self.cy @property def tool(self): + """ Alias for :py:attr:`aperture` for use inside an :py:class:`gerbonara.excellon.ExcellonFile`. """ return self.aperture @tool.setter @@ -329,6 +535,9 @@ class Arc(GerberObject): @property def plated(self): + """ (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None`` + (plating undefined) + """ return self.tool.plated def _rotate(self, rotation, cx=0, cy=0): @@ -338,7 +547,7 @@ class Arc(GerberObject): self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy) self.cx, self.cy = new_cx - self.x1, new_cy - self.y1 - def to_primitives(self, unit=None): + def _to_primitives(self, unit=None): conv = self.converted(unit) w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging yield gp.Arc(x1=conv.x1, y1=conv.y1, @@ -348,7 +557,7 @@ class Arc(GerberObject): width=w, polarity_dark=self.polarity_dark) - def to_statements(self, gs): + def _to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) yield from gs.set_aperture(self.aperture) # TODO is the following line correct? @@ -363,7 +572,7 @@ class Arc(GerberObject): gs.update_point(*self.p2, unit=self.unit) - def to_xnc(self, ctx): + def _to_xnc(self, ctx): yield from ctx.select_tool(self.tool) yield from ctx.route_mode(self.unit, self.x1, self.y1) code = 'G02' if self.clockwise else 'G03' @@ -376,6 +585,7 @@ class Arc(GerberObject): ctx.set_current_point(self.unit, self.x2, self.y2) + # internally used to compute Excellon file path length def curve_length(self, unit=MM): return self.unit.convert_to(unit, math.hypot(self.cx, self.cy) * self.sweep_angle) -- cgit