summaryrefslogtreecommitdiff
path: root/gerbonara/utils.py
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara/utils.py')
-rw-r--r--gerbonara/utils.py256
1 files changed, 219 insertions, 37 deletions
diff --git a/gerbonara/utils.py b/gerbonara/utils.py
index 71251de..d1f9d61 100644
--- a/gerbonara/utils.py
+++ b/gerbonara/utils.py
@@ -30,9 +30,11 @@ from enum import Enum
from math import radians, sin, cos, sqrt, atan2, pi
class UnknownStatementWarning(Warning):
+ """ Gerbonara found an unknown Gerber or Excellon statement. """
pass
class RegexMatcher:
+ """ Internal parsing helper """
def __init__(self):
self.mapping = {}
@@ -51,13 +53,27 @@ class RegexMatcher:
else:
return False
+
class LengthUnit:
+ """ Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store lenght
+ information. Provides a number of useful unit conversion functions.
+
+ Singleton, use only global instances ``utils.MM`` and ``utils.Inch``.
+ """
+
def __init__(self, name, shorthand, this_in_mm):
self.name = name
self.shorthand = shorthand
self.factor = this_in_mm
def convert_from(self, unit, value):
+ """ Convert ``value`` from ``unit`` into this unit.
+
+ :param unit: ``MM``, ``Inch`` or one of the strings ``"mm"`` or ``"inch"``
+ :param float value:
+ :rtype: float
+ """
+
if isinstance(unit, str):
unit = units[unit]
@@ -67,6 +83,8 @@ class LengthUnit:
return value * unit.factor / self.factor
def convert_to(self, unit, value):
+ """ :py:meth:`.LengthUnit.convert_from` but in reverse. """
+
if isinstance(unit, str):
unit = to_unit(unit)
@@ -76,9 +94,17 @@ class LengthUnit:
return unit.convert_from(self, value)
def format(self, value):
+ """ Return a human-readdable string representing value in this unit.
+
+ :param float value:
+ :returns: something like "3mm"
+ :rtype: str
+ """
+
return f'{value:.3f}{self.shorthand}' if value is not None else ''
def __call__(self, value, unit):
+ """ Convenience alias for :py:meth:`.LengthUnit.convert_from` """
return self.convert_from(unit, value)
def __eq__(self, other):
@@ -105,12 +131,41 @@ MILLIMETERS_PER_INCH = 25.4
Inch = LengthUnit('inch', 'in', MILLIMETERS_PER_INCH)
MM = LengthUnit('millimeter', 'mm', 1)
units = {'inch': Inch, 'mm': MM, None: None}
-to_unit = lambda name: units[name.lower() if name else None]
+
+def _raise_error(*args, **kwargs):
+ raise SystemError('LengthUnit is a singleton. Use gerbonara.utils.MM or gerbonara.utils.Inch. Please do not invent '
+ 'your own length units, the imperial system is already messed up enough.')
+LengthUnit.__init__ = _raise_error
+
+def to_unit(name):
+ """ Convert string ``name`` into a registered length unit. Returns ``None`` if the argument cannot be converted.
+
+ :param str name: ``'mm'`` or ``'inch'``
+ :returns: ``MM``, ``Inch`` or ``None``
+ :rtype: :py:class:`.LengthUnit` or ``None``
+ """
+
+ if name is None:
+ return None
+
+ if isinstance(name, LengthUnit):
+ return name
+
+ if isinstance(name, str):
+ name = name.lower()
+ if name in units:
+ return units[name]
+
+ raise ValueError(f'Invalid unit {name!r}. Should be either "mm", "inch" or None for no unit.')
class InterpMode(Enum):
+ """ Gerber / Excellon interpolation mode. """
+ #: straight line
LINEAR = 0
+ #: clockwise circular arc
CIRCULAR_CW = 1
+ #: counterclockwise circular arc
CIRCULAR_CCW = 2
@@ -151,56 +206,53 @@ def decimal_string(value, precision=6, padding=False):
else:
return int(floatstr)
-def validate_coordinates(position):
- if position is not None:
- if len(position) != 2:
- raise TypeError('Position must be a tuple (n=2) of coordinates')
- else:
- for coord in position:
- if not (isinstance(coord, int) or isinstance(coord, float)):
- raise TypeError('Coordinates must be integers or floats')
-def rotate_point(point, angle, center=(0.0, 0.0)):
- """ Rotate a point about another point.
+def rotate_point(x, y, angle, cx=0, cy=0):
+ """ Rotate point (x,y) around (cx,cy) by ``angle`` radians clockwise. """
- Parameters
- -----------
- point : tuple(<float>, <float>)
- Point to rotate about origin or center point
+ return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle),
+ cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle))
- angle : float
- Angle to rotate the point [degrees]
- center : tuple(<float>, <float>)
- Coordinates about which the point is rotated. Defaults to the origin.
+def min_none(a, b):
+ """ Like the ``min(..)`` builtin, but if either value is ``None``, returns the other. """
+ if a is None:
+ return b
+ if b is None:
+ return a
+ return min(a, b)
- Returns
- -------
- rotated_point : tuple(<float>, <float>)
- `point` rotated about `center` by `angle` degrees.
- """
- angle = radians(angle)
- cos_angle = cos(angle)
- sin_angle = sin(angle)
+def max_none(a, b):
+ """ Like the ``max(..)`` builtin, but if either value is ``None``, returns the other. """
+ if a is None:
+ return b
+ if b is None:
+ return a
+ return max(a, b)
- return (
- cos_angle * (point[0] - center[0]) - sin_angle * (point[1] - center[1]) + center[0],
- sin_angle * (point[0] - center[0]) + cos_angle * (point[1] - center[1]) + center[1])
-def nearly_equal(point1, point2, ndigits = 6):
- '''Are the points nearly equal'''
+def add_bounds(b1, b2):
+ """ Add/union two bounding boxes.
- return round(point1[0] - point2[0], ndigits) == 0 and round(point1[1] - point2[1], ndigits) == 0
+ :param tuple b1: ``((min_x, min_y), (max_x, max_y))``
+ :param tuple b2: ``((min_x, min_y), (max_x, max_y))``
+ :returns: ``((min_x, min_y), (max_x, max_y))``
+ :rtype: tuple
+ """
-def sq_distance(point1, point2):
+ (min_x_1, min_y_1), (max_x_1, max_y_1) = b1
+ (min_x_2, min_y_2), (max_x_2, max_y_2) = b2
+ min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2)
+ max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2)
+ return ((min_x, min_y), (max_x, max_y))
- diff1 = point1[0] - point2[0]
- diff2 = point1[1] - point2[1]
- return diff1 * diff1 + diff2 * diff2
class Tag:
+ """ Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your
+ own implementation by passing a ``tag`` parameter. """
+
def __init__(self, name, children=None, root=False, **attrs):
self.name, self.attrs = name, attrs
self.children = children or []
@@ -216,3 +268,133 @@ class Tag:
return f'{prefix}<{opening}/>'
+def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
+ """ Calculate bounding box of a circular arc given in Gerber notation (i.e. with center relative to first point).
+
+ :returns: ``((x_min, y_min), (x_max, y_max))``
+ """
+ # This is one of these problems typical for computer geometry where out of nowhere a seemingly simple task just
+ # happens to be anything but in practice.
+ #
+ # Online there are a number of algorithms to be found solving this problem. Often, they solve the more general
+ # problem for elliptic arcs. We can keep things simple here since we only have circular arcs.
+ #
+ # This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus
+ # sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2).
+ #
+ # cx, cy are relative to p1.
+
+ # Center arc on cx, cy
+ cx += x1
+ cy += y1
+ x1 -= cx
+ x2 -= cx
+ y1 -= cy
+ y2 -= cy
+ clockwise = bool(clockwise) # bool'ify for XOR/XNOR below
+
+ # Calculate radius
+ r = math.sqrt(x1**2 + y1**2)
+
+ # Calculate in which half-planes (north/south, west/east) P1 and P2 lie.
+ # Note that we assume the y axis points upwards, as in Gerber and maths.
+ # SVG has its y axis pointing downwards.
+ p1_west = x1 < 0
+ p1_north = y1 > 0
+ p2_west = x2 < 0
+ p2_north = y2 > 0
+
+ # Calculate bounding box of P1 and P2
+ min_x = min(x1, x2)
+ min_y = min(y1, y2)
+ max_x = max(x1, x2)
+ max_y = max(y1, y2)
+
+ # North
+ # ^
+ # |
+ # |(0,0)
+ # West <-----X-----> East
+ # |
+ # +Y |
+ # ^ v
+ # | South
+ # |
+ # +-----> +X
+ #
+ # Check whether the arc sweeps over any coordinate axes. If it does, add the intersection point to the bounding box.
+ # Note that, since this intersection point is at radius r, it has coordinate e.g. (0, r) for the north intersection.
+ # Since we know that the points lie on either side of the coordinate axis, the '0' coordinate of the intersection
+ # point will not change the bounding box in that axis--only its 'r' coordinate matters. We also know that the
+ # absolute value of that coordinate will be greater than or equal to the old coordinate in that direction since the
+ # intersection with the axis is the point where the full circle is tangent to the AABB. Thus, we can blindly set the
+ # corresponding coordinate of the bounding box without min()/max()'ing first.
+
+ # Handle north/south halfplanes
+ if p1_west != p2_west: # arc starts in west half-plane, ends in east half-plane
+ if p1_west == clockwise: # arc is clockwise west -> east or counter-clockwise east -> west
+ max_y = r # add north to bounding box
+ else: # arc is counter-clockwise west -> east or clockwise east -> west
+ min_y = -r # south
+ else: # Arc starts and ends in same halfplane west/east
+ # Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for
+ # angle comparisons.
+ small_arc_is_north_to_south = y1 > y2
+ small_arc_is_clockwise = small_arc_is_north_to_south == p1_west
+ if small_arc_is_clockwise != clockwise:
+ min_y, max_y = -r, r # intersect aabb with both north and south
+
+ # Handle west/east halfplanes
+ if p1_north != p2_north:
+ if p1_north == clockwise:
+ max_x = r # east
+ else:
+ min_x = -r # west
+ else:
+ small_arc_is_west_to_east = x1 < x2
+ small_arc_is_clockwise = small_arc_is_west_to_east == p1_north
+ if small_arc_is_clockwise != clockwise:
+ min_x, max_x = -r, r # intersect aabb with both north and south
+
+ return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)
+
+
+def point_line_distance(l1, l2, p):
+ """ Calculate distance between infinite line through l1 and l2, and point p. """
+ # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
+ x1, y1 = l1
+ x2, y2 = l2
+ x0, y0 = p
+ length = math.dist(l1, l2)
+ if math.isclose(length, 0):
+ return math.dist(l1, p)
+ return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
+
+
+def svg_arc(old, new, center, clockwise):
+ """ Format an SVG circular arc "A" path data entry given an arc in Gerber notation (i.e. with center relative to
+ first point).
+
+ :rtype: str
+ """
+ r = math.hypot(*center)
+ # invert sweep flag since the svg y axis is mirrored
+ sweep_flag = int(not clockwise)
+ # In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
+ # in SVG, we have to split it into two.
+ if math.isclose(math.dist(old, new), 0):
+ intermediate = old[0] + 2*center[0], old[1] + 2*center[1]
+ # Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of
+ # a circular cutin
+ return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {intermediate[0]:.6} {intermediate[1]:.6} ' +\
+ f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}'
+
+ else: # normal case
+ d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
+ large_arc = int((d < 0) == clockwise)
+ return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
+
+
+def svg_rotation(angle_rad, cx=0, cy=0):
+ return f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'
+