diff options
-rw-r--r-- | gerbonara/aperture_macros/primitive.py | 2 | ||||
-rw-r--r-- | gerbonara/apertures.py | 4 | ||||
-rw-r--r-- | gerbonara/cad/kicad/footprints.py | 57 | ||||
-rw-r--r-- | gerbonara/graphic_objects.py | 10 | ||||
-rw-r--r-- | gerbonara/layers.py | 11 | ||||
-rw-r--r-- | gerbonara/tests/test_kicad_footprints.py | 27 |
6 files changed, 81 insertions, 30 deletions
diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py index 8d4bf4f..25c9bd1 100644 --- a/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -242,7 +242,7 @@ class Outline(Primitive): code = 4 def __init__(self, unit, args): - if len(args) < 11: + if len(args) < 10: raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).') if len(args) > 5004: raise ValueError(f'Invalid aperture macro outline primitive, too many points ({len(args)//2-2}).') diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index 415eeee..feccb7f 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -387,14 +387,14 @@ class ObroundAperture(Aperture): if self.w > self.h: inst = self else: - inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=rotation+self.rotation-90) + inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=self.rotation-math.pi/2) return ApertureMacroInstance(GenericMacros.obround, [MM(inst.w, self.unit), MM(inst.h, self.unit), MM(inst.hole_dia, self.unit) or 0, MM(inst.hole_rect_h, self.unit) or 0, - inst.rotation]) + inst.rotation + rotation]) def _params(self, unit=None): return _strip_right( diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index 13cad7d..4066384 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -400,14 +400,14 @@ class Pad: return ap.CircleAperture(self.size.x+2*margin, unit=MM) elif self.shape == Atom.rect: - if margin: + if margin > 0: return ap.ApertureMacroInstance(GenericMacros.rounded_rect, [self.size.x+2*margin, self.size.y+2*margin, margin, 0, 0, # no hole rotation], unit=MM) else: - return ap.RectangleAperture(self.size.x, self.size.y, unit=MM).rotated(rotation) + return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation) elif self.shape == Atom.oval: return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation) @@ -428,11 +428,12 @@ class Pad: dy = dx rotation -= math.pi/2 - if not margin: + if margin <= 0: # Note: KiCad already uses MM units, so no conversion needed here. + alpha = math.atan(y / (dy/2)) return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid, - [x+dy, y, + [x+dy+margin*math.cos(alpha), y+margin, dy, 0, 0, # no hole rotation], unit=MM) @@ -447,26 +448,54 @@ class Pad: elif self.shape == Atom.roundrect: x, y = self.size.x, self.size.y r = min(x, y) * self.roundrect_rratio - return ap.ApertureMacroInstance(GenericMacros.rounded_rect, - [x+2*margin, y+2*margin, - r+margin, - 0, 0, # no hole - rotation], unit=MM) + if margin > -r: + return ap.ApertureMacroInstance(GenericMacros.rounded_rect, + [x+2*margin, y+2*margin, + r+margin, + 0, 0, # no hole + rotation], unit=MM) + else: + return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(rotation) elif self.shape == Atom.custom: primitives = [] + # One round trip through the Gerbonara APIs, please! for obj in self.primitives.all(): for gn_obj in obj.render(): - primitives += gn_obj._aperture_macro_primitives() # todo: precision params + if margin and isinstance(gn_obj, (go.Line, go.Arc)): + gn_obj = gn_obj.dilated(margin) + + if isinstance(gn_obj, go.Region) and margin > 0: + for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)): + primitives += line._aperture_macro_primitives() + + new_primitives = list(gn_obj._aperture_macro_primitives()) # todo: precision params + primitives += new_primitives + + # inexact, only works with convex shapes. But whatever, the only other way to do this would require + # an entire polygon clipping/offsetting library. Probably a bad choice to put something this complex + # into a file format. + if isinstance(gn_obj, go.Region) and margin < 0: + for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)): + line.polarity_dark = False + primitives += line._aperture_macro_primitives() if self.options: if self.options.anchor == Atom.rect and self.size.x > 0 and self.size.y > 0: - primitives.append(amp.CenterLine(MM, [1, self.size.x, self.size.y, 0, 0, 0])) + if margin <= 0: + primitives.append(amp.CenterLine(MM, [1, self.size.x+2*margin, self.size.y+2*margin, 0, 0, 0])) - elif self.options.anchor == Atom.circle and self.size.x > 0: - primitives.append(amp.Circle(MM, [1, self.size.x, 0, 0, 0])) + else: # margin > 0 + primitives.append(amp.CenterLine(MM, [1, self.size.x+2*margin, self.size.y, 0, 0, 0])) + primitives.append(amp.CenterLine(MM, [1, self.size.x, self.size.y+2*margin, 0, 0, 0])) + primitives.append(amp.Circle(MM, [1, 2*margin, -self.size.x/2, -self.size.y/2])) + primitives.append(amp.Circle(MM, [1, 2*margin, -self.size.x/2, +self.size.y/2])) + primitives.append(amp.Circle(MM, [1, 2*margin, +self.size.x/2, -self.size.y/2])) + primitives.append(amp.Circle(MM, [1, 2*margin, +self.size.x/2, +self.size.y/2])) + elif self.options.anchor == Atom.circle and self.size.x > 0: + primitives.append(amp.Circle(MM, [1, self.size.x+2*margin, 0, 0, 0])) macro = ApertureMacro(primitives=primitives).rotated(rotation) return ap.ApertureMacroInstance(macro, unit=MM) @@ -686,6 +715,8 @@ LAYER_MAP_K2G = { 'F.CrtYd': ('top', 'courtyard'), 'B.Fab': ('bottom', 'fabrication'), 'F.Fab': ('top', 'fabrication'), + 'B.Adhes': ('bottom', 'adhesive'), + 'F.Adhes': ('top', 'adhesive'), 'Dwgs.User': ('mechanical', 'drawings'), 'Cmts.User': ('mechanical', 'comments'), 'Edge.Cuts': ('mechanical', 'outline'), diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index b9217bb..432a988 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -295,6 +295,9 @@ class Region(GraphicObject): def __bool__(self): return bool(self.outline) + def __str__(self): + return f'<Region with {len(self.outline)} points and {sum(1 if c else 0 for c in self.arc_centers)} arc segments at {hex(id(self))}' + def _offset(self, dx, dy): self.outline = [ (x+dx, y+dy) for x, y in self.outline ] @@ -340,7 +343,7 @@ class Region(GraphicObject): self.outline.append(self.outline[0]) def outline_objects(self, aperture=None): - for p1, p2, arc in zip_longest(self.outline[:-1], self.outline[1:], self.arc_centers): + for p1, p2, arc in zip_longest(self.outline, self.outline[1:] + self.outline[:1], self.arc_centers): if arc: clockwise, pc = arc yield Arc(*p1, *p2, *pc, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark) @@ -350,7 +353,7 @@ class Region(GraphicObject): def _aperture_macro_primitives(self, max_error=1e-2, unit=MM): # unit is only for max_error, the resulting primitives will always be in MM - if len(self.outline) < 3: + if len(self.outline) < 2: return points = [self.outline[0]] @@ -361,7 +364,7 @@ class Region(GraphicObject): #d = math.dist(p1, p2) #err = r - math.sqrt(r**2 - (d/(2*n))**2) #n = math.ceil(1/(2*math.sqrt(r**2 - (r - max_err)**2)/d)) - arc = Arc(*p1, *p2, *pc, clockwise, unit=self.unit, polarity_dark=self.polarity_dark) + arc = Arc(*p1, *p2, *pc, clockwise, unit=self.unit, polarity_dark=self.polarity_dark, aperture=None) for line in arc.approximate(max_error=max_error, unit=unit): points.append(line.p2) @@ -370,6 +373,7 @@ class Region(GraphicObject): if points[-1] != points[0]: points.append(points[0]) + yield amp.Outline(self.unit, [int(self.polarity_dark), len(points)-1, *(coord for p in points for coord in p)]) def to_primitives(self, unit=None): diff --git a/gerbonara/layers.py b/gerbonara/layers.py index 76ae12e..97e15b0 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -82,6 +82,8 @@ class NamingScheme: 'other drawings': '{board_name}-Dwgs.User.gbr',
'top fabrication': '{board_name}-F.Fab.gbr',
'bottom fabrication': '{board_name}-B.Fab.gbr',
+ 'top adhesive': '{board_name}-F.Adhes.gbr',
+ 'bottom adhesive': '{board_name}-B.Adhes.gbr',
'top courtyard': '{board_name}-F.CrtYd.gbr',
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
'other netlist': '{board_name}.d356',
@@ -290,7 +292,9 @@ class LayerStack: :py:obj:`"altium"`
"""
- def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None, courtyard=False, fabrication=False):
+ def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None,
+ board_name=None, original_path=None, was_zipped=False, generator=None, courtyard=False,
+ fabrication=False, adhesive=False):
if not drill_layers and (graphic_layers, drill_pth, drill_npth) == (None, None, None):
graphic_layers = {tuple(layer.split()): GerberFile()
for layer in ('top paste', 'top silk', 'top mask', 'top copper',
@@ -307,6 +311,11 @@ class LayerStack: **graphic_layers,
('bottom', 'fabrication'): GerberFile()}
+ if adhesive:
+ graphic_layers = {('top', 'adhesive'): GerberFile(),
+ **graphic_layers,
+ ('bottom', 'adhesive'): GerberFile()}
+
drill_pth = ExcellonFile()
drill_npth = ExcellonFile()
diff --git a/gerbonara/tests/test_kicad_footprints.py b/gerbonara/tests/test_kicad_footprints.py index 3334593..e71c2e9 100644 --- a/gerbonara/tests/test_kicad_footprints.py +++ b/gerbonara/tests/test_kicad_footprints.py @@ -101,7 +101,6 @@ def _parse_path_d(path): # NOTE: kicad-cli exports oddly broken svgs. One of the weirder issues is that in some paths, the "L" command is # simply ommitted. for match in re.finditer(r'([ML]?) ?([0-9.]+) *,? *([0-9.]+)|(A) ?([0-9.]+) *,? *([0-9.]+) *,? *([0-9.]+) *,? * ([01]) *,? *([01]) *,? *([0-9.]+) *,? *([0-9.]+)', path_d): - print('P:', match.group(0)) ml, x, y, a, rx, ry, angle, large_arc, sweep, ax, ay = match.groups() if ml or not a: @@ -127,16 +126,21 @@ def _parse_path_d(path): dx = ax - last_x dy = ay - last_y l = math.hypot(dx, dy) + # clockwise normal nx = -dy/l ny = dx/l - nl = math.sqrt(rx**2 - (l/2)**2) - if sweep != large_arc: - cx = mx + nx*nl - cy = my + ny*nl + arg = rx**2 - (l/2)**2 + if arg < 0 or math.isclose(arg, 0, abs_tol=1e-6): + cx, cy = mx, my else: - cx = mx - nx*nl - cy = my - ny*nl + nl = math.sqrt(arg) + if sweep != large_arc: + cx = mx + nx*nl + cy = my + ny*nl + else: + cx = mx - nx*nl + cy = my - ny*nl (min_x, min_y), (max_x, max_y) = arc_bounds(last_x, last_y, ax, ay, cx-last_x, cy-last_y, clockwise=(not sweep)) min_x -= sr @@ -178,7 +182,7 @@ def test_render(kicad_mod_file, tmpfile, print_on_error): # actual content, and text that is slightly off from where it should be. The difference is only a few hundred # micrometers, but it's enough to really throw off our error calculation, so we just ignore text. fp = FootprintInstance(0, 0, sexp=Footprint.open_mod(kicad_mod_file), hide_text=True) - stack = LayerStack(courtyard=True, fabrication=True) + stack = LayerStack(courtyard=True, fabrication=True, adhesive=True) stack.add_layer('mechanical drawings') stack.add_layer('mechanical comments') fp.render(stack) @@ -298,7 +302,10 @@ def test_render(kicad_mod_file, tmpfile, print_on_error): svg_to_png(ref_svg, tmpfile('Reference render', '.png'), bg=None, dpi=600) svg_to_png(out_svg, tmpfile('Output render', '.png'), bg=None, dpi=600) mean, _max, hist = svg_difference(ref_svg, out_svg, dpi=600, diff_out=tmpfile('Difference', '.png')) - assert mean < 1e-3 + + # compensate for circular pads aliasing badly + aliasing_artifacts = 1e-4 * len(fp.sexp.pads)/50 + assert mean < 1e-3 + aliasing_artifacts assert hist[9] < 100 - assert hist[3:].sum() < 1e-3*hist.size + assert hist[3:].sum() < (1e-3 + 10*aliasing_artifacts)*hist.size |