kopia lustrzana https://gitlab.com/gerbolyze/gerbonara
Multilayer coil WIP
rodzic
3e47e7c2da
commit
301601e81d
|
@ -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)
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
2
setup.py
2
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',
|
||||
|
|
|
@ -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()
|
Ładowanie…
Reference in New Issue