summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--gerbonara/aperture_macros/parse.py8
-rw-r--r--gerbonara/cad/kicad/footprints.py49
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py2
-rw-r--r--gerbonara/graphic_objects.py2
-rw-r--r--gerbonara/tests/test_kicad_footprints.py153
5 files changed, 180 insertions, 34 deletions
diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py
index 5ecc8ad..c1a776f 100644
--- a/gerbonara/aperture_macros/parse.py
+++ b/gerbonara/aperture_macros/parse.py
@@ -172,10 +172,10 @@ class GenericMacros:
rounded_rect = ApertureMacro('GRR', [
ap.CenterLine('mm', [1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad]),
ap.CenterLine('mm', [1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad]),
- ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
- ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
- ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
- ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
+ ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad]),
+ ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad]),
+ ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad]),
+ ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad]),
*_generic_hole(4)])
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index 1178c79..4581c3b 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -177,13 +177,18 @@ class Arc:
def render(self, variables=None):
- cx, cy = self.mid.x, self.mid.y
+ mx, my = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
dasher = Dasher(self)
+ # https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib
+ d = 2 * (x1 * (y2 - my) + x2 * (my - y1) + mx * (y1 - y2))
+ cx = ((x1 * x1 + y1 * y1) * (y2 - my) + (x2 * x2 + y2 * y2) * (my - y1) + (mx * mx + my * my) * (y1 - y2)) / d
+ cy = ((x1 * x1 + y1 * y1) * (mx - x2) + (x2 * x2 + y2 * y2) * (x1 - mx) + (mx * mx + my * my) * (x2 - x1)) / d
+
# KiCad only has clockwise arcs.
- arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=True, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
+ arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=False, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
if dasher.solid:
yield arc
@@ -360,20 +365,20 @@ class Pad:
primitives: OmitDefault(CustomPadPrimitives) = None
def render(self, variables=None):
- if self.type in (Atom.connect, Atom.np_thru_hole):
- return
+ #if self.type in (Atom.connect, Atom.np_thru_hole):
+ # return
- yield go.Flash(self.at.x, self.at.y, self.aperture().rotated(math.radians(self.at.rotation)), unit=MM)
+ yield go.Flash(self.at.x, self.at.y, self.aperture(), unit=MM)
def aperture(self):
if self.shape == Atom.circle:
return ap.CircleAperture(self.size.x, unit=MM)
elif self.shape == Atom.rect:
- return ap.RectangleAperture(self.size.x, self.size.y, unit=MM)
+ return ap.RectangleAperture(self.size.x, self.size.y, unit=MM).rotated(self.at.rotation)
elif self.shape == Atom.oval:
- return ap.ObroundAperture(self.size.x, self.size.y, unit=MM)
+ return ap.ObroundAperture(self.size.x, self.size.y, unit=MM).rotated(self.at.rotation)
elif self.shape == Atom.trapezoid:
# KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably
@@ -405,7 +410,7 @@ class Pad:
for obj in self.primitives.all():
for gn_obj in obj.render():
primitives += gn_obj._aperture_macro_primitives() # todo: precision params
- macro = ApertureMacro(primitives=primitives)
+ macro = ApertureMacro(primitives=primitives).rotated(self.at.rotation)
return ap.ApertureMacroInstance(macro, unit=MM)
def render_drill(self):
@@ -413,14 +418,30 @@ class Pad:
return
plated = self.type != Atom.np_thru_hole
- aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM)
if self.drill.oval:
- w = self.drill.width / 2
- l = go.Line(-w, 0, w, 0, aperture=aperture, unit=MM)
+ dia = self.drill.diameter
+ w = self.drill.width
+
+ if self.drill.offset:
+ ox, oy = self.drill.offset.x, self.drill.offset.y
+ else:
+ ox, oy = 0, 0
+
+ if w > dia:
+ dx = 0
+ dy = (w-dia)/2
+ else:
+ dx = (dia-w)/2
+ dy = 0
+
+ aperture = ap.ExcellonTool(min(dia, w), plated=plated, unit=MM)
+ l = go.Line(ox-dx, oy-dy, ox+dx, oy+dy, aperture=aperture, unit=MM)
l.rotate(math.radians(self.at.rotation))
l.offset(self.at.x, self.at.y)
yield l
+
else:
+ aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM)
yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
@@ -583,6 +604,8 @@ LAYER_MAP_K2G = {
'F.CrtYd': ('top', 'courtyard'),
'B.Fab': ('bottom', 'fabrication'),
'F.Fab': ('top', 'fabrication'),
+ 'Dwgs.User': ('mechanical', 'drawings'),
+ 'Cmts.User': ('mechanical', 'comments'),
'Edge.Cuts': ('mechanical', 'outline'),
}
@@ -609,7 +632,9 @@ class FootprintInstance(Positioned):
if self.value is not None:
variables['VALUE'] = str(self.value)
- self.sexp.render(layer_stack, LAYER_MAP_K2G,
+ layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
+
+ self.sexp.render(layer_stack, layer_map,
x=x, y=y, rotation=rotation,
side=self.side,
text=(not self.hide_text),
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index bc1bfe7..171fa76 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -124,7 +124,7 @@ class FillMode:
@classmethod
def __map__(self, obj, parent=None):
- return obj[0] in (Atom.solid, Atom.yes)
+ return obj[1] in (Atom.solid, Atom.yes)
@classmethod
def __sexp__(self, value):
diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py
index bcf94ce..b9217bb 100644
--- a/gerbonara/graphic_objects.py
+++ b/gerbonara/graphic_objects.py
@@ -716,7 +716,7 @@ class Arc(GraphicObject):
def as_primitive(self, unit=None):
conv = self.converted(unit)
- w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
+ w = self.aperture.equivalent_width(unit) if self.aperture else 0
return gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2,
cx=conv.cx, cy=conv.cy,
diff --git a/gerbonara/tests/test_kicad_footprints.py b/gerbonara/tests/test_kicad_footprints.py
index d5e7085..0d7c11b 100644
--- a/gerbonara/tests/test_kicad_footprints.py
+++ b/gerbonara/tests/test_kicad_footprints.py
@@ -1,13 +1,16 @@
+import math
from itertools import zip_longest
import subprocess
import re
+import bs4
+
from .utils import tmpfile, print_on_error
from .image_support import kicad_fp_export, svg_difference, svg_soup, svg_to_png, run_cargo_cmd
from .. import graphic_objects as go
-from ..utils import MM
+from ..utils import MM, arc_bounds, sum_bounds
from ..layers import LayerStack
from ..cad.kicad.sexp import build_sexp
from ..cad.kicad.sexp_mapper import sexp
@@ -65,15 +68,104 @@ def test_round_trip(kicad_mod_file):
assert original == stage1
+def _compute_style(elem):
+ current_style = {}
+ for elem in [*reversed(list(elem.parents)), elem]:
+ attrs = dict(elem.attrs)
+ for match in re.finditer(r'([^:;]+):([^:;]+)', attrs.pop('style', '')):
+ k, v = match.groups()
+ current_style[k.strip().lower()] = v.strip()
+
+ for k, v in elem.attrs.items():
+ current_style[k.lower()] = v
+ return current_style
+
+
def _parse_path_d(path):
path_d = path.get('d')
if not path_d:
return
- for match in re.finditer(r'[ML] ?([0-9.]+) *,? *([0-9.]+)', path_d):
- x, y = match.groups()
- x, y = float(x), float(y)
- yield x, y
+ style = _compute_style(path)
+ if style.get('stroke', 'none') != 'none':
+ sr = float(style.get('stroke-width', 0)) / 2
+ else:
+ sr = 0
+
+ if 'C' in path_d:
+ raise ValueError('Path contains cubic beziers')
+
+ last_x, last_y = None, None
+ 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):
+ ml, x, y, a, rx, ry, angle, large_arc, sweep, ax, ay = match.groups()
+
+ if ml:
+ x, y = float(x), float(y)
+ last_x, last_y = x, y
+ yield x-sr, y-sr
+ yield x-sr, y+sr
+ yield x+sr, y-sr
+ yield x+sr, y+sr
+
+ elif a:
+ rx, ry = float(rx), float(ry)
+ ax, ay = float(ax), float(ay)
+ angle = float(angle)
+ large_arc = bool(int(large_arc))
+ sweep = bool(int(sweep))
+
+ if not math.isclose(rx, ry, abs_tol=1e-6):
+ raise ValueError("Elliptical arcs not supported. How did that end up here? KiCad can't do those either!")
+
+ mx = (last_x + ax)/2
+ my = (last_y + ay)/2
+ 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
+ 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
+ min_y -= sr
+ max_x += sr
+ max_y += sr
+ # dbg_i += 1
+ # with open(f'/tmp/dbg-arc-{dbg_i}.svg', 'w') as f:
+ # vbx, vby = min(last_x, ax), min(last_y, ay)
+ # vbw, vbh = max(last_x, ax), max(last_y, ay)
+ # vbw -= vbx
+ # vbh -= vby
+ # k = 2
+ # vbx -= vbw*k
+ # vby -= vbh*k
+ # vbw *= 2*k+1
+ # vbh *= 2*k+1
+ # sw = min(vbw, vbh)*1e-3
+ # mr = 3*sw
+ # f.write('<?xml version="1.0" standalone="no"?>\n')
+ # f.write('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n')
+ # f.write(f'<svg version="1.1" width="200mm" height="200mm" viewBox="{vbx} {vby} {vbw} {vbh}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">>\n')
+ # f.write(f'<path fill="none" stroke="#ff00ff" stroke-width="{sw}" d="{path_d}"/>\n')
+ # f.write(f'<rect fill="none" stroke="#404040" stroke-width="{sw}" x="{min_x}" y="{min_y}" width="{max_x-min_x}" height="{max_y-min_y}"/>\n')
+ # f.write(f'<circle fill="none" r="{mr}" stroke="blue" stroke-width="{sw}" cx="{last_x}" cy="{last_y}"/>\n')
+ # f.write(f'<circle fill="none" r="{mr}" stroke="blue" stroke-width="{sw}" cx="{ax}" cy="{ay}"/>\n')
+ # f.write(f'<circle fill="none" r="{mr}" stroke="red" stroke-width="{sw}" cx="{mx}" cy="{my}"/>\n')
+ # f.write(f'<circle fill="none" r="{mr}" stroke="red" stroke-width="{sw}" cx="{cx}" cy="{cy}"/>\n')
+ # f.write('</svg>\n')
+ yield min_x, min_y
+ yield min_x, max_y
+ yield max_x, min_y
+ yield max_x, max_y
+ last_x, last_y = ax, ay
def test_render(kicad_mod_file, tmpfile, print_on_error):
# Hide text and remove text from KiCad's renders. Our text rendering is alright, but KiCad has some weird issue
@@ -82,31 +174,35 @@ def test_render(kicad_mod_file, tmpfile, print_on_error):
# 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.add_layer('mechanical drawings')
+ stack.add_layer('mechanical comments')
fp.render(stack)
color_map = {gn_id: KICAD_LAYER_COLORS[kicad_id] for gn_id, kicad_id in LAYER_MAP_G2K.items()}
color_map[('drill', 'pth')] = (255, 255, 255, 1)
color_map[('drill', 'npth')] = (255, 255, 255, 1)
- color_map = {key: (f'#{r:02x}{g:02x}{b:02x}', str(a)) for key, (r, g, b, a) in color_map.items()}
+ # Remove alpha since overlaid shapes won't work correctly with non-1 alpha without complicated svg filter hacks
+ color_map = {key: (f'#{r:02x}{g:02x}{b:02x}', '1') for key, (r, g, b, _a) in color_map.items()}
margin = 10 # mm
layer = stack[('top', 'courtyard')]
- points = []
+ bounds = []
+ print('===== BOUNDS =====')
for obj in layer.objects:
if isinstance(obj, (go.Line, go.Arc)):
- points.append((obj.x1, obj.y1))
- points.append((obj.x2, obj.y2))
+ bbox = (min_x, min_y), (max_x, max_y) = obj.bounding_box(unit=MM)
+ import textwrap
+ print(f'{min_x: 3.6f} {min_y: 3.6f} {max_x: 3.6f} {max_y: 3.6f}', '\n'.join(textwrap.wrap(str(obj), width=80, subsequent_indent=' '*(3+4*(3+1+6)))))
+ bounds.append(bbox)
+ print('===== END =====')
- if not points:
+ if not bounds:
print('Footprint has no paths on courtyard layer')
return
- min_x = min(x for x, y in points)
- min_y = min(y for x, y in points)
- max_x = max(x for x, y in points)
- max_y = max(y for x, y in points)
+ bounds = sum_bounds(bounds)
+ (min_x, min_y), (max_x, max_y) = bounds
w, h = max_x-min_x, max_y-min_y
- bounds = ((min_x, min_y), (max_x, max_y))
print_on_error('Gerbonara bounds:', bounds, f'w={w:.6f}', f'h={h:.6f}')
out_svg = tmpfile('Output', '.svg')
@@ -158,12 +254,37 @@ def test_render(kicad_mod_file, tmpfile, print_on_error):
# Sample footprint: Connector_PinSocket_2.00mm.pretty/PinSocket_2x11_P2.00mm_Vertical.kicad_mod
run_cargo_cmd('usvg', [str(ref_svg), str(ref_svg)])
- # fix up usvg width/height
with svg_soup(ref_svg) as soup:
+ # fix up usvg width/height
root = soup.find('svg')
root['width'] = root_w
root['height'] = root_h
+ # remove alpha to avoid complicated filter hacks
+ for elem in root.descendants:
+ if not isinstance(elem, bs4.Tag):
+ continue
+
+ if elem.has_attr('opacity'):
+ elem['opacity'] = '1'
+
+ if elem.has_attr('fill-opacity'):
+ elem['fill-opacity'] = '1'
+
+ if elem.has_attr('stroke-opacity'):
+ elem['stroke-opacity'] = '1'
+
+ # kicad-cli incorrectly fills arcs
+ for elem in root.find_all('path'):
+ if ' C ' in elem.get('d', '') and elem.get('stroke', 'none') != 'none':
+ elem['fill'] = 'none'
+
+ # Move fabrication layers above drills because kicad-cli's svg rendering is wonky.
+ with svg_soup(out_svg) as soup:
+ root = soup.find('svg')
+ root.append(soup.find('g', id='l-bottom-fabrication').extract())
+ root.append(soup.find('g', id='l-top-fabrication').extract())
+
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'))