From bda404c18be7e6c8a82c2a4022bcde1215679b9e Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 23 Apr 2023 23:33:00 +0200 Subject: Fix a bunch of failing tests --- gerbonara/aperture_macros/parse.py | 8 +- gerbonara/cad/kicad/footprints.py | 49 ++++++--- gerbonara/cad/kicad/graphical_primitives.py | 2 +- gerbonara/graphic_objects.py | 2 +- gerbonara/tests/test_kicad_footprints.py | 153 +++++++++++++++++++++++++--- 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('\n') + # f.write('\n') + # f.write(f'>\n') + # f.write(f'\n') + # f.write(f'\n') + # f.write(f'\n') + # f.write(f'\n') + # f.write(f'\n') + # f.write(f'\n') + # f.write('\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')) -- cgit