Multilayer coil WIP

autoroute
jaseg 2023-09-19 12:44:22 +02:00
rodzic 3e47e7c2da
commit 301601e81d
10 zmienionych plików z 506 dodań i 55 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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<<int(m.group(1))
return mask
def center_arc_to_kicad_mid(center, start, end):
# Convert normal p1/p2/center notation to the insanity that is kicad's midpoint notation
cx, cy = center.x, center.y
x1, y1 = start.x - cx, start.y - cy
x2, y2 = end.x - cx, end.y - cy
# Get a vector pointing from the center to the "mid" point.
dx, dy = x1 - x2, y1 - y2 # Get a vector pointing from "end" to "start"
dx, dy = -dy, dx # rotate by 90 degrees counter-clockwise
# 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
return XYCoord(mx, my)
@sexp_type('hatch')
class Hatch:
style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge

Wyświetl plik

@ -633,7 +633,7 @@ class Schematic:
# From: https://jakevdp.github.io/blog/2012/10/07/xkcd-style-plots-in-matplotlib/
#def xkcd_line(x, y, xlim=None, ylim=None, mag=1.0, f1=30, f2=0.05, f3=15):
def xkcd_line(x, y, xlim=None, ylim=None, mag=1.0, f1=10, f2=0.10, f3=5):
def xkcd_line(x, y, xlim=None, ylim=None, mag=1.0, f1=10, f2=0.05, f3=5):
"""
Mimic a hand-drawn line from (x, y) data
@ -745,9 +745,10 @@ def wonkify(path):
for p0, p1, p2 in zip(pts[0::], pts[1::], pts[2::]):
dx1, dy1 = p1[0] - p0[0], p1[1] - p0[1]
dx2, dy2 = p2[0] - p1[0], p2[1] - p1[1]
l1, l2 = math.hypot(dx1, dy1), math.hypot(dx2, dy2)
a1, a2 = math.atan2(dy1, dx1), math.atan2(dy2, dx2)
da = (a2 - a1 + math.pi) % (2*math.pi) - math.pi
if abs(da) > math.pi/4:
if abs(da) > math.pi/4 and l1+l2 > 3:
seg.append(p1)
seg = [p1, p2]
segs.append(seg)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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):

Wyświetl plik

@ -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',

Wyświetl plik

@ -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'<path {attrs} d="{self.d.rstrip()}"/>'
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'<circle {attrs} r="{self.r}" cx="{self.cx}" cy="{self.cy}"/>'
def svg_file(fn, stuff, vbw, vbh, vbx=0, vby=0):
with open(fn, 'w') as f:
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="{vbw*4}mm" height="{vbh*4}mm" viewBox="{vbx} {vby} {vbw} {vbh}" style="background-color: #333" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">>\n')
for foo in stuff:
f.write(str(foo))
f.write('</svg>\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()