From 301601e81df58ea5fc5f32773c45e7a7e6a6f23c Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 19 Sep 2023 12:44:22 +0200 Subject: Multilayer coil WIP --- gerbonara/cad/kicad/base_types.py | 3 + gerbonara/cad/kicad/footprints.py | 37 +++- gerbonara/cad/kicad/graphical_primitives.py | 17 ++ gerbonara/cad/kicad/pcb.py | 95 ++++++--- gerbonara/cad/kicad/primitives.py | 34 ++++ gerbonara/cad/kicad/schematic.py | 5 +- gerbonara/cli.py | 22 ++- gerbonara/layers.py | 52 ++--- setup.py | 2 +- twisted_coil_gen_twolayer.py | 294 ++++++++++++++++++++++++++++ 10 files changed, 506 insertions(+), 55 deletions(-) create mode 100644 twisted_coil_gen_twolayer.py diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index 001138c..11eeb6d 100644 --- a/gerbonara/cad/kicad/base_types.py +++ b/gerbonara/cad/kicad/base_types.py @@ -208,6 +208,9 @@ class XYCoord: else: self.x, self.y = x, y + def within_distance(self, x, y, dist): + return math.dist((x, y), (self.x, self.y)) < dist + def isclose(self, other, tol=1e-3): return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol) diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index 47d474a..5c27855 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -409,12 +409,47 @@ class Pad: x, y = rotate_point(self.at.x, self.at.y, math.radians(pr)) return x+px, y+py, self.at.rotation, False + @property + def layer_mask(self): + return layer_mask(self.layers) + def offset(self, x=0, y=0): self.at = self.at.with_offset(x, y) - def find_connected(self, **filters): + def find_connected_footprints(self, **filters): """ Find footprints connected to the same net as this pad """ return self.footprint.board.find_footprints(net=self.net.name, **filters) + + def find_same_net(self, include_vias=True): + """ Find traces and vias of the same net as this pad. """ + return self.footprint.board.find_traces(self.net.name, include_vias=include_vias) + + def find_connected_traces(self, consider_candidates=5): + board = self.footprint.board + + found = set() + search_frontier = [(self.at, 0, self.layer_mask)] + while search_frontier: + coord, size, layers = search_frontier.pop() + x, y = coord.x, coord.y + + for cand, attr, cand_size in self.footprint.board.query_trace_index((x, x, y, y), layers, + n=consider_candidates): + if cand in found: + continue + + cand_coord = getattr(cand, attr) + cand_x, cand_y = cand_coord.x, cand_coord.y + if math.dist((x, y), (cand_x, cand_y)) <= size/2 + cand_size/2: + found.add(cand) + yield cand + + if hasattr(cand, 'at'): # via or pad + search_frontier.append((cand.at, getattr(cand, 'size', 0), cand.layer_mask)) + else: + mask = cand.layer_mask + search_frontier.append((cand.start, cand.width, mask)) + search_frontier.append((cand.end, cand.width, mask)) def render(self, variables=None, margin=None, cache=None): #if self.type in (Atom.connect, Atom.np_thru_hole): diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py index f1d13e6..568d1d2 100644 --- a/gerbonara/cad/kicad/graphical_primitives.py +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -78,6 +78,10 @@ class Line: stroke: Stroke = field(default_factory=Stroke) tstamp: Timestamp = None + def rotate(self, angle, cx=None, cy=None): + self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy) + self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy) + def render(self, variables=None): if self.angle: raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.') @@ -176,6 +180,19 @@ class Arc: width: Named(float) = None stroke: Stroke = field(default_factory=Stroke) tstamp: Timestamp = None + _: SEXP_END = None + center: XYCoord = None + + def __post_init__(self): + self.start = XYCoord(self.start) + self.end = XYCoord(self.end) + self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end) + self.center = None + + def rotate(self, angle, cx=None, cy=None): + self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy) + self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy) + self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy) def render(self, variables=None): # FIXME stroke support diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py index 2ad126e..ccda43f 100644 --- a/gerbonara/cad/kicad/pcb.py +++ b/gerbonara/cad/kicad/pcb.py @@ -4,7 +4,7 @@ Library for handling KiCad's PCB files (`*.kicad_mod`). import math from pathlib import Path -from dataclasses import field, KW_ONLY +from dataclasses import field, KW_ONLY, fields from itertools import chain import re import fnmatch @@ -14,6 +14,7 @@ from .base_types import * from .primitives import * from .footprints import Footprint from . import graphical_primitives as gr +import rtree.index from .. import primitives as cad_pr @@ -164,6 +165,10 @@ class TrackSegment: self.start = XYCoord(self.start) self.end = XYCoord(self.end) + @property + def layer_mask(self): + return layer_mask([self.layer]) + def render(self, variables=None, cache=None): if not self.width: return @@ -193,29 +198,18 @@ class TrackArc: locked: Flag() = False net: Named(int) = 0 tstamp: Timestamp = field(default_factory=Timestamp) - _: KW_ONLY + _: SEXP_END = None center: XYCoord = None def __post_init__(self): self.start = XYCoord(self.start) self.end = XYCoord(self.end) - if self.center is not None: - # Convert normal p1/p2/center notation to the insanity that is kicad's midpoint notation - center = XYCoord(self.center) - cx, cy = center.x, center.y - x1, y1 = self.start.x - cx, self.start.y - cy - x2, y2 = self.end.x - cx, self.end.y - cy - # Get a vector pointing towards the middle between "start" and "end" - dx, dy = (x1 + x2)/2, (y1 + y2)/2 - # normalize vector, and multiply by radius to get final point - r = math.hypot(x1, y1) - l = math.hypot(dx, dy) - mx = cx + dx / l * r - my = cy + dy / l * r - self.mid = XYCoord(mx, my) - self.center = None - else: - self.mid = XYCoord(self.mid) + self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end) + self.center = None + + @property + def layer_mask(self): + return layer_mask([self.layer]) def render(self, variables=None, cache=None): if not self.width: @@ -228,9 +222,6 @@ class TrackArc: yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=aperture, clockwise=True, unit=MM) def rotate(self, angle, cx=None, cy=None): - if cx is None or cy is None: - cx, cy = self.mid.x, self.mid.y - self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy) self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy) self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy) @@ -259,6 +250,14 @@ class Via: def abs_pos(self): return self.at.x, self.at.y, 0, False + @property + def layer_mask(self): + return layer_mask(self.layers) + + @property + def width(self): + return self.size + def __post_init__(self): self.at = XYCoord(self.at) @@ -314,8 +313,47 @@ class Board: _ : SEXP_END = None original_filename: str = None _bounding_box: tuple = None + _trace_index: rtree.index.Index = None + _trace_index_map: dict = None + def rebuild_trace_index(self): + idx = self._trace_index = rtree.index.Index() + id_map = self._trace_index_map = {} + for obj in chain(self.track_segments, self.track_arcs): + for field in ('start', 'end'): + obj_id = id(obj) + coord = getattr(obj, field) + _trace_index_map[obj_id] = obj, field, obj.width, obj.layer_mask + idx.insert(obj_id, (coord.x, coord.x, coord.y, coord.y)) + + for fp in self.footprints: + for pad in fp.pads: + obj_id = id(pad) + _trace_index_map[obj_id] = pad, 'at', 0, pad.layer_mask + idx.insert(obj_id, (pad.at.x, pad.at.x, pad.at.y, pad.at.y)) + + for via in self.vias: + obj_id = id(via) + _trace_index_map[obj_id] = via, 'at', via.size, via.layer_mask + idx.insert(obj_id, (via.at.x, via.at.x, via.at.y, via.at.y)) + + def query_trace_index(self, point, layers='*.Cu', n=5): + if self._trace_index is None: + self.rebuild_trace_index() + + if isinstance(layers, str): + layers = [l.strip() for l in layers.split(',')] + + if not isinstance(layers, int): + layers = layer_mask(layers) + + x, y = point + for obj_id in self._trace_index.nearest((x, x, y, y), n): + entry = obj, attr, size, mask = _trace_index_map[obj_id] + if layers & mask: + yield entry + def __after_parse__(self, parent): self.properties = {prop.key: prop.value for prop in self.properties} @@ -365,6 +403,12 @@ class Board: case _: raise TypeError('Can only remove KiCad objects, cannot map generic gerbonara.cad objects for removal') + def remove_many(self, iterable): + iterable = {id(obj) for obj in iterable} + for field in fields(self): + if field.default_factory is list and field.name not in ('nets', 'properties'): + setattr(self, field.name, [obj for obj in getattr(self, field.name) if id(obj) not in iterable]) + def add(self, obj): match obj: case gr.Text(): @@ -481,6 +525,13 @@ class Board: continue yield fp + def find_traces(self, net=None, include_vias=True): + net_id = self.net_id(net, create=False) + match = lambda obj: obj.net == net_id + for obj in chain(self.track_segments, self.track_arcs, self.vias): + if obj.net == net_id: + yield obj + @property def version(self): return self._version diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py index 58a5b2c..d5ee205 100644 --- a/gerbonara/cad/kicad/primitives.py +++ b/gerbonara/cad/kicad/primitives.py @@ -1,5 +1,6 @@ import enum +import math import re from .sexp import * @@ -12,6 +13,7 @@ def unfuck_layers(layers): else: return layers + def fuck_layers(layers): if layers and 'F.Cu' in layers and 'B.Cu' in layers and not any(re.match(r'^In[0-9]+\.Cu$', l) for l in layers): return ['F&B.Cu', *(l for l in layers if l not in ('F.Cu', 'B.Cu'))] @@ -19,6 +21,38 @@ def fuck_layers(layers): return layers +def layer_mask(layers): + mask = 0 + for layer in layers: + match layer: + case '*.Cu': + return 0xff + case 'F.Cu': + mask |= 1<<0 + case 'B.Cu': + mask |= 1<<31 + case _: + if (m := re.match(f'In([0-9]+)\.Cu', layer)): + mask |= 1< math.pi/4: + if abs(da) > math.pi/4 and l1+l2 > 3: seg.append(p1) seg = [p1, p2] segs.append(seg) diff --git a/gerbonara/cli.py b/gerbonara/cli.py index bcc4f11..95be4db 100644 --- a/gerbonara/cli.py +++ b/gerbonara/cli.py @@ -142,12 +142,17 @@ def cli(): rules and use only rules given by --input-map''') @click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type from extension and contents)''') -@click.option('--top/--bottom', default=True, help='Which side of the board to render') +@click.option('--top', 'side', flag_value='top', help='Render top side') +@click.option('--bottom', 'side', flag_value='bottom', help='Render top side') @click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default: millimeter''') @click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport') @click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"') @click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.') +@click.option('--pretty/--no-filters', default=True, help='''Export pseudo-realistic render using filters (default) or + just stack up layers using given colorscheme. In "--no-filters" mode, by default all layers are exported + unless either "--top" or "--bottom" is given.''') +@click.option('--drills/--no-drills', default=True, help='''Include (default) or exclude drills ("--no-filters" only!)''') @click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline. Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an @@ -155,8 +160,8 @@ def cli(): with FF being completely opaque, and 00 being invisibly transparent.''') @click.argument('inpath', type=click.Path(exists=True)) @click.argument('outfile', type=click.File('w'), default='-') -def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, top, command_line_units, - margin, force_bounds, inkscape, colorscheme): +def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, side, drills, + command_line_units, margin, force_bounds, inkscape, pretty, colorscheme): """ Render a gerber file, or a directory or zip of gerber files into an SVG file. """ overrides = json.loads(input_map.read_bytes()) if input_map else None @@ -174,9 +179,14 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, if colorscheme: colorscheme = json.loads(colorscheme.read_text()) - outfile.write(str(stack.to_pretty_svg(side='top' if top else 'bottom', margin=margin, - arg_unit=(command_line_units or MM), - svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme))) + if pretty: + svg = stack.to_pretty_svg(side='bottom' if side == 'bottom' else 'top', margin=margin, + arg_unit=(command_line_units or MM), + svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme) + else: + svg = stack.to_svg(side_re=side or '.*', margin=margin, drills=drills, arg_unit=(command_line_units or MM), + svg_unit=MM, force_bounds=force_bounds, colors=colorscheme) + outfile.write(str(svg)) @cli.command() diff --git a/gerbonara/layers.py b/gerbonara/layers.py index 858dbe4..461d500 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -699,7 +699,7 @@ class LayerStack: def __repr__(self): return str(self) - def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color_map=None, tag=Tag): + def to_svg(self, margin=0, side_re='.*', drills=True, arg_unit=MM, svg_unit=MM, force_bounds=None, colors=None, tag=Tag): """ Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools @@ -709,6 +709,9 @@ class LayerStack: mirrored vertically. :param margin: Export SVG file with given margin around the board's bounding box. + :param side_re: A regex, such as ``'top'``, ``'bottom'``, or ``'.*'`` (default). Selects which layers to export. + The default includes inner layers. + :param drills: :py:obj:`bool` setting if drills are included (default) or not. :param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and ``force_bounds`` are specified in. Default: mm :param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file. @@ -716,6 +719,7 @@ class LayerStack: :param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG file instead of deriving them from this board's bounding box and ``margin``. Note that this will not scale or move the board, but instead will only crop the viewport. + :param colors: Dict mapping ``f'{side} {use}'`` strings to SVG colors. :param tag: Extension point to support alternative XML serializers in addition to the built-in one. :rtype: :py:obj:`str` """ @@ -726,29 +730,29 @@ class LayerStack: stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'} - if color_map is None: - color_map = default_dict(lambda: 'black') + if colors is None: + colors = defaultdict(lambda: 'black') tags = [] + layer_transform = f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)' for (side, use), layer in reversed(self.graphic_layers.items()): - fg = color_map[(side, use)] - tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), - **stroke_attrs, id=f'l-{side}-{use}')) + if re.match(side_re, side) and (fg := colors.get(f'{side} {use}')): + tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), + **stroke_attrs, id=f'l-{side}-{use}', transform=layer_transform)) - if self.drill_pth: - fg = color_map[('drill', 'pth')] - tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), - **stroke_attrs, id=f'l-drill-pth')) + if drills: + if self.drill_pth and (fg := colors.get('drill pth')): + tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), + **stroke_attrs, id=f'l-drill-pth', transform=layer_transform)) - if self.drill_npth: - fg = color_map[('drill', 'npth')] - tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), - **stroke_attrs, id=f'l-drill-npth')) + if self.drill_npth and (fg := colors.get('drill npth')): + tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), + **stroke_attrs, id=f'l-drill-npth', transform=layer_transform)) - for i, layer in enumerate(self._drill_layers): - fg = color_map[('drill', 'unknown')] - tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), - **stroke_attrs, id=f'l-drill-{i}')) + if (fg := colors.get('drill unknown')): + for i, layer in enumerate(self._drill_layers): + tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), + **stroke_attrs, id=f'l-drill-{i}', transform=layer_transform)) return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag) @@ -819,6 +823,7 @@ class LayerStack: inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {} stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'} + layer_transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)' use_defs = [] @@ -862,18 +867,19 @@ class LayerStack: objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white')) layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})', fill=default_fill, stroke=default_stroke, **stroke_attrs, - **inkscape_attrs(f'{side} {use}'))) + **inkscape_attrs(f'{side} {use}'), transform=layer_transform)) for i, layer in enumerate(self.drill_layers): layers.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)), - id=f'l-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}'))) + id=f'l-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}'), + transform=layer_transform)) if self.outline: layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)), - id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'))) + id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'), + transform=layer_transform)) - layer_group = tag('g', layers, transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)') - tags = [tag('defs', filter_defs + use_defs), layer_group] + tags = [tag('defs', filter_defs + use_defs), *layers] return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape) def bounding_box(self, unit=MM, default=None): diff --git a/setup.py b/setup.py index 8af6447..a45535c 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup( 'Tracker': 'https://gitlab.com/gerbolyze/gerbonara/issues', }, packages=find_packages(exclude=['tests']), - install_requires=['click'], + install_requires=['click', 'rtree'], entry_points={ 'console_scripts': [ 'gerbonara = gerbonara.cli:cli', diff --git a/twisted_coil_gen_twolayer.py b/twisted_coil_gen_twolayer.py new file mode 100644 index 0000000..574b8b8 --- /dev/null +++ b/twisted_coil_gen_twolayer.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os +from math import * +from pathlib import Path +from itertools import cycle +from scipy.constants import mu_0 + +from gerbonara.cad.kicad import pcb as kicad_pcb +from gerbonara.cad.kicad import footprints as kicad_fp +from gerbonara.cad.kicad import graphical_primitives as kicad_gr +from gerbonara.cad.kicad import primitives as kicad_pr +from gerbonara.utils import Tag +import click + + +__version__ = '1.0.0' + + +def point_line_distance(p, l1, l2): + x0, y0 = p + x1, y1 = l1 + x2, y2 = l2 + # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line + return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / sqrt((x2-x1)**2 + (y2-y1)**2) + +def line_line_intersection(l1, l2): + p1, p2 = l1 + p3, p4 = l2 + x1, y1 = p1 + x2, y2 = p2 + x3, y3 = p3 + x4, y4 = p4 + + # https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection + px = ((x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)) + py = ((x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)) + return px, py + +def angle_between_vectors(va, vb): + angle = atan2(vb[1], vb[0]) - atan2(va[1], va[0]) + if angle < 0: + angle += 2*pi + return angle + +class SVGPath: + def __init__(self, **attrs): + self.d = '' + self.attrs = attrs + + def line(self, x, y): + self.d += f'L {x} {y} ' + + def move(self, x, y): + self.d += f'M {x} {y} ' + + def arc(self, x, y, r, large, sweep): + self.d += f'A {r} {r} 0 {int(large)} {int(sweep)} {x} {y} ' + + def close(self): + self.d += 'Z ' + + def __str__(self): + attrs = ' '.join(f'{key.replace("_", "-")}="{value}"' for key, value in self.attrs.items()) + return f'' + +class SVGCircle: + def __init__(self, r, cx, cy, **attrs): + self.r = r + self.cx, self.cy = cx, cy + self.attrs = attrs + + def __str__(self): + attrs = ' '.join(f'{key.replace("_", "-")}="{value}"' for key, value in self.attrs.items()) + return f'' + +def svg_file(fn, stuff, vbw, vbh, vbx=0, vby=0): + with open(fn, 'w') as f: + f.write('\n') + f.write('\n') + f.write(f'>\n') + + for foo in stuff: + f.write(str(foo)) + + f.write('\n') + +@click.command() +@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path)) +@click.option('--footprint-name', help="Name for the generated footprint. Default: Output file name sans extension.") +@click.option('--target-layers', default='F.Cu,B.Cu', help="Target KiCad layers for the generated footprint. Default: F.Cu,B.Cu.") +@click.option('--turns', type=int, default=5, help='Number of turns') +@click.option('--diameter', type=float, default=50, help='Outer diameter [mm]') +@click.option('--trace-width', type=float, default=0.15) +@click.option('--via-diameter', type=float, default=0.6) +@click.option('--via-drill', type=float, default=0.3) +@click.option('--keepout-zone/--no-keepout-zone', default=True, help='Add a keepout are to the footprint (default: yes)') +@click.option('--keepout-margin', type=float, default=5, help='Margin between outside of coil and keepout area (mm, default: 5)') +@click.option('--num-twists', type=int, default=1, help='Number of twists per revolution (default: 1)') +@click.option('--clearance', type=float, default=0.15) +@click.option('--clipboard/--no-clipboard', help='Use clipboard integration (requires wl-clipboard)') +@click.option('--counter-clockwise/--clockwise', help='Direction of generated spiral. Default: clockwise when wound from the inside.') +def generate(outfile, turns, diameter, via_diameter, via_drill, trace_width, clearance, footprint_name, target_layers, + num_twists, clipboard, counter_clockwise, keepout_zone, keepout_margin): + if 'WAYLAND_DISPLAY' in os.environ: + copy, paste, cliputil = ['wl-copy'], ['wl-paste'], 'xclip' + else: + copy, paste, cliputil = ['xclip', '-i', '-sel', 'clipboard'], ['xclip', '-o', '-sel' 'clipboard'], 'wl-clipboard' + + + pitch = clearance + trace_width + target_layers = [name.strip() for name in target_layers.split(',')] + via_diameter = max(trace_width, via_diameter) + rainbow = '#817 #a35 #c66 #e94 #ed0 #9d5 #4d8 #2cb #0bc #09c #36b #639'.split() + rainbow = rainbow[2::3] + rainbow[1::3] + rainbow[0::3] + out_paths = [SVGPath(fill='none', stroke=rainbow[i%len(rainbow)], stroke_width=trace_width, stroke_linejoin='round', stroke_linecap='round') for i in range(len(target_layers))] + svg_stuff = [*out_paths] + + + # See https://coil32.net/pcb-coil.html for details + + d_inside = diameter - 2*(pitch*turns - clearance) + d_avg = (diameter + d_inside)/2 + phi = (diameter - d_inside) / (diameter + d_inside) + c1, c2, c3, c4 = 1.00, 2.46, 0.00, 0.20 + L = mu_0 * turns**2 * d_avg*1e3 * c1 / 2 * (log(c2/phi) + c3*phi + c4*phi**2) + print(f'Outer diameter: {diameter:g} mm', file=sys.stderr) + print(f'Average diameter: {d_avg:g} mm', file=sys.stderr) + print(f'Inner diameter: {d_inside:g} mm', file=sys.stderr) + print(f'Fill factor: {phi:g}', file=sys.stderr) + print(f'Approximate inductance: {L:g} µH', file=sys.stderr) + + + make_pad = lambda num, x, y: kicad_fp.Pad( + number=str(num), + type=kicad_fp.Atom.smd, + shape=kicad_fp.Atom.circle, + at=kicad_fp.AtPos(x=x, y=y), + size=kicad_fp.XYCoord(x=trace_width, y=trace_width), + layers=[target_layer], + clearance=clearance, + zone_connect=0) + + make_line = lambda x1, y1, x2, y2, layer: kicad_fp.Line( + start=kicad_fp.XYCoord(x=x1, y=y1), + end=kicad_fp.XYCoord(x=x2, y=y2), + layer=layer, + stroke=kicad_fp.Stroke(width=trace_width)) + + make_arc = lambda x1, y1, x2, y2, xc, yc, layer: kicad_fp.Arc( + start=kicad_fp.XYCoord(x=x1, y=y1), + mid=kicad_fp.XYCoord(x=xc, y=yc), + end=kicad_fp.XYCoord(x=x2, y=y2), + layer=layer, + stroke=kicad_fp.Stroke(width=trace_width)) + + + make_via = lambda x, y, layers: kicad_fp.Pad(number="NC", + type=kicad_fp.Atom.thru_hole, + shape=kicad_fp.Atom.circle, + at=kicad_fp.AtPos(x=x, y=y), + size=kicad_fp.XYCoord(x=via_diameter, y=via_diameter), + drill=kicad_fp.Drill(diameter=via_drill), + layers=layers, + clearance=clearance, + zone_connect=0) + + pads = [] + lines = [] + arcs = [] + turns_per_layer = ceil((turns-1) / len(target_layers)) + print(f'Splitting {turns} turns into {len(target_layers)} layers using {turns_per_layer} turns per layer plus one weaving turn.', file=sys.stderr) + sector_angle = 2*pi / turns_per_layer + ### DELETE THIS: + d_inside = diameter/2 # FIXME DEBUG + ### + + def do_spiral(path, r1, r2, a1, a2, layer, fn=64): + x0, y0 = cos(a1)*r1, sin(a1)*r1 + path.move(x0, y0) + direction = '↓' if r2 < r1 else '↑' + dr = 3 if r2 < r1 else -3 + label = f'{direction} {degrees(a1):.0f}' + svg_stuff.append(Tag('text', + [label], + x=str(x0 + cos(a1)*dr), + y=str(y0 + sin(a1)*dr), + style=f'font: 1px bold sans-serif; fill: {path.attrs["stroke"]}')) + + for i in range(fn+1): + r = r1 + i*(r2-r1)/fn + a = a1 + i*(a2-a1)/fn + xn, yn = cos(a)*r, sin(a)*r + path.line(xn, yn) + + svg_stuff.append(Tag('text', + [label], + x=str(xn + cos(a2)*-dr), + y=str(yn + sin(a2)*-dr + 1.2), + style=f'font: 1px bold sans-serif; fill: {path.attrs["stroke"]}')) + + + print(f'{turns=} {turns_per_layer=} {len(target_layers)=}', file=sys.stderr) + + start_radius = d_inside/2 + end_radius = diameter/2 + + inner_via_ring_radius = start_radius - via_diameter/2 + inner_via_angle = 2*asin(via_diameter/2 / inner_via_ring_radius) + + outer_via_ring_radius = end_radius + via_diameter/2 + outer_via_angle = 2*asin(via_diameter/2 / outer_via_ring_radius) + print(f'inner via ring @ {inner_via_ring_radius:.2f} mm (from {start_radius:.2f} mm)', file=sys.stderr) + print(f' {degrees(inner_via_angle):.1f} deg / via', file=sys.stderr) + print(f'outer via ring @ {outer_via_ring_radius:.2f} mm (from {end_radius:.2f} mm)', file=sys.stderr) + print(f' {degrees(outer_via_angle):.1f} deg / via', file=sys.stderr) + + for n in range(turns-1): + layer_n = n % len(target_layers) + layer = target_layers[layer_n] + layer_turn = floor(n / len(target_layers)) + print(f' {layer_n=} {layer_turn=}', file=sys.stderr) + + start_angle = sector_angle * (layer_turn - layer_n / len(target_layers)) + end_angle = start_angle + (turns_per_layer + 1/len(target_layers)) * sector_angle + + if layer_n % 2 == 1: + start_radius, end_radius = end_radius, start_radius + + do_spiral(out_paths[layer_n], start_radius, end_radius, start_angle, end_angle, layer_n) + + svg_file('/tmp/test.svg', svg_stuff, 100, 100, -50, -50) + + if counter_clockwise: + for p in pads: + p.at.y = -p.at.y + + for l in lines: + l.start.y = -l.start.y + l.end.y = -l.end.y + + for a in arcs: + a.start.y = -a.start.y + a.end.y = -a.end.y + + if footprint_name: + name = footprint_name + elif outfile: + name = outfile.stem, + else: + name = 'generated_coil' + + if keepout_zone: + r = diameter/2 + keepout_margin + tol = 0.05 # mm + n = ceil(pi / acos(1 - tol/r)) + pts = [(r*cos(a*2*pi/n), r*sin(a*2*pi/n)) for a in range(n)] + zones = [kicad_pr.Zone(layers=['*.Cu'], + hatch=kicad_pr.Hatch(), + filled_areas_thickness=False, + keepout=kicad_pr.ZoneKeepout(copperpour_allowed=False), + polygon=kicad_pr.ZonePolygon(pts=kicad_pr.PointList(xy=[kicad_pr.XYCoord(x=x, y=y) for x, y in pts])))] + else: + zones = [] + + fp = kicad_fp.Footprint( + name=name, + generator=kicad_fp.Atom('GerbonaraTwistedCoilGenV1'), + layer='F.Cu', + descr=f"{turns} turn {diameter:.2f} mm diameter twisted coil footprint, inductance approximately {L:.6f} µH. Generated by gerbonara'c Twisted Coil generator, version {__version__}.", + clearance=clearance, + zone_connect=0, + lines=lines, + arcs=arcs, + pads=pads, + zones=zones, + ) + + if clipboard: + try: + print(f'Running {copy[0]}.', file=sys.stderr) + proc = subprocess.Popen(copy, stdin=subprocess.PIPE, text=True) + proc.communicate(fp.serialize()) + except FileNotFoundError: + print(f'Error: --clipboard requires the {copy[0]} and {paste[0]} utilities from {cliputil} to be installed.', file=sys.stderr) + elif not outfile: + print(fp.serialize()) + else: + fp.write(outfile) + +if __name__ == '__main__': + generate() -- cgit