From a7f1f6ef0fdd9c792b3234931754dac5d81b15e5 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:05:57 -0500 Subject: Finish adding square hole support, fix some primitive calculations, etc. --- gerber/primitives.py | 232 ++++++++++++++++++++++++++++++--------------------- gerber/rs274x.py | 115 ++++++++++++++++++------- gerber/utils.py | 7 +- requirements.txt | 2 +- 4 files changed, 223 insertions(+), 133 deletions(-) diff --git a/gerber/primitives.py b/gerber/primitives.py index bd93e04..f583ca9 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -64,7 +64,6 @@ class Primitive(object): @property def flashed(self): '''Is this a flashed primitive''' - raise NotImplementedError('Is flashed must be ' 'implemented in subclass') @@ -271,9 +270,9 @@ class Line(Primitive): @property def vertices(self): if self._vertices is None: + start = self.start + end = self.end if isinstance(self.aperture, Rectangle): - start = self.start - end = self.end width = self.aperture.width height = self.aperture.height @@ -289,6 +288,11 @@ class Line(Primitive): # The line is defined by the convex hull of the points self._vertices = convex_hull((start_ll, start_lr, start_ul, start_ur, end_ll, end_lr, end_ul, end_ur)) + elif isinstance(self.aperture, Polygon): + points = [map(add, point, vertex) + for vertex in self.aperture.vertices + for point in (start, end)] + self._vertices = convex_hull(points) return self._vertices def offset(self, x_offset=0, y_offset=0): @@ -309,11 +313,18 @@ class Line(Primitive): return nearly_equal(self.start, equiv_start) and nearly_equal(self.end, equiv_end) + def __str__(self): + return "".format(self.start, self.end) + + def __repr__(self): + return str(self) + class Arc(Primitive): """ """ - def __init__(self, start, end, center, direction, aperture, quadrant_mode, **kwargs): + def __init__(self, start, end, center, direction, aperture, quadrant_mode, + **kwargs): super(Arc, self).__init__(**kwargs) self._start = start self._end = end @@ -371,15 +382,15 @@ class Arc(Primitive): @property def start_angle(self): - dy, dx = tuple([start - center for start, center + dx, dy = tuple([start - center for start, center in zip(self.start, self.center)]) - return math.atan2(dx, dy) + return math.atan2(dy, dx) @property def end_angle(self): - dy, dx = tuple([end - center for end, center + dx, dy = tuple([end - center for end, center in zip(self.end, self.center)]) - return math.atan2(dx, dy) + return math.atan2(dy, dx) @property def sweep_angle(self): @@ -399,77 +410,98 @@ class Arc(Primitive): theta0 = (self.start_angle + two_pi) % two_pi theta1 = (self.end_angle + two_pi) % two_pi points = [self.start, self.end] + if self.quadrant_mode == 'multi-quadrant': + if self.direction == 'counterclockwise': + # Passes through 0 degrees + if theta0 >= theta1: + points.append((self.center[0] + self.radius, self.center[1])) + # Passes through 90 degrees + if (((theta0 <= math.pi / 2.) and ((theta1 >= math.pi / 2.) or (theta1 <= theta0))) + or ((theta1 > math.pi / 2.) and (theta1 <= theta0))): + points.append((self.center[0], self.center[1] + self.radius)) + # Passes through 180 degrees + if ((theta0 <= math.pi and (theta1 >= math.pi or theta1 <= theta0)) + or ((theta1 > math.pi) and (theta1 <= theta0))): + points.append((self.center[0] - self.radius, self.center[1])) + # Passes through 270 degrees + if (theta0 <= math.pi * 1.5 and (theta1 >= math.pi * 1.5 or theta1 <= theta0) + or ((theta1 > math.pi * 1.5) and (theta1 <= theta0))): + points.append((self.center[0], self.center[1] - self.radius)) + else: + # Passes through 0 degrees + if theta1 >= theta0: + points.append((self.center[0] + self.radius, self.center[1])) + # Passes through 90 degrees + if (((theta1 <= math.pi / 2.) and (theta0 >= math.pi / 2. or theta0 <= theta1)) + or ((theta0 > math.pi / 2.) and (theta0 <= theta1))): + points.append((self.center[0], self.center[1] + self.radius)) + # Passes through 180 degrees + if (((theta1 <= math.pi) and (theta0 >= math.pi or theta0 <= theta1)) + or ((theta0 > math.pi) and (theta0 <= theta1))): + points.append((self.center[0] - self.radius, self.center[1])) + # Passes through 270 degrees + if (((theta1 <= math.pi * 1.5) and (theta0 >= math.pi * 1.5 or theta0 <= theta1)) + or ((theta0 > math.pi * 1.5) and (theta0 <= theta1))): + points.append((self.center[0], self.center[1] - self.radius)) + x, y = zip(*points) + if hasattr(self.aperture, 'radius'): + min_x = min(x) - self.aperture.radius + max_x = max(x) + self.aperture.radius + min_y = min(y) - self.aperture.radius + max_y = max(y) + self.aperture.radius + else: + min_x = min(x) - self.aperture.width + max_x = max(x) + self.aperture.width + min_y = min(y) - self.aperture.height + max_y = max(y) + self.aperture.height + + self._bounding_box = ((min_x, max_x), (min_y, max_y)) + return self._bounding_box + + @property + def bounding_box_no_aperture(self): + '''Gets the bounding box without considering the aperture''' + two_pi = 2 * math.pi + theta0 = (self.start_angle + two_pi) % two_pi + theta1 = (self.end_angle + two_pi) % two_pi + points = [self.start, self.end] + if self.quadrant_mode == 'multi-quadrant': if self.direction == 'counterclockwise': # Passes through 0 degrees - if theta0 > theta1: + if theta0 >= theta1: points.append((self.center[0] + self.radius, self.center[1])) # Passes through 90 degrees - if theta0 <= math.pi / \ - 2. and (theta1 >= math.pi / 2. or theta1 < theta0): + if (((theta0 <= math.pi / 2.) and ( + (theta1 >= math.pi / 2.) or (theta1 <= theta0))) + or ((theta1 > math.pi / 2.) and (theta1 <= theta0))): points.append((self.center[0], self.center[1] + self.radius)) # Passes through 180 degrees - if theta0 <= math.pi and (theta1 >= math.pi or theta1 < theta0): + if ((theta0 <= math.pi and (theta1 >= math.pi or theta1 <= theta0)) + or ((theta1 > math.pi) and (theta1 <= theta0))): points.append((self.center[0] - self.radius, self.center[1])) # Passes through 270 degrees - if theta0 <= math.pi * \ - 1.5 and (theta1 >= math.pi * 1.5 or theta1 < theta0): + if (theta0 <= math.pi * 1.5 and ( + theta1 >= math.pi * 1.5 or theta1 <= theta0) + or ((theta1 > math.pi * 1.5) and (theta1 <= theta0))): points.append((self.center[0], self.center[1] - self.radius)) else: # Passes through 0 degrees - if theta1 > theta0: + if theta1 >= theta0: points.append((self.center[0] + self.radius, self.center[1])) # Passes through 90 degrees - if theta1 <= math.pi / \ - 2. and (theta0 >= math.pi / 2. or theta0 < theta1): + if (((theta1 <= math.pi / 2.) and ( + theta0 >= math.pi / 2. or theta0 <= theta1)) + or ((theta0 > math.pi / 2.) and (theta0 <= theta1))): points.append((self.center[0], self.center[1] + self.radius)) # Passes through 180 degrees - if theta1 <= math.pi and (theta0 >= math.pi or theta0 < theta1): + if (((theta1 <= math.pi) and (theta0 >= math.pi or theta0 <= theta1)) + or ((theta0 > math.pi) and (theta0 <= theta1))): points.append((self.center[0] - self.radius, self.center[1])) # Passes through 270 degrees - if theta1 <= math.pi * \ - 1.5 and (theta0 >= math.pi * 1.5 or theta0 < theta1): + if (((theta1 <= math.pi * 1.5) and ( + theta0 >= math.pi * 1.5 or theta0 <= theta1)) + or ((theta0 > math.pi * 1.5) and (theta0 <= theta1))): points.append((self.center[0], self.center[1] - self.radius)) - x, y = zip(*points) - min_x = min(x) - self.aperture.radius - max_x = max(x) + self.aperture.radius - min_y = min(y) - self.aperture.radius - max_y = max(y) + self.aperture.radius - self._bounding_box = ((min_x, max_x), (min_y, max_y)) - return self._bounding_box - - @property - def bounding_box_no_aperture(self): - '''Gets the bounding box without considering the aperture''' - two_pi = 2 * math.pi - theta0 = (self.start_angle + two_pi) % two_pi - theta1 = (self.end_angle + two_pi) % two_pi - points = [self.start, self.end] - if self.direction == 'counterclockwise': - # Passes through 0 degrees - if theta0 > theta1: - points.append((self.center[0] + self.radius, self.center[1])) - # Passes through 90 degrees - if theta0 <= math.pi / 2. and (theta1 >= math.pi / 2. or theta1 < theta0): - points.append((self.center[0], self.center[1] + self.radius)) - # Passes through 180 degrees - if theta0 <= math.pi and (theta1 >= math.pi or theta1 < theta0): - points.append((self.center[0] - self.radius, self.center[1])) - # Passes through 270 degrees - if theta0 <= math.pi * 1.5 and (theta1 >= math.pi * 1.5 or theta1 < theta0): - points.append((self.center[0], self.center[1] - self.radius )) - else: - # Passes through 0 degrees - if theta1 > theta0: - points.append((self.center[0] + self.radius, self.center[1])) - # Passes through 90 degrees - if theta1 <= math.pi / 2. and (theta0 >= math.pi / 2. or theta0 < theta1): - points.append((self.center[0], self.center[1] + self.radius)) - # Passes through 180 degrees - if theta1 <= math.pi and (theta0 >= math.pi or theta0 < theta1): - points.append((self.center[0] - self.radius, self.center[1])) - # Passes through 270 degrees - if theta1 <= math.pi * 1.5 and (theta0 >= math.pi * 1.5 or theta0 < theta1): - points.append((self.center[0], self.center[1] - self.radius )) x, y = zip(*points) min_x = min(x) @@ -489,13 +521,16 @@ class Circle(Primitive): """ """ - def __init__(self, position, diameter, hole_diameter = None, **kwargs): + def __init__(self, position, diameter, hole_diameter=None, + hole_width=0, hole_height=0, **kwargs): super(Circle, self).__init__(**kwargs) validate_coordinates(position) self._position = position self._diameter = diameter self.hole_diameter = hole_diameter - self._to_convert = ['position', 'diameter', 'hole_diameter'] + self.hole_width = hole_width + self.hole_height = hole_height + self._to_convert = ['position', 'diameter', 'hole_diameter', 'hole_width', 'hole_height'] @property def flashed(self): @@ -631,14 +666,18 @@ class Rectangle(Primitive): then you don't need to worry about rotation """ - def __init__(self, position, width, height, hole_diameter=0, **kwargs): + def __init__(self, position, width, height, hole_diameter=0, + hole_width=0, hole_height=0, **kwargs): super(Rectangle, self).__init__(**kwargs) validate_coordinates(position) self._position = position self._width = width self._height = height self.hole_diameter = hole_diameter - self._to_convert = ['position', 'width', 'height', 'hole_diameter'] + self.hole_width = hole_width + self.hole_height = hole_height + self._to_convert = ['position', 'width', 'height', 'hole_diameter', + 'hole_width', 'hole_height'] # TODO These are probably wrong when rotated self._lower_left = None self._upper_right = None @@ -736,6 +775,12 @@ class Rectangle(Primitive): return nearly_equal(self.position, equiv_position) + def __str__(self): + return "".format(self.width, self.height, self.rotation * 180/math.pi) + + def __repr__(self): + return self.__str__() + class Diamond(Primitive): """ @@ -898,7 +943,8 @@ class ChamferRectangle(Primitive): ((self.position[0] - delta_w), (self.position[1] - delta_h)), ((self.position[0] + delta_w), (self.position[1] - delta_h)) ] - for idx, corner, chamfered in enumerate((rect_corners, self.corners)): + for idx, params in enumerate(zip(rect_corners, self.corners)): + corner, chamfered = params x, y = corner if chamfered: if idx == 0: @@ -1019,14 +1065,18 @@ class Obround(Primitive): """ """ - def __init__(self, position, width, height, hole_diameter=0, **kwargs): + def __init__(self, position, width, height, hole_diameter=0, + hole_width=0,hole_height=0, **kwargs): super(Obround, self).__init__(**kwargs) validate_coordinates(position) self._position = position self._width = width self._height = height self.hole_diameter = hole_diameter - self._to_convert = ['position', 'width', 'height', 'hole_diameter'] + self.hole_width = hole_width + self.hole_height = hole_height + self._to_convert = ['position', 'width', 'height', 'hole_diameter', + 'hole_width', 'hole_height' ] @property def flashed(self): @@ -1116,14 +1166,18 @@ class Polygon(Primitive): """ Polygon flash defined by a set number of sides. """ - def __init__(self, position, sides, radius, hole_diameter, **kwargs): + def __init__(self, position, sides, radius, hole_diameter=0, + hole_width=0, hole_height=0, **kwargs): super(Polygon, self).__init__(**kwargs) validate_coordinates(position) self._position = position self.sides = sides self._radius = radius self.hole_diameter = hole_diameter - self._to_convert = ['position', 'radius', 'hole_diameter'] + self.hole_width = hole_width + self.hole_height = hole_height + self._to_convert = ['position', 'radius', 'hole_diameter', + 'hole_width', 'hole_height'] @property def flashed(self): @@ -1174,25 +1228,14 @@ class Polygon(Primitive): def vertices(self): offset = self.rotation - da = 360.0 / self.sides + delta_angle = 360.0 / self.sides points = [] - for i in xrange(self.sides): - points.append(rotate_point((self.position[0] + self.radius, self.position[1]), offset + da * i, self.position)) - + for i in range(self.sides): + points.append( + rotate_point((self.position[0] + self.radius, self.position[1]), offset + delta_angle * i, self.position)) return points - @property - def vertices(self): - if self._vertices is None: - theta = math.radians(360/self.sides) - vertices = [(self.position[0] + (math.cos(theta * side) * self.radius), - self.position[1] + (math.sin(theta * side) * self.radius)) - for side in range(self.sides)] - self._vertices = [(((x * self._cos_theta) - (y * self._sin_theta)), - ((x * self._sin_theta) + (y * self._cos_theta))) - for x, y in vertices] - return self._vertices def equivalent(self, other, offset): """ @@ -1555,15 +1598,12 @@ class SquareRoundDonut(Primitive): class Drill(Primitive): """ A drill hole """ - def __init__(self, position, diameter, hit, **kwargs): + def __init__(self, position, diameter, **kwargs): super(Drill, self).__init__('dark', **kwargs) validate_coordinates(position) self._position = position self._diameter = diameter - self.hit = hit - self._to_convert = ['position', 'diameter', 'hit'] - - # TODO Ths won't handle the hit updates correctly + self._to_convert = ['position', 'diameter'] @property def flashed(self): @@ -1606,23 +1646,21 @@ class Drill(Primitive): self.position = tuple(map(add, self.position, (x_offset, y_offset))) def __str__(self): - return '' % (self.diameter, self.position[0], self.position[1], self.hit) + return '' % (self.diameter, self.units, self.position[0], self.position[1]) class Slot(Primitive): """ A drilled slot """ - def __init__(self, start, end, diameter, hit, **kwargs): + def __init__(self, start, end, diameter, **kwargs): super(Slot, self).__init__('dark', **kwargs) validate_coordinates(start) validate_coordinates(end) self.start = start self.end = end self.diameter = diameter - self.hit = hit - self._to_convert = ['start', 'end', 'diameter', 'hit'] + self._to_convert = ['start', 'end', 'diameter'] - # TODO this needs to use cached bounding box @property def flashed(self): @@ -1630,8 +1668,8 @@ class Slot(Primitive): def bounding_box(self): if self._bounding_box is None: - ll = tuple([c - self.outer_diameter / 2. for c in self.position]) - ur = tuple([c + self.outer_diameter / 2. for c in self.position]) + ll = tuple([c - self.diameter / 2. for c in self.position]) + ur = tuple([c + self.diameter / 2. for c in self.position]) self._bounding_box = ((ll[0], ur[0]), (ll[1], ur[1])) return self._bounding_box diff --git a/gerber/rs274x.py b/gerber/rs274x.py index ff8addd..5191fb7 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -514,32 +514,51 @@ class GerberParser(object): if shape == 'C': diameter = modifiers[0][0] - if len(modifiers[0]) >= 2: + hole_diameter = 0 + rectangular_hole = (0, 0) + if len(modifiers[0]) == 2: hole_diameter = modifiers[0][1] - else: - hole_diameter = None + elif len(modifiers[0]) == 3: + rectangular_hole = modifiers[0][1:3] + + aperture = Circle(position=None, diameter=diameter, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + units=self.settings.units) - aperture = Circle(position=None, diameter=diameter, hole_diameter=hole_diameter, units=self.settings.units) elif shape == 'R': width = modifiers[0][0] height = modifiers[0][1] - if len(modifiers[0]) >= 3: + hole_diameter = 0 + rectangular_hole = (0, 0) + if len(modifiers[0]) == 3: hole_diameter = modifiers[0][2] - else: - hole_diameter = None - - aperture = Rectangle(position=None, width=width, height=height, hole_diameter=hole_diameter, units=self.settings.units) + elif len(modifiers[0]) == 4: + rectangular_hole = modifiers[0][2:4] + + aperture = Rectangle(position=None, width=width, height=height, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + units=self.settings.units) elif shape == 'O': width = modifiers[0][0] height = modifiers[0][1] - if len(modifiers[0]) >= 3: + hole_diameter = 0 + rectangular_hole = (0, 0) + if len(modifiers[0]) == 3: hole_diameter = modifiers[0][2] - else: - hole_diameter = None - - aperture = Obround(position=None, width=width, height=height, hole_diameter=hole_diameter, units=self.settings.units) + elif len(modifiers[0]) == 4: + rectangular_hole = modifiers[0][2:4] + + aperture = Obround(position=None, width=width, height=height, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + units=self.settings.units) elif shape == 'P': outer_diameter = modifiers[0][0] number_vertices = int(modifiers[0][1]) @@ -548,11 +567,19 @@ class GerberParser(object): else: rotation = 0 - if len(modifiers[0]) > 3: + hole_diameter = 0 + rectangular_hole = (0, 0) + if len(modifiers[0]) == 4: hole_diameter = modifiers[0][3] - else: - hole_diameter = None - aperture = Polygon(position=None, sides=number_vertices, radius=outer_diameter/2.0, hole_diameter=hole_diameter, rotation=rotation) + elif len(modifiers[0]) >= 5: + rectangular_hole = modifiers[0][3:5] + + aperture = Polygon(position=None, sides=number_vertices, + radius=outer_diameter/2.0, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + rotation=rotation) else: aperture = self.macros[shape].build(modifiers) @@ -663,13 +690,18 @@ class GerberParser(object): quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units)) + # Gerbv seems to reset interpolation mode in regions.. + # TODO: Make sure this is right. + self.interpolation = 'linear' elif self.op == "D02" or self.op == "D2": if self.region_mode == "on": # D02 in the middle of a region finishes that region and starts a new one if self.current_region and len(self.current_region) > 1: - self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity, units=self.settings.units)) + self.primitives.append(Region(self.current_region, + level_polarity=self.level_polarity, + units=self.settings.units)) self.current_region = None elif self.op == "D03" or self.op == "D3": @@ -694,29 +726,53 @@ class GerberParser(object): def _find_center(self, start, end, offsets): """ - In single quadrant mode, the offsets are always positive, which means there are 4 possible centers. - The correct center is the only one that results in an arc with sweep angle of less than or equal to 90 degrees + In single quadrant mode, the offsets are always positive, which means + there are 4 possible centers. The correct center is the only one that + results in an arc with sweep angle of less than or equal to 90 degrees + in the specified direction """ - + two_pi = 2 * math.pi if self.quadrant_mode == 'single-quadrant': + # The Gerber spec says single quadrant only has one possible center, + # and you can detect it based on the angle. But for real files, this + # seems to work better - there is usually only one option that makes + # sense for the center (since the distance should be the same + # from start and end). We select the center with the least error in + # radius from all the options with a valid sweep angle. - # The Gerber spec says single quadrant only has one possible center, and you can detect - # based on the angle. But for real files, this seems to work better - there is usually - # only one option that makes sense for the center (since the distance should be the same - # from start and end). Find the center that makes the most sense sqdist_diff_min = sys.maxint center = None for factors in [(1, 1), (1, -1), (-1, 1), (-1, -1)]: - test_center = (start[0] + offsets[0] * factors[0], start[1] + offsets[1] * factors[1]) + test_center = (start[0] + offsets[0] * factors[0], + start[1] + offsets[1] * factors[1]) + + # Find angle from center to start and end points + start_angle = math.atan2(*reversed([_start - _center for _start, _center in zip(start, test_center)])) + end_angle = math.atan2(*reversed([_end - _center for _end, _center in zip(end, test_center)])) + # Clamp angles to 0, 2pi + theta0 = (start_angle + two_pi) % two_pi + theta1 = (end_angle + two_pi) % two_pi + + # Determine sweep angle in the current arc direction + if self.direction == 'counterclockwise': + sweep_angle = abs(theta1 - theta0) + else: + theta0 += two_pi + sweep_angle = abs(theta0 - theta1) % two_pi + + # Calculate the radius error sqdist_start = sq_distance(start, test_center) sqdist_end = sq_distance(end, test_center) - if abs(sqdist_start - sqdist_end) < sqdist_diff_min: + # Take the option with the lowest radius error from the set of + # options with a valid sweep angle + if ((abs(sqdist_start - sqdist_end) < sqdist_diff_min) + and (sweep_angle >= 0) + and (sweep_angle <= math.pi / 2.0)): center = test_center sqdist_diff_min = abs(sqdist_start - sqdist_end) - return center else: return (start[0] + offsets[0], start[1] + offsets[1]) @@ -724,7 +780,6 @@ class GerberParser(object): def _evaluate_aperture(self, stmt): self.aperture = stmt.d - def _match_one(expr, data): match = expr.match(data) if match is None: diff --git a/gerber/utils.py b/gerber/utils.py index c62ad2a..06adfd7 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -25,9 +25,7 @@ files. import os from math import radians, sin, cos -from operator import sub -from copy import deepcopy -from pyhull.convex_hull import ConvexHull +from scipy.spatial import ConvexHull MILLIMETERS_PER_INCH = 25.4 @@ -344,5 +342,4 @@ def listdir(directory, ignore_hidden=True, ignore_os=True): def convex_hull(points): vertices = ConvexHull(points).vertices - return [points[idx] for idx in - set([point for pair in vertices for point in pair])] + return [points[idx] for idx in vertices] diff --git a/requirements.txt b/requirements.txt index 014e92b..e049232 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ ## The following requirements were added by pip --freeze: cairocffi==0.6 -pyhull==1.5.6 +scipy -- cgit