Compare commits

...
Sign in to create a new pull request.

29 commits
main ... wip

Author SHA1 Message Date
jaseg
b60ae26db2 protoserve: Fix bugs, make gerber link more visible 2023-04-09 18:46:36 +02:00
jaseg
f74bd30c0f protoserve: Gerber download works 2023-04-09 17:53:54 +02:00
jaseg
c9dff5450f protogen web interface works 2023-04-09 17:24:50 +02:00
jaseg
3b5fb41ecb protoserve WIP 2023-04-07 19:27:48 +02:00
jaseg
10cd29b96c protoboard webthing WIP 2023-04-06 22:19:59 +02:00
jaseg
48d4aeee94 Make SVG export even smaller 2023-04-06 16:41:10 +02:00
jaseg
6378a91f36 Make generated pretty SVGs smaller 2023-04-06 16:27:57 +02:00
jaseg
ef2864cfb3 Copper fill WIP 2023-04-06 15:17:37 +02:00
jaseg
0a059353d7 Improve protoboard row/column numbering 2023-04-05 18:56:29 +02:00
jaseg
51327ccfeb cad: Add pad numbering for protoboards 2023-04-05 17:44:31 +02:00
jaseg
c10616094c Add RF protoboard 2023-04-05 16:36:30 +02:00
jaseg
4c558f8111 Add missing protoboard file, add powered proto layout 2023-04-05 16:06:03 +02:00
jaseg
ee0c1d38e6 Fix aperture macro multiplication syntax 2023-04-05 14:15:33 +02:00
jaseg
513f6ebf1b Fix rectangle aperture rotation 2023-04-05 14:15:22 +02:00
jaseg
5cf9837484 Add more protoboard layouts 2023-04-05 14:01:40 +02:00
jaseg
d437e06325 Initial protoboard generation working 2023-04-05 01:29:33 +02:00
jaseg
495ae6e932 cad: Fix outline reconstruction and add text feature 2023-04-04 20:06:16 +02:00
jaseg
15867450d9 cad: Finish initial board support 2023-04-04 19:06:37 +02:00
jaseg
82fcc24456 Various convenience improvements, and make board name guessing really smart 2023-04-04 19:06:04 +02:00
jaseg
a877261256 cad: Add trace corner rounding function 2023-04-04 14:05:54 +02:00
jaseg
db2bacebc7 Add missing WIP changes 2023-04-04 01:35:38 +02:00
jaseg
8d4430ea61 Add beginnings of CAD module 2023-04-04 01:31:19 +02:00
jaseg
909766a3a0 Fix extraneous tool selection codes in merged Excellon files 2023-03-31 22:34:28 +02:00
jaseg
845224e2d6 Fix failing tests 2023-03-31 22:31:19 +02:00
jaseg
0ae72f3159 Improve layer stack handling 2023-03-31 16:31:44 +02:00
jaseg
84ec7b26e6 Add convex hull and point in polygon functions 2023-03-31 14:12:26 +02:00
jaseg
36e355cbd8 Improve drill layer handling
Now, drill_pth and drill_npth contain those layers where they match, and
everything else is put in _drill_layers. The @property drill_layers now
returns everything.
2023-03-31 14:11:30 +02:00
jaseg
0037195543 Dedup both Excellon and Gerber tools during write 2023-03-24 00:12:50 +01:00
jaseg
2a3deb6c00 Fix crash in gerber to excellon conversion 2023-03-23 23:51:36 +01:00
21 changed files with 68586 additions and 142 deletions

View file

@ -204,7 +204,7 @@ class OperatorExpression(Expression):
op = {operator.add: '+', op = {operator.add: '+',
operator.sub: '-', operator.sub: '-',
operator.mul: 'X', operator.mul: 'x',
operator.truediv: '/'} [self.op] operator.truediv: '/'} [self.op]
return f'{lval}{op}{rval}' return f'{lval}{op}{rval}'

View file

@ -166,7 +166,17 @@ class GenericMacros:
rect = ApertureMacro('GNR', [ rect = ApertureMacro('GNR', [
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]), ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
*_generic_hole(3) ]) *_generic_hole(3)])
# params: width, height, corner radius, *hole, rotation
rounded_rect = ApertureMacro('GRR', [
ap.CenterLine('mm', [1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad]),
ap.CenterLine('mm', [1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad]),
ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
*_generic_hole(4)])
# w must be larger than h # w must be larger than h
obround = ApertureMacro('GNO', [ obround = ApertureMacro('GNO', [

View file

@ -134,7 +134,7 @@ class Aperture:
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at # we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
# export time during to_gerber, this parameter is evaluated. # export time during to_gerber, this parameter is evaluated.
unit = settings.unit if settings else None unit = settings.unit if settings else None
actual_inst = self._rotated() actual_inst = self.rotated()
params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None) params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None)
if params: if params:
return f'{actual_inst._gerber_shape_code},{params}' return f'{actual_inst._gerber_shape_code},{params}'
@ -204,7 +204,7 @@ class ExcellonTool(Aperture):
offset = unit(offset, self.unit) offset = unit(offset, self.unit)
return replace(self, diameter=self.diameter+2*offset) return replace(self, diameter=self.diameter+2*offset)
def _rotated(self): def rotated(self, angle=0):
return self return self
def to_macro(self): def to_macro(self):
@ -245,11 +245,11 @@ class CircleAperture(Aperture):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None) return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None)
def _rotated(self): def rotated(self, angle=0):
if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None: if math.isclose((self.rotation+angle) % (2*math.pi), 0, abs_tol=1e-6) or self.hole_rect_h is None:
return self return self
else: else:
return self.to_macro(self.rotation) return self.to_macro(self.rotation+angle)
def scaled(self, scale): def scaled(self, scale):
return replace(self, return replace(self,
@ -300,8 +300,10 @@ class RectangleAperture(Aperture):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None) return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
def _rotated(self): def rotated(self, angle=0):
self.rotation += angle
if math.isclose(self.rotation % math.pi, 0): if math.isclose(self.rotation % math.pi, 0):
self.rotation = 0
return self return self
elif math.isclose(self.rotation % math.pi, math.pi/2): elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0) return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
@ -315,13 +317,13 @@ class RectangleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale, hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale) hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
def to_macro(self): def to_macro(self, rotation=0):
return ApertureMacroInstance(GenericMacros.rect, return ApertureMacroInstance(GenericMacros.rect,
[MM(self.w, self.unit), [MM(self.w, self.unit),
MM(self.h, self.unit), MM(self.h, self.unit),
MM(self.hole_dia, self.unit) or 0, MM(self.hole_dia, self.unit) or 0,
MM(self.hole_rect_h, self.unit) or 0, MM(self.hole_rect_h, self.unit) or 0,
self.rotation]) self.rotation + rotation])
def _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
@ -365,13 +367,13 @@ class ObroundAperture(Aperture):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None) return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
def _rotated(self): def rotated(self, angle=0):
if math.isclose(self.rotation % math.pi, 0): if math.isclose((angle + self.rotation) % math.pi, 0, abs_tol=1e-6):
return self return self
elif math.isclose(self.rotation % math.pi, math.pi/2): elif math.isclose((angle + self.rotation) % math.pi, math.pi/2, abs_tol=1e-6):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0) return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
else: else:
return self.to_macro() return self.to_macro(angle)
def scaled(self, scale): def scaled(self, scale):
return replace(self, return replace(self,
@ -380,12 +382,13 @@ class ObroundAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale, hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale) hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
def to_macro(self): def to_macro(self, rotation=0):
# generic macro only supports w > h so flip x/y if h > w # generic macro only supports w > h so flip x/y if h > w
if self.w > self.h: if self.w > self.h:
inst = self inst = self
else: else:
inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=self.rotation-90) inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=rotation+self.rotation-90)
return ApertureMacroInstance(GenericMacros.obround, return ApertureMacroInstance(GenericMacros.obround,
[MM(inst.w, self.unit), [MM(inst.w, self.unit),
MM(inst.h, self.unit), MM(inst.h, self.unit),
@ -433,8 +436,11 @@ class PolygonAperture(Aperture):
flash = _flash_hole flash = _flash_hole
def _rotated(self): def rotated(self, angle=0):
return self if angle != 0:
return replace(self, rotatio=self.rotation + angle)
else:
return self
def scaled(self, scale): def scaled(self, scale):
return replace(self, return replace(self,
@ -445,7 +451,10 @@ class PolygonAperture(Aperture):
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM)) return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
def _params(self, unit=None): def _params(self, unit=None):
rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None rotation = self.rotation % (2*math.pi / self.n_vertices)
if math.isclose(rotation, 0, abs_tol=1-e6):
rotation = None
if self.hole_dia is not None: if self.hole_dia is not None:
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia) return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
elif rotation is not None and not math.isclose(rotation, 0): elif rotation is not None and not math.isclose(rotation, 0):
@ -483,14 +492,14 @@ class ApertureMacroInstance(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
return replace(self, macro=self.macro.dilated(offset, unit)) return replace(self, macro=self.macro.dilated(offset, unit))
def _rotated(self): def rotated(self, angle=0):
if math.isclose(self.rotation % (2*math.pi), 0): if math.isclose((self.rotation+angle) % (2*math.pi), 0):
return self return self
else: else:
return self.to_macro() return self.to_macro(angle)
def to_macro(self): def to_macro(self, rotation=0):
return replace(self, macro=self.macro.rotated(self.rotation), rotation=0) return replace(self, macro=self.macro.rotated(self.rotation+rotation), rotation=0)
def scaled(self, scale): def scaled(self, scale):
return replace(self, macro=self.macro.scaled(scale)) return replace(self, macro=self.macro.scaled(scale))

View file

700
gerbonara/cad/primitives.py Normal file
View file

@ -0,0 +1,700 @@
import sys
import math
import warnings
from copy import copy
from itertools import zip_longest, chain
from dataclasses import dataclass, field, KW_ONLY
from collections import defaultdict
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag
from ..layers import LayerStack
from ..graphic_objects import Line, Arc, Flash
from ..apertures import Aperture, CircleAperture, RectangleAperture, ExcellonTool
from ..newstroke import Newstroke
def sgn(x):
return -1 if x < 0 else 1
class KeepoutError(ValueError):
def __init__(self, obj, keepout, *args, **kwargs):
super().__init__(*args, **kwargs)
self.obj = obj
self.keepout = keepout
newstroke_font = None
class Board:
def __init__(self, w=None, h=None, corner_radius=1.5, center=False, default_via_hole=0.4, default_via_diameter=0.8, x=0, y=0, rotation=0, unit=MM):
self.x, self.y = x, y
self.rotation = 0
self.objects = []
self.outline = []
self.extra_silk_top = []
self.extra_silk_bottom = []
self.keepouts = []
self.default_via_hole = MM(default_via_hole, unit)
self.default_via_diameter = MM(default_via_diameter, unit)
self.unit = unit
if w or h:
if w and h:
self.rounded_rect_outline(w, h, r=corner_radius, center=center)
self.w, self.h = w, h
else:
raise ValueError('Either both, w and h, or neither of them must be given.')
else:
self.w = self.h = None
@property
def abs_pos(self):
return self.x, self.y, self.rotation
def add_silk(self, side, obj):
if side not in ('top', 'bottom'):
raise ValueError('side must be one of "top" or "bottom".')
if side == 'top':
self.extra_silk_top.append(obj)
else:
self.extra_silk_bottom.append(obj)
def add_text(self, *args, **kwargs):
self.objects.append(Text(*args, **kwargs))
def add_keepout(self, bbox, unit=MM):
((_x_min, _y_min), (_x_max, _y_max)) = bbox
self.keepouts.append(MM.convert_bounds_from(unit, bbox))
def add(self, obj, keepout_errors='raise'):
if keepout_errors not in ('ignore', 'raise', 'warn', 'skip'):
raise ValueError('keepout_errors must be one of "ignore", "raise", "warn" or "skip".')
if keepout_errors != 'ignore':
for ko in self.keepouts:
if obj.overlaps(ko, unit=MM):
if keepout_errors == 'warn':
warnings.warn(msg)
elif keepout_errors == 'raise':
raise KeepoutError(obj, ko, msg)
return
obj.parent = self
self.objects.append(obj)
def via(self, x, y, diameter=None, hole=None, keepout_errors='raise', unit=MM):
diameter = diameter or unit(self.default_via_dia, MM)
hole = hole or unit(self.default_via_hole, MM)
obj = Via(x, y, diameter, hole, unit=unit, keepout_errors=keepout_errors)
self.add(obj)
return obj
def rounded_rect_outline(self, w, h, r=0, x0=None, y0=None, center=False, unit=MM):
if x0 is None:
x0 = -w/2 if center else 0
if y0 is None:
y0 = -h/2 if center else 0
ap = CircleAperture(0.05, unit=MM)
self.outline.append(Line(x0+r, y0, x0+w-r, y0, ap, unit=unit))
if r:
self.outline.append(Arc(x0+w-r, y0, x0+w, y0+r, 0, r, False, ap, unit=unit))
self.outline.append(Line(x0+w, y0+r, x0+w, y0+h-r, ap, unit=unit))
if r:
self.outline.append(Arc(x0+w, y0+h-r, x0+w-r, y0+h, -r, 0, False, ap, unit=unit))
self.outline.append(Line(x0+w-r, y0+h, x0+r, y0+h, ap, unit=unit))
if r:
self.outline.append(Arc(x0+r, y0+h, x0, y0+h-r, 0, -r, False, ap, unit=unit))
self.outline.append(Line(x0, y0+h-r, x0, y0+r, ap, unit=unit))
if r:
self.outline.append(Arc(x0, y0+r, x0+r, y0, r, 0, False, ap, unit=unit))
def layer_stack(self, layer_stack=None):
if layer_stack is None:
layer_stack = LayerStack()
for obj in chain(self.objects):
obj.render(layer_stack)
layer_stack['mechanical', 'outline'].objects.extend(self.outline)
layer_stack['top', 'silk'].objects.extend(self.extra_silk_top)
layer_stack['bottom', 'silk'].objects.extend(self.extra_silk_bottom)
return layer_stack
def svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None):
return self.layer_stack().to_svg(margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
force_bounds=force_bounds)
def pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, inkscape=False, colors=None):
return self.layer_stack().to_pretty_svg(side=side, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
force_bounds=force_bounds, inkscape=inkscape, colors=colors)
@dataclass
class Positioned:
x: float
y: float
_: KW_ONLY
rotation: float = 0.0
side: str = 'top'
unit: LengthUnit = MM
parent: object = None
def flip(self):
self.side = 'top' if self.side == 'bottom' else 'bottom'
@property
def abs_pos(self):
if self.parent is None:
px, py, pa = 0, 0, 0
else:
px, py, pa = self.parent.abs_pos
return self.x+px, self.y+py, self.rotation+pa
def bounding_box(self, unit=MM):
stack = LayerStack()
self.render(stack)
objects = chain(*(l.objects for l in stack.graphic_layers.values()),
stack.drill_pth.objects, stack.drill_npth.objects)
objects = list(objects)
#print('foo', type(self).__name__,
# [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr)
return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit))
def overlaps(self, bbox, unit=MM):
return bbox_intersect(self.bounding_box(unit), bbox)
@property
def single_sided(self):
return True
@dataclass
class ObjectGroup(Positioned):
top_copper: list = field(default_factory=list)
top_mask: list = field(default_factory=list)
top_silk: list = field(default_factory=list)
top_paste: list = field(default_factory=list)
bottom_copper: list = field(default_factory=list)
bottom_mask: list = field(default_factory=list)
bottom_silk: list = field(default_factory=list)
bottom_paste: list = field(default_factory=list)
drill_npth: list = field(default_factory=list)
drill_pth: list = field(default_factory=list)
objects: list = field(default_factory=list)
def render(self, layer_stack):
x, y, rotation = self.abs_pos
top, bottom = ('bottom', 'top') if self.side == 'bottom' else ('top', 'bottom')
for obj in self.objects:
obj.parent = self
obj.render(layer_stack)
for target, source in [
(layer_stack[top, 'copper'], self.top_copper),
(layer_stack[top, 'mask'], self.top_mask),
(layer_stack[top, 'silk'], self.top_silk),
(layer_stack[top, 'paste'], self.top_paste),
(layer_stack[bottom, 'copper'], self.bottom_copper),
(layer_stack[bottom, 'mask'], self.bottom_mask),
(layer_stack[bottom, 'silk'], self.bottom_silk),
(layer_stack[bottom, 'paste'], self.bottom_paste),
(layer_stack.drill_pth, self.drill_pth),
(layer_stack.drill_npth, self.drill_npth)]:
for fe in source:
fe = copy(fe)
fe.rotate(rotation)
fe.offset(x, y, self.unit)
target.objects.append(fe)
@property
def single_sided(self):
any_top = self.top_copper or self.top_mask or self.top_paste or self.top_silk
any_bottom = self.bottom_copper or self.bottom_mask or self.bottom_paste or self.bottom_silk
any_drill = self.drill_npth or self.drill_pth
return not (any_drill or (any_top and any_bottom))
@dataclass
class Text(Positioned):
text: str
font_size: float = 2.5
stroke_width: float = 0.25
h_align: str = 'left'
v_align: str = 'bottom'
layer: str = 'silk'
polarity_dark: bool = True
def render(self, layer_stack):
obj_x, obj_y, rotation = self.abs_pos
global newstroke_font
if newstroke_font is None:
newstroke_font = Newstroke()
strokes = list(newstroke_font.render(self.text, size=self.font_size))
if not strokes:
return
xs = [x for points in strokes for x, _y in points]
ys = [y for points in strokes for _x, y in points]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
if self.h_align == 'left':
x0 = 0
elif self.h_align == 'center':
x0 = -max_x/2
elif self.h_align == 'right':
x0 = -max_x
else:
raise ValueError('h_align must be one of "left", "center", or "right".')
if self.v_align == 'top':
y0 = -(max_y - min_y)
elif self.v_align == 'middle':
y0 = -(max_y - min_y)/2
elif self.v_align == 'bottom':
y0 = 0
else:
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
if self.side == 'bottom':
x0 += min_x + max_x
x_sign = -1
else:
x_sign = 1
ap = CircleAperture(self.stroke_width, unit=self.unit)
for stroke in strokes:
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
obj = Line(x0+x_sign*x1, y0-y1, x0+x_sign*x2, y0-y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
obj.rotate(rotation)
obj.offset(obj_x, obj_y)
layer_stack[self.side, self.layer].objects.append(obj)
@dataclass
class Pad(Positioned):
pass
@dataclass
class SMDPad(Pad):
copper_aperture: Aperture
mask_aperture: Aperture
paste_aperture: Aperture
silk_features: list = field(default_factory=list)
def render(self, layer_stack):
x, y, rotation = self.abs_pos
layer_stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit))
layer_stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit))
if self.paste_aperture:
layer_stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit))
layer_stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit)
for feature in self.silk_features])
@classmethod
def rect(kls, x, y, w, h, rotation=0, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
ap_c = RectangleAperture(w, h, unit=unit)
ap_m = RectangleAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit)
ap_p = RectangleAperture(w+2*paste_expansion, h+2*paste_expansion, unit=unit) if paste else None
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, rotation=rotation,
unit=unit)
@classmethod
def circle(kls, x, y, dia, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
ap_c = CircleAperture(dia, unit=unit)
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
@dataclass
class THTPad(Pad):
drill_dia: float
pad_top: SMDPad
pad_bottom: SMDPad = None
aperture_inner: Aperture = None
plated: bool = True
def __post_init__(self):
if self.pad_bottom is None:
import sys
self.pad_bottom = copy(self.pad_top)
self.pad_bottom.flip()
self.pad_top.parent = self.pad_bottom.parent = self
if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'):
raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to "{self.pad_top.side}" and the bottom pad side to "{self.pad_bottom.side}".')
def render(self, layer_stack):
x, y, rotation = self.abs_pos
self.pad_top.parent = self
self.pad_top.render(layer_stack)
if self.pad_bottom:
self.pad_bottom.parent = self
self.pad_bottom.render(layer_stack)
if self.aperture_inner is None:
(x_min, y_min), (x_max, y_max) = self.pad_top.bounding_box(MM)
w_top = x_max - x_min
h_top = y_max - y_min
if self.pad_bottom:
(x_min, y_min), (x_max, y_max) = self.pad_bottom.bounding_box(MM)
w_bottom = x_max - x_min
h_bottom = y_max - y_min
w_top = min(w_top, w_bottom)
h_top = min(h_top, h_bottom)
self.aperture_inner = CircleAperture(min(w_top, h_top), unit=MM)
for (side, use), layer in layer_stack.inner_layers:
layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit))
hole = Flash(x, y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit)
if self.plated:
layer_stack.drill_pth.objects.append(hole)
else:
layer_stack.drill_npth.objects.append(hole)
@property
def single_sided(self):
return False
@classmethod
def rect(kls, x, y, hole_dia, w, h=None, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
if h is None:
h = w
pad = SMDPad.rect(0, 0, w, h, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
@classmethod
def circle(kls, x, y, hole_dia, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDPad.circle(0, 0, dia, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
return kls(x, y, hole_dia, pad, plated=plated, unit=unit)
@classmethod
def obround(kls, x, y, hole_dia, w, h, rotation=0, mask_expansion=0.0, paste_expanson=0.0, paste=True, plated=True, unit=MM):
ap_c = CircleAperture(dia, unit=unit)
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
pad = SMDPad(0, 0, side='top', copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
@dataclass
class Hole(Positioned):
diameter: float
mask_copper_margin: float = 0.2
def render(self, layer_stack):
x, y, rotation = self.abs_pos
hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit)
layer_stack.drill_npth.objects.append(hole)
if self.mask_copper_margin > 0:
mask = Flash(x, y, CircleAperture(self.mask_copper_margin, unit=self.unit), polarity_dark=False, unit=self.unit)
layer_stack['top', 'copper'].objects.append(mask)
layer_stack['bottom', 'copper'].objects.append(mask)
@property
def single_sided(self):
return False
@dataclass
class Via(Positioned):
diameter: float
hole: float
def render(self, layer_stack):
x, y, rotation = self.abs_pos
aperture = CircleAperture(diameter=self.diameter, unit=self.unit)
tool = ExcellonTool(diameter=self.hole, unit=self.unit)
for (side, use), layer in layer_stack.copper_layers:
layer.objects.append(Flash(x, y, aperture, unit=self.unit))
layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit))
@property
def single_sided(self):
return False
@dataclass
class Trace:
width: float
start: object = None
end: object = None
waypoints: [(float, float)] = field(default_factory=list)
style: str = 'oblique'
orientation: [str] = tuple() # 'top' or 'bottom'
roundover: float = 0
unit: LengthUnit = MM
parent: object = None
DIRECT = 'direct'
OBLIQUE = 'oblique'
ORTHO = 'ortho'
CW = 'cw'
CCW = 'ccw'
def _route(self, p1, p2, orientation):
x1, y1 = p1
x2, y2 = p2
dx = x2-x1
dy = y2-y1
yield p1
if self.style == 'direct' or \
math.isclose(x1, x2, abs_tol=1e-6) or math.isclose(y1, y2, abs_tol=1e-6) or \
(self.style == 'oblique' and math.isclose(dx, dy, abs_tol=1e-6)):
return
p = (abs(dy) > abs(dx)) == ((dx >= 0) == (dy >= 0))
if self.style == 'oblique':
if p == (orientation == 'cw'):
if abs(dy) > abs(dx):
yield (x1, y1+sgn(dy)*(abs(dy)-abs(dx)))
else:
yield (x1+sgn(dx)*(abs(dx)-abs(dy)), y1)
else:
if abs(dy) > abs(dx):
yield (x2, y1+sgn(dy)*abs(dx))
else:
yield (x1+sgn(dx)*abs(dy), y2)
else: # self.style == 'ortho'
if p == (orientation == 'cw'):
if abs(dy) > abs(dx):
yield (x1, y2)
else:
yield (x2, y1)
else:
if abs(dy) > abs(dx):
yield (x2, y1)
else:
yield (x1, y2)
@classmethod
def _midpoint(kls, p1, p2):
x1, y1 = p1
x2, y2 = p2
dx = x2 - x1
dy = y2 - y1
xm = x1 + dx / 2
ym = y1 + dy / 2
return (xm, ym)
@classmethod
def _point_on_line(kls, p1, p2, dist_from_p1):
x1, y1 = p1
x2, y2 = p2
dx = x2 - x1
dy = y2 - y1
dist = math.dist(p1, p2)
if math.isclose(dist, 0, abs_tol=1e-6):
return p2
xm = x1 + dx / dist * dist_from_p1
ym = y1 + dy / dist * dist_from_p1
return (xm, ym)
@classmethod
def _angle_between(kls, p1, p2, p3):
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x1, y1 = x1 - x2, y1 - y2
x3, y3 = x3 - x2, y3 - y2
dot_product = x1*x3 + y1*y3
l1 = math.hypot(x1, y1)
l2 = math.hypot(x3, y3)
norm = dot_product / l1 / l2
return math.acos(min(1, max(-1, norm)))
def _round_over(self, points, aperture):
if math.isclose(self.roundover, 0, abs_tol=1e-6) or len(points) <= 2:
import sys
for p1, p2 in zip(points[:-1], points[1:]):
yield Line(*p1, *p2, aperture=aperture, unit=self.unit)
return
# here: len(points) >= 3
line_b = Line(*points[0], *self._midpoint(points[0], points[1]), aperture=aperture, unit=self.unit)
for p1, p2, p3 in zip(points[:-2], points[1:-1], points[2:]):
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
xa, ya = pa = self._midpoint(p1, p2)
xb, yb = pb = self._midpoint(p2, p3)
la = math.dist(pa, p2)
lb = math.dist(p2, pb)
alpha = self._angle_between(p1, p2, p3)
if alpha == 0:
l = Line(line_b.x1, line_b.y1, *p2, aperture=aperture, unit=self.unit)
line_b = Line(*p2, *pb, aperture=aperture, unit=self.unit)
yield l
continue
tr = self.roundover/math.tan(alpha/2)
t = min(la, lb, tr)
r = t*math.tan(alpha/2)
xs, ys = ps = self._point_on_line(p2, pa, t)
xe, ye = pe = self._point_on_line(p2, pb, t)
if math.isclose(t, la, abs_tol=1e-6):
if not math.isclose(line_b.curve_length(), 0, abs_tol=1e-6):
yield line_b
xs, ys = ps = pa
else:
yield Line(line_b.x1, line_b.y1, xs, ys, aperture=aperture, unit=self.unit)
if math.isclose(t, lb, abs_tol=1e-6):
xe, ye = pe = pb
line_b = Line(*pe, *pb, aperture=aperture, unit=self.unit)
if math.isclose(r, 0, abs_tol=1e-6):
continue
xc = -(y2 - ys) / t * r
yc = +(x2 - xs) / t * r
xsr = xs - x2
ysr = ys - y2
xer = xe - x2
yer = ye - y2
cross_product_z = xsr * yer - ysr * xer
clockwise = cross_product_z > 0
if clockwise:
xc, yc = -xc, -yc
yield Arc(*ps, *pe, xc, yc, clockwise, aperture=aperture, unit=self.unit)
yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit)
def _to_graphic_objects(self):
start, end = self.start, self.end
if not isinstance(start, tuple):
*start, _rotation = start.abs_pos
if not isinstance(end, tuple):
*end, _rotation = end.abs_pos
aperture = CircleAperture(diameter=self.width, unit=self.unit)
points_in = [start, *self.waypoints, end]
points = []
for p1, p2, orientation in zip_longest(points_in[:-1], points_in[1:], self.orientation):
points.extend(self._route(p1, p2, orientation))
points.append(p2)
return self._round_over(points, aperture)
def render(self, layer_stack):
layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects())
def _route_demo():
from ..utils import setup_svg, Tag
def pd_obj(objs):
objs = list(objs)
yield f'M {objs[0].x1}, {objs[0].y1}'
for obj in objs:
if isinstance(obj, Line):
yield f'L {obj.x2}, {obj.y2}'
else:
assert isinstance(obj, Arc)
yield svg_arc(obj.p1, obj.p2, obj.center_relative, obj.clockwise)
pd = lambda points: f'M {points[0][0]}, {points[0][1]} ' + ' '.join(f'L {x}, {y}' for x, y in points[1:])
font = Newstroke()
tags = []
for n in range(0, 8*6):
theta = 2*math.pi / (8*6) * n
dx, dy = math.cos(theta), math.sin(theta)
strokes = list(font.render(f'α={n/(8*6)*360}', size=0.2))
xs = [x for st in strokes for x, _y in st]
ys = [y for st in strokes for _x, y in st]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
xf = f'translate({n//6*1.1 + 0.1} {n%6*1.3 + 0.3}) scale(0.5 0.5) translate(1 1)'
txf = f'{xf} translate(0 -1.2) translate({-(max_x-min_x)/2} {-max_y})'
tags.append(Tag('circle', cx='0', cy='0', r='1',
fill='none', stroke='black', opacity='0.5', stroke_width='0.01',
transform=xf))
tags.append(Tag('path',
fill='none',
stroke='black', opacity='0.5', stroke_width='0.02', stroke_linejoin='round', stroke_linecap='round',
transform=txf, d=' '.join(pd(points) for points in strokes)))
#for r in [0.0, 0.1, 0.2, 0.3]:
for r in [0, 0.2]:
#tr = Trace(0.1, style='ortho', roundover=r, start=(0, 0), end=(dx, dy))
tr = Trace(0.1, style='oblique', roundover=r, start=(dx, dy), end=(0, 0))
#points_cw = list(tr._route((0, 0), (dx, dy), 'cw')) + [(dx, dy)]
#points_ccw = list(tr._route((0, 0), (dx, dy), 'ccw')) + [(dx, dy)]
tr.orientation = ['cw']
objs_cw = tr._to_graphic_objects()
tr.orientation = ['ccw']
objs_ccw = tr._to_graphic_objects()
tags.append(Tag('path',
fill='none',
stroke='red', stroke_width='0.01', stroke_linecap='round',
transform=xf, d=' '.join(pd_obj(objs_cw))))
tags.append(Tag('path',
fill='none',
stroke='blue', stroke_width='0.01', stroke_linecap='round',
transform=xf, d=' '.join(pd_obj(objs_ccw))))
#tags.append(Tag('path',
# fill='none',
# stroke='red', stroke_width='0.01', stroke_linecap='round',
# transform=xf, d=pd(points_cw)))
#tags.append(Tag('path',
# fill='none',
# stroke='blue', stroke_width='0.01', stroke_linecap='round',
# transform=xf, d=pd(points_ccw)))
print(setup_svg([Tag('g', tags, transform='scale(20 20)')], [(0, 0), (20*10*1.1 + 0.1, 20*10*1.3 + 0.1)]))
def _board_demo():
b = Board(100, 80)
p1 = THTPad.rect(10, 10, 0.9, 1.8)
b.add(p1)
p2 = THTPad.rect(20, 15, 0.9, 1.8)
b.add(p2)
b.add(Trace(0.5, p1, p2, style='ortho', roundover=1.5))
b.add_text(50, 50, 'Foobar')
print(b.pretty_svg())
b.layer_stack().save_to_directory('/tmp/testdir')
if __name__ == '__main__':
_board_demo()
#_route_demo()

525
gerbonara/cad/protoboard.py Normal file
View file

@ -0,0 +1,525 @@
import sys
import re
import math
import string
import itertools
from copy import copy, deepcopy
import warnings
from .primitives import *
from ..graphic_objects import Region
from ..apertures import RectangleAperture, CircleAperture
class ProtoBoard(Board):
def __init__(self, w, h, content, margin=None, corner_radius=None, mounting_hole_dia=None, mounting_hole_offset=None, unit=MM):
corner_radius = corner_radius or unit(1.5, MM)
super().__init__(w, h, corner_radius, unit=unit)
self.margin = margin or unit(2, MM)
self.content = content
if mounting_hole_dia:
mounting_hole_offset = mounting_hole_offset or mounting_hole_dia*2
ko = mounting_hole_offset*2
self.add(Hole(mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(w-mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(w-mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
self.keepouts.append(((0, 0), (ko, ko)))
self.keepouts.append(((w-ko, 0), (w, ko)))
self.keepouts.append(((0, h-ko), (ko, h)))
self.keepouts.append(((w-ko, h-ko), (w, h)))
self.generate()
def generate(self, unit=MM):
bbox = ((self.margin, self.margin), (self.w-self.margin, self.h-self.margin))
bbox = unit.convert_bounds_from(self.unit, bbox)
for obj in self.content.generate(bbox, (True, True, True, True), unit):
self.add(obj, keepout_errors='skip')
class PropLayout:
def __init__(self, content, direction, proportions):
self.content = list(content)
if direction not in ('h', 'v'):
raise ValueError('direction must be one of "h", or "v".')
self.direction = direction
self.proportions = list(proportions)
if len(content) != len(proportions):
raise ValueError('proportions and content must have same length')
def generate(self, bbox, border_text, unit=MM):
for i, (bbox, child) in enumerate(self.layout_2d(bbox, unit)):
first = bool(i == 0)
last = bool(i == len(self.content)-1)
yield from child.generate(bbox, (
border_text[0] and (last or self.direction == 'h'),
border_text[1] and (last or self.direction == 'v'),
border_text[2] and (first or self.direction == 'h'),
border_text[3] and (first or self.direction == 'v'),
), unit)
def fit_size(self, w, h, unit=MM):
widths = []
heights = []
for ((x_min, y_min), (x_max, y_max)), child in self.layout_2d(((0, 0), (w, h)), unit):
if not isinstance(child, EmptyProtoArea):
widths.append(x_max - x_min)
heights.append(y_max - y_min)
if self.direction == 'h':
return sum(widths), max(heights)
else:
return max(widths), sum(heights)
def layout_2d(self, bbox, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
actual_l = 0
target_l = 0
for l, child in zip(self.layout(w if self.direction == 'h' else h, unit), self.content):
this_x, this_y = x, y
this_w, this_h = w, h
target_l += l
if self.direction == 'h':
this_w = target_l - actual_l
else:
this_h = target_l - actual_l
this_w, this_h = child.fit_size(this_w, this_h, unit)
if self.direction == 'h':
x += this_w
actual_l += this_w
this_h = h
else:
y += this_h
actual_l += this_h
this_w = w
yield ((this_x, this_y), (this_x+this_w, this_y+this_h)), child
def layout(self, length, unit=MM):
out = [ eval_value(value, MM(length, unit)) for value in self.proportions ]
total_length = sum(value for value in out if value is not None)
if length - total_length < -1e-6:
raise ValueError(f'Proportions sum to {total_length} mm, which is greater than the available space of {length} mm.')
leftover = length - total_length
sum_props = sum( (value or 1.0) for value in self.proportions if not isinstance(value, str) )
return [ unit(leftover * (value or 1.0) / sum_props if not isinstance(value, str) else calculated, MM)
for value, calculated in zip(self.proportions, out) ]
@property
def single_sided(self):
return all(elem.single_sided for elem in self.content)
def __str__(self):
children = ', '.join( f'{elem}:{width}' for elem, width in zip(self.content, self.proportions))
return f'PropLayout[{self.direction.upper()}]({children})'
class TwoSideLayout:
def __init__(self, top, bottom):
self.top, self.bottom = top, bottom
if not top.single_sided or not bottom.single_sided:
warnings.warn('Two-sided pattern used on one side of a TwoSideLayout')
def fit_size(self, w, h, unit=MM):
w1, h1 = self.top.fit_size(w, h, unit)
w2, h2 = self.bottom.fit_size(w, h, unit)
if isinstance(self.top, EmptyProtoArea):
if isinstance(self.bottom, EmptyProtoArea):
return w1, h1
return w2, h2
if isinstance(self.bottom, EmptyProtoArea):
return w1, h1
return max(w1, w2), max(h1, h2)
def generate(self, bbox, border_text, unit=MM):
yield from self.top.generate(bbox, border_text, unit)
for obj in self.bottom.generate(bbox, border_text, unit):
obj.side = 'bottom'
yield obj
def numeric(start=1):
def gen():
nonlocal start
for i in itertools.count(start):
yield str(i)
return gen
def alphabetic(case='upper'):
if case not in ('lower', 'upper'):
raise ValueError('case must be one of "lower" or "upper".')
index = string.ascii_lowercase if case == 'lower' else string.ascii_uppercase
def gen():
nonlocal index
for i in itertools.count():
if i<26:
yield index[i]
continue
i -= 26
if i<26*26:
yield index[i//26] + index[i%26]
continue
i -= 26*26
if i<26*26*26:
yield index[i//(26*26)] + index[(i//26)%26] + index[i%26]
else:
raise ValueError('row/column index out of range')
return gen
class PatternProtoArea:
def __init__(self, pitch_x, pitch_y=None, obj=None, numbers=True, font_size=None, font_stroke=None, number_x_gen=alphabetic(), number_y_gen=numeric(), interval_x=5, interval_y=None, unit=MM):
self.pitch_x = pitch_x
self.pitch_y = pitch_y or pitch_x
self.obj = obj
self.unit = unit
self.numbers = numbers
self.font_size = font_size or unit(1.0, MM)
self.font_stroke = font_stroke or unit(0.2, MM)
self.interval_x = interval_x
self.interval_y = interval_y or (1 if MM(self.pitch_y, unit) >= 2.0 else 5)
self.number_x_gen, self.number_y_gen = number_x_gen, number_y_gen
def fit_size(self, w, h, unit=MM):
(min_x, min_y), (max_x, max_y) = self.fit_rect(((0, 0), (w, h)))
return max_x-min_x, max_y-min_y
def fit_rect(self, bbox, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
w_mod = round((w + 5e-7) % unit(self.pitch_x, self.unit), 6)
h_mod = round((h + 5e-7) % unit(self.pitch_y, self.unit), 6)
w_fit, h_fit = round(w - w_mod, 6), round(h - h_mod, 6)
x = x + (w-w_fit)/2
y = y + (h-h_fit)/2
return (x, y), (x+w_fit, y+h_fit)
def generate(self, bbox, border_text, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
n_x = int(w//unit(self.pitch_x, self.unit))
n_y = int(h//unit(self.pitch_y, self.unit))
off_x = (w % unit(self.pitch_x, self.unit)) / 2
off_y = (h % unit(self.pitch_y, self.unit)) / 2
if self.numbers:
for i, lno_i in list(zip(range(n_y), self.number_y_gen())):
if i == 0 or i == n_y - 1 or (i+1) % self.interval_y == 0:
t_y = off_y + y + (n_y - 1 - i + 0.5) * self.pitch_y
if border_text[3]:
t_x = x + off_x
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', side='bottom', unit=self.unit)
if border_text[1]:
t_x = x + w - off_x
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', side='bottom', unit=self.unit)
for i, lno_i in zip(range(n_x), self.number_x_gen()):
if i == 0 or i == n_x - 1 or (i+1) % self.interval_x == 0:
t_x = off_x + x + (i + 0.5) * self.pitch_x
if border_text[2]:
t_y = y + off_y
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', side='bottom', unit=self.unit)
if border_text[0]:
t_y = y + h - off_y
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', side='bottom', unit=self.unit)
for i in range(n_x):
for j in range(n_y):
if hasattr(self.obj, 'inst'):
inst = self.obj.inst(i, j, i == n_x-1, j == n_y-1)
if not inst:
continue
else:
inst = copy(self.obj)
inst.x = inst.unit(off_x + x, unit) + (i + 0.5) * inst.unit(self.pitch_x, self.unit)
inst.y = inst.unit(off_y + y, unit) + (j + 0.5) * inst.unit(self.pitch_y, self.unit)
yield inst
@property
def single_sided(self):
return self.obj.single_sided
class EmptyProtoArea:
def __init__(self, copper_fill=False):
self.copper_fill = copper_fill
def fit_size(self, w, h, unit=MM):
return w, h
def generate(self, bbox, border_text, unit=MM):
if self.copper_fill:
(min_x, min_y), (max_x, max_y) = bbox
group = ObjectGroup(0, 0, top_copper=[Region([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)],
unit=unit, polarity_dark=True)])
group.bounding_box = lambda *args, **kwargs: None
yield group
@property
def single_sided(self):
return True
class ManhattanPads(ObjectGroup):
def __init__(self, w, h=None, gap=0.2, unit=MM):
super().__init__(0, 0)
h = h or w
self.gap = gap
self.unit = unit
p = (w-2*gap)/2
q = (h-2*gap)/2
small_ap = RectangleAperture(p, q, unit=unit)
s = min(w, h) / 2 / math.sqrt(2)
large_ap = RectangleAperture(s, s, rotation=math.pi/4, unit=unit)
large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, rotation=math.pi/4, unit=unit)
a = gap/2 + p/2
b = gap/2 + q/2
self.top_copper.append(Flash(-a, -b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash(-a, b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash( a, -b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash( a, b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=large_ap_neg, polarity_dark=False, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=large_ap, unit=unit))
self.top_mask = self.top_copper
class RFGroundProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, clearance=None, via_dia=None, via_drill=None, pad_dia=None, trace_width=None, unit=MM):
super().__init__(0, 0)
self.unit = unit
self.pitch = pitch = pitch or unit(2.54, MM)
self.drill = drill = drill or unit(0.9, MM)
self.clearance = clearance = clearance or unit(0.3, MM)
self.via_drill = via_drill = via_drill or unit(0.4, MM)
self.via_dia = via_dia = via_dia or unit(0.8, MM)
if pad_dia is None:
self.trace_width = trace_width = trace_width or unit(0.3, MM)
pad_dia = pitch - trace_width - 2*clearance
elif trace_width is None:
trace_width = pitch - pad_dia - 2*clearance
self.pad_dia = pad_dia
via_ap = RectangleAperture(via_dia, via_dia, rotation=math.pi/4, unit=unit)
pad_ap = CircleAperture(pad_dia, unit=unit)
pad_neg_ap = CircleAperture(pad_dia+2*clearance, unit=unit)
ground_ap = RectangleAperture(pitch + unit(0.01, MM), pitch + unit(0.01, MM), unit=unit)
pad_drill = ExcellonTool(drill, plated=True, unit=unit)
via_drill = ExcellonTool(via_drill, plated=True, unit=unit)
self.top_copper.append(Flash(0, 0, aperture=ground_ap, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=pad_neg_ap, polarity_dark=False, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=pad_ap, unit=unit))
self.top_mask.append(Flash(0, 0, aperture=pad_ap, unit=unit))
self.top_copper.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
self.top_mask.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
self.drill_pth.append(Flash(0, 0, aperture=pad_drill, unit=unit))
self.drill_pth.append(Flash(pitch/2, pitch/2, aperture=via_drill, unit=unit))
self.bottom_copper = self.top_copper
self.bottom_mask = self.top_mask
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if border_x or border_y:
inst.drill_pth = inst.drill_pth[:-1]
inst.top_copper = inst.bottom_copper = inst.top_copper[:-1]
inst.top_mask = inst.bottom_mask = inst.top_mask[:-1]
return inst
class THTFlowerProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, diameter=None, unit=MM):
super().__init__(0, 0, unit=unit)
self.pitch = pitch = pitch or unit(2.54, MM)
drill = drill or unit(0.9, MM)
diameter = diameter or unit(2.0, MM)
p = pitch / 2
self.objects.append(THTPad.circle(-p, 0, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle( p, 0, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle(0, -p, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle(0, p, drill, diameter, paste=False, unit=unit))
middle_ap = CircleAperture(diameter, unit=unit)
self.top_copper.append(Flash(0, 0, aperture=middle_ap, unit=unit))
self.bottom_copper = self.top_mask = self.bottom_mask = self.top_copper
def inst(self, x, y, border_x, border_y):
if (x % 2 == 0) and (y % 2 == 0):
return copy(self)
if (x % 2 == 1) and (y % 2 == 1):
return copy(self)
return None
def bounding_box(self, unit=MM):
x, y, rotation = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
class PoweredProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM):
super().__init__(0, 0)
self.unit = unit
self.pitch = pitch = pitch or unit(2.54, MM)
self.drill = drill = drill or unit(0.9, MM)
self.clearance = clearance = clearance or unit(0.3, MM)
self.trace_width = trace_width = trace_width or unit(0.3, MM)
self.via_size = via_size = via_size or unit(0.4, MM)
main_pad_dia = pitch - trace_width - 2*clearance
power_pad_dia_max = math.sqrt(2)*pitch - main_pad_dia - 2*clearance
if power_pad_dia is None:
power_pad_dia = power_pad_dia_max - clearance # reduce some more to give the user more room
elif power_pad_dia > power_pad_dia_max:
warnings.warn(f'Power pad diameter {power_pad_dia} > {power_pad_dia_max} violates pad-to-pad clearance')
self.power_pad_dia = power_pad_dia
main_ap = CircleAperture(main_pad_dia, unit=unit)
power_ap = CircleAperture(self.power_pad_dia, unit=unit)
for l in [self.top_copper, self.bottom_copper]:
l.append(Flash(0, 0, aperture=main_ap, unit=unit))
l.append(Flash(-pitch/2, -pitch/2, aperture=power_ap, unit=unit))
l.append(Flash(-pitch/2, pitch/2, aperture=power_ap, unit=unit))
l.append(Flash( pitch/2, -pitch/2, aperture=power_ap, unit=unit))
l.append(Flash( pitch/2, pitch/2, aperture=power_ap, unit=unit))
self.drill_pth.append(Flash(0, 0, ExcellonTool(drill, plated=True, unit=unit), unit=unit))
self.drill_pth.append(Flash(-pitch/2, -pitch/2, ExcellonTool(via_size, plated=True, unit=unit), unit=unit))
self.top_mask = copy(self.top_copper)
self.bottom_mask = copy(self.bottom_copper)
self.line_ap = CircleAperture(trace_width, unit=unit)
self.top_copper.append(Line(-pitch/2, -pitch/2, -pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
self.top_copper.append(Line(pitch/2, -pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
self.bottom_copper.append(Line(-pitch/2, -pitch/2, pitch/2, -pitch/2, aperture=self.line_ap, unit=unit))
self.bottom_copper.append(Line(-pitch/2, pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if (x + y) % 2 == 0:
inst.drill_pth = inst.drill_pth[:-1]
c = self.power_pad_dia/2 + self.clearance
p = self.pitch/2
if x == 1:
inst.top_silk = [Line(-p, -p+c, -p, p-c, aperture=self.line_ap, unit=self.unit)]
elif x % 2 == 0:
inst.top_silk = [Line(p, -p+c, p, p-c, aperture=self.line_ap, unit=self.unit)]
if y == 0:
inst.bottom_silk = [Line(-p+c, -p, p-c, -p, aperture=self.line_ap, unit=self.unit)]
elif y % 2 == 1:
inst.bottom_silk = [Line(-p+c, p, p-c, p, aperture=self.line_ap, unit=self.unit)]
return inst
def bounding_box(self, unit=MM):
x, y, rotation = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
def convert_to_mm(value, unit):
unitl = unit.lower()
if unitl == 'mm':
return value
elif unitl == 'cm':
return value*10
elif unitl == 'in':
return value*25.4
elif unitl == 'mil':
return value/1000*25.4
else:
raise ValueError(f'Invalid unit {unit}, allowed units are mm, cm, in, and mil.')
_VALUE_RE = re.compile('([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)')
def eval_value(value, total_length=None):
if not isinstance(value, str):
return None
m = _VALUE_RE.match(value.lower())
number, unit = m.groups()
if unit == '%':
if total_length is None:
raise ValueError('Percentages are not allowed for this value')
return total_length * float(number) / 100
return convert_to_mm(float(number), unit)
def _demo():
pattern1 = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
pattern2 = PatternProtoArea(1.2, 2.0, obj=SMDPad.rect(0, 0, 1.0, 1.8, paste=False))
pattern3 = PatternProtoArea(2.54, 1.27, obj=SMDPad.rect(0, 0, 2.3, 1.0, paste=False))
#pattern3 = EmptyProtoArea(copper_fill=True)
#stack = TwoSideLayout(pattern2, pattern3)
stack = PropLayout([pattern2, pattern3], 'v', [0.5, 0.5])
pattern = PropLayout([pattern1, stack], 'h', [0.5, 0.5])
#pattern = PatternProtoArea(2.54, obj=ManhattanPads(2.54))
#pattern = PatternProtoArea(2.54, obj=PoweredProto())
#pattern = PatternProtoArea(2.54, obj=RFGroundProto())
#pattern = PatternProtoArea(2.54*1.5, obj=THTFlowerProto())
#pattern = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
#pattern = PatternProtoArea(2.54, obj=PoweredProto())
pb = ProtoBoard(100, 80, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5)
print(pb.pretty_svg())
pb.layer_stack().save_to_directory('/tmp/testdir')
if __name__ == '__main__':
_demo()
#cnt = alphabetic()()
#for _ in range(32):
# for _ in range(26):
# print(f'{next(cnt):>2}', end=' ', file=sys.stderr)
# print(file=sys.stderr)

154
gerbonara/cad/protoserve.py Normal file
View file

@ -0,0 +1,154 @@
#!/usr/bin/env python
import importlib.resources
from tempfile import NamedTemporaryFile, TemporaryDirectory
from pathlib import Path
from quart import Quart, request, Response, send_file
from . import protoboard as pb
from . import protoserve_data
from ..utils import MM, Inch
def extract_importlib(package):
root = TemporaryDirectory()
stack = [(importlib.resources.files(package), Path(root.name))]
while stack:
res, out = stack.pop()
for item in res.iterdir():
item_out = out / item.name
if item.is_file():
item_out.write_bytes(item.read_bytes())
else:
assert item.is_dir()
item_out.mkdir()
stack.push((item, item_out))
return root
static_folder = extract_importlib(protoserve_data)
app = Quart(__name__, static_folder=static_folder.name)
@app.route('/')
async def index():
return await app.send_static_file('protoserve.html')
def deserialize(obj, unit):
pitch_x = float(obj.get('pitch_x', 1.27))
pitch_y = float(obj.get('pitch_y', 1.27))
clearance = float(obj.get('clearance', 0.2))
mil = lambda x: x/1000 if unit == Inch else x
match obj['type']:
case 'layout':
if not obj.get('children'):
return pb.EmptyProtoArea()
proportions = [float(child['layout_prop']) for child in obj['children']]
content = [deserialize(child, unit) for child in obj['children']]
return pb.PropLayout(content, obj['direction'], proportions)
case 'twoside':
top, bottom = obj['children']
return pb.TwoSideLayout(deserialize(top, unit), deserialize(bottom, unit))
case 'placeholder':
return pb.EmptyProtoArea()
case 'smd':
match obj['pad_shape']:
case 'rect':
pad = pb.SMDPad.rect(0, 0, pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
case 'circle':
pad = pb.SMDPad.circle(0, 0, min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pad, unit=unit)
case 'tht':
hole_dia = mil(float(obj['hole_dia']))
match obj['plating']:
case 'plated':
oneside, plated = False, True
case 'nonplated':
oneside, plated = False, False
case 'singleside':
oneside, plated = True, False
match obj['pad_shape']:
case 'rect':
pad = pb.THTPad.rect(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
case 'circle':
pad = pb.THTPad.circle(0, 0, hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
case 'obround':
pad = pb.THTPad.obround(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
if oneside:
pad.pad_bottom = None
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pad, unit=unit)
case 'manhattan':
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pb.ManhattanPads(pitch_x, pitch_y, clearance, unit=unit), unit=unit)
case 'powered':
pitch = mil(float(obj.get('pitch', 2.54)))
hole_dia = mil(float(obj['hole_dia']))
via_drill = mil(float(obj['via_hole_dia']))
trace_width = mil(float(obj['trace_width']))
return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, trace_width=trace_width, unit=unit), unit=unit)
case 'flower':
pitch = mil(float(obj.get('pitch', 2.54)))
hole_dia = mil(float(obj['hole_dia']))
pattern_dia = mil(float(obj['pattern_dia']))
return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit)
case 'rf':
pitch = float(obj.get('pitch', 2.54))
hole_dia = float(obj['hole_dia'])
via_dia = float(obj['via_dia'])
via_drill = float(obj['via_hole_dia'])
return pb.PatternProtoArea(pitch, pitch, pb.RFGroundProto(pitch, hole_dia, clearance, via_dia, via_drill, unit=MM), unit=MM)
def to_board(obj):
unit = Inch if obj.get('units' == 'us') else MM
w = float(obj.get('width', unit(100, MM)))
h = float(obj.get('height', unit(80, MM)))
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
holes = obj.get('mounting_holes', {})
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
if obj.get('children'):
content = deserialize(obj['children'][0], unit)
else:
content = [pb.EmptyProtoArea()]
return pb.ProtoBoard(w, h, content,
corner_radius=corner_radius,
mounting_hole_dia=mounting_hole_dia,
mounting_hole_offset=mounting_hole_offset,
unit=unit)
@app.route('/preview.svg', methods=['POST'])
async def preview():
obj = await request.get_json()
board = to_board(obj)
return Response(str(board.pretty_svg()), mimetype='image/svg+xml')
@app.route('/gerbers.zip', methods=['POST'])
async def gerbers():
obj = await request.get_json()
board = to_board(obj)
with NamedTemporaryFile(suffix='.zip') as f:
f = Path(f.name)
board.layer_stack().save_to_zipfile(f)
return Response(f.read_bytes(), mimetype='image/svg+xml')
if __name__ == '__main__':
app.run()

View file

@ -0,0 +1,941 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Protoserve</title>
<link rel="icon" type="image/png" href="static/favicon-512.png">
<link rel="apple-touch-icon" href="static/favicon-512.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--u-display-metric: default;
--u-display-us: none;
}
html, body {
margin: 0;
width: 100%;
height: 100%;
font-family: Helvetica, Segoe UI, Sans-Serif;
}
body {
display: grid;
grid-template-columns: clamp(200px, 500px, 50vw) 1fr;
grid-template-rows: 1fr 0fr;
grid-template-areas: "controls main"
"links main";
}
#controls {
grid-area: controls;
user-select: none;
display: grid;
grid-template-columns: 10fr 1fr 1fr;
align-content: start;
overflow-y: scroll;
}
label {
grid-column-start: 1;
grid-column-end: span 3;
display: grid;
grid-template-columns: subgrid;
text-align: right;
}
input[type="checkbox"] {
justify-self: start;
}
input {
align-self: start;
}
.group > h4, .group > h5 {
text-align: center;
margin: 5px;
grid-column-start: 1;
grid-column-end: span 3;
}
.expand > :first-child {
grid-column-start: 1;
grid-column-end: span 3;
}
.group, .field, .expand {
grid-column-start: 1;
grid-column-end: span 3;
display: grid;
grid-template-columns: subgrid;
align-content: start;
}
.group {
background-color: hsl(.0turn 50% 10% / 4%);
padding: 10px;
border-radius: 10px;
box-shadow: 0 0 8px 0px hsl(0 0% 0% / 20%);
margin: 10px 0 10px 0;
}
.expand > .field:first-child {
display: grid;
grid-template-columns: 1fr 100fr 1fr;
align-items: center;
text-align: left;
width: 100%;
position: relative;
}
.group > .content {
grid-column-start: 1;
grid-column-end: span 3;
display: flex;
flex-direction: column;
text-align: center;
}
.group > div > .proportion {
display: none;
}
.proportional > div > .proportion {
display: grid;
}
.split-sides .double-sided-only {
display: none;
}
.split-sides > .placeholder .area-controls {
display: none;
}
.board > .placeholder > .area-controls {
display: none;
}
.area-controls .area-move {
display: none;
}
.area-controls .area-move::before {
content: "/";
padding: 0 5px 0 5px;
}
.group.proportional > .group > .area-controls .area-move {
display: block;
}
.content.area-controls {
flex-direction: row;
justify-content: center;
}
.field > input, .field > select { max-width: 5em;
text-align: right;
margin: 0 5px 0 5px;
}
.group.expand {
border-radius: 0;
}
.expand > :first-child {
font-weight: bold;
}
.expand.collapsed > :nth-child(n+2) {
display: none;
}
.unit.metric {
display: var(--u-display-metric);
}
.unit.us {
display: var(--u-display-us);
}
#preview {
grid-area: main;
padding: 20px;
}
#preview-image {
width: 100%;
height: 100%;
object-fit: contain;
}
#links {
grid-area: links;
display: flex;
justify-content: center;
padding: 5px;
}
#link-gerbers {
background-color: #0d6efd;
color: white;
font-weight: bold;
box-shadow: 0px 0px 1px 1px hsl(0, 0%, 0% / 20%);
border-radius: .5em;
padding: 1em 2em 1em 2em;
}
.layout-area {
grid-column-start: 1;
grid-column-end: span 3;
}
.drop-target {
grid-column-start: 1;
grid-column-end: span 3;
text-align: center;
display: none;
}
.group.drop-enabled > .drop-target {
display: block;
}
.placeholder hr {
width: 3em;
border: none;
border-top: 1px solid hsl(0 0% 60%);
}
#controls.move-in-progress input {
background-color: hsl(0 0% 85%);
}
#controls.move-in-progress {
color: hsl(0 0% 60%);
}
.narrow-only {
display: none;
}
</style>
</head>
<body>
<div id="controls">
<div class="group board">
<h4>Board settings</h4>
<label>Units
<select name='units' value="metric">
<option value="metric">Metric</option>
<option value="us">US Customary</option>
</select>
</label>
<label>Board width
<input name="width" type="text" placeholder="width" value="100">
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<label>Board height
<input name="height" type="text" placeholder="height" value="80">
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<div class="group expand" data-group="round_corners">
<label>Round corners
<input name="enabled" type="checkbox" checked>
</label>
<label>Radius
<input name="radius" type="text" placeholder="radius" value="1.5">
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
</div>
<div class="group expand" data-group="mounting_holes">
<label>Mounting holes
<input name="enabled" type="checkbox" name="has_holes" checked>
</label>
<label>Diameter
<input type="text" placeholder="diameter" name="diameter" value="3.2"></input>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<label>Board edge to hole center
<input type="text" placeholder="distance" name="offset" value="5"></input>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
</div>
<h4>Content</h4>
<div class="group placeholder"></div>
</div>
</div>
<div id="preview">
<img id="preview-image" alt="Automatically generated preview image"/>
</div>
<div id="links">
<a class="narrow-only" href="#controls">Settings</a>
<a class="narrow-only" href="#preview">Preview</a>
<a id="link-gerbers" href='#'>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em">
<title>Download</title>
<!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32V274.7l-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7V32zM64 352c-35.3 0-64 28.7-64 64v32c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V416c0-35.3-28.7-64-64-64H346.5l-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352H64zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>
</svg>
Gerbers
</a>
</div>
<template id="tpl-drop-target">
<a class="drop-target" href="#">
<svg viewBox="0 0 532 532" width="1em" xmlns="http://www.w3.org/2000/svg">
<title>Move here</title>
<path id="path2" d="m 424.025,300.075 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 h -82.7 l 181.3,-181.4 c 12.5,-12.5 12.5,-32.8 0,-45.3 -12.5,-12.5 -32.8,-12.5 -45.3,0 l -181.3,181.4 v -82.7 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 160 c 0,17.7 14.3,32 32,32 z M 80,52 C 35.8,52 0,87.8 0,132 v 320 c 0,44.2 35.8,80 80,80 h 320 c 44.2,0 80,-35.8 80,-80 v -72 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 72 c 0,8.8 -7.2,16 -16,16 H 80 c -8.8,0 -16,-7.2 -16,-16 V 132 c 0,-8.8 7.2,-16 16,-16 h 72 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z" />
</svg>
</a>
</template>
<template id="tpl-g-layout">
<div data-type="layout" class="group proportional">
<h4>Proportional Layout</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Layout settings</h4>
<label>Direction
<select name="direction" value="horizontal">
<option value="h">horizontal</option>
<option value="v">vertical</option>
</select>
</label>
<h5>Content</h4>
<div class="drop-target"></div>
<div class="placeholder"></div>
<div class="drop-target"></div>
<div class="placeholder"></div>
<div class="drop-target"></div>
<a class="content add-element" href="#">Add element</a>
</div>
</template>
<template id="tpl-g-twoside">
<div data-type='twoside' class="group split-sides">
<h4>Split front and back</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Front</h5>
<div class="placeholder"></div>
<h5>Back</h5>
<div class="placeholder"></div>
</div>
</template>
<template id="tpl-g-placeholder">
<div data-type="placeholder" class="group placeholder">
<h4>Empty area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<div class="content">
<a href="#" data-placeholder="layout">Create Layout</a>
<a href="#" data-placeholder="twoside" class="double-sided-only">Split front and back</a>
<hr/>
<a href="#" data-placeholder="smd">SMD area</a>
<a href="#" data-placeholder="tht" class="double-sided-only">THT area</a>
<a href="#" data-placeholder="manhattan">Manhattan area</a>
<a href="#" data-placeholder="flower"class="double-sided-only">THT Flower area</a>
<a href="#" data-placeholder="powered"class="double-sided-only">Powered THT area</a>
<a href="#" data-placeholder="rf"class="double-sided-only">RF THT area</a>
</div>
</div>
</template>
<template id="tpl-g-smd">
<div data-type="smd" class="group smd">
<h4>SMD area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="1.27">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pitch Y
<input type="text" name="pitch_y" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.3">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pad shape
<select name="pad_shape" value="rect">
<option value="rect">(Rounded) Rectangle</option>
<option value="circle">Circle</option>
<option value="obround">Obround</option>
</select>
</label>
<label class="only-shape rect">Corner radius
<input type="text" name="pad_h" placeholder="length" value="0">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-tht">
<div data-type="tht" class="group tht">
<h4>THT area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pitch Y
<input type="text" name="pitch_y" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Plating
<select name="plating" value="through">
<option value="plated">Double-sided, through-plated</option>
<option value="nonplated">Double-sided, non-plated</option>
<option value="singleside">Single-sided, non-plated</option>
</select>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pad shape
<select name="pad_shape" value="circle">
<option value="circle">Circle</option>
<option value="rect">(Rounded) Rectangle</option>
<option value="obround">Obround</option>
</select>
</label>
<label class="only-shape rect">Corner radius
<input type="text" name="pad_h" placeholder="length" value="0">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-manhattan">
<div data-type="manhattan" class="group manhattan">
<h4>Manhattan area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="5.08">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pitch Y
<input type="text" name="pitch_y" placeholder="length" value="5.08">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-flower">
<div data-type="flower" class="group flower">
<h4>THT flower area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch
<input type="text" name="pitch" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pattern diameter
<input type="text" name="pattern_dia" placeholder="length" value="2.0">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-powered">
<div data-type="powered" class="group powered">
<h4>Powered THT area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch
<input type="text" name="pitch" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via drill
<input type="text" name="via_hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width
<input type="text" name="trace_width" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-rf">
<div data-type="rf" class="group rf">
<h4>THT area with RF ground</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch
<input type="text" name="pitch" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width
<input type="text" name="trace_width" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via diameter
<input type="text" name="via_dia" placeholder="length" value="0.8">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via drill
<input type="text" name="via_hole_dia" placeholder="length" value="0.4">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<script>
document.querySelectorAll('.expand').forEach((elem) => {
const checkbox = elem.querySelector(':first-child > input');
checkbox.addEventListener("change", (evt) => {
if (evt.currentTarget.checked) {
elem.classList.remove('collapsed');
} else {
elem.classList.add('collapsed');
}
});
if (checkbox.checked) {
elem.classList.remove('collapsed');
} else {
elem.classList.add('collapsed');
}
});
let g_dropElement = null;
function hookupAreaRemove(node) {
for (const bt of node.querySelectorAll('a.area-remove')) {
bt.addEventListener('click', (evt) => {
let elem = evt.target.closest('.group');
if (elem.parentElement && elem.parentElement.matches('.proportional')) {
let sibling = elem.previousElementSibling;
if (sibling.matches('.drop-target')) {
sibling.remove();
}
elem.remove();
} else {
elem.replaceWith(createPlaceholder());
}
previewReloader.scheduleCall();
});
}
}
function createDropTarget() {
const node = document.querySelector('#tpl-drop-target').content.cloneNode(true);
node.querySelector('a').addEventListener('click', (evt) => {
if (g_dropElement != null) {
const target = evt.target.closest('a');
let sibling = g_dropElement.previousElementSibling;
if (sibling.matches('.drop-target')) {
if (sibling == target) {
return;
}
sibling.remove();
}
g_dropElement.remove();
g_dropElement.querySelector('a.area-move').innerText = "Move";
target.before(sibling);
target.before(g_dropElement);
document.querySelector('#controls').classList.remove('move-in-progress');
document.querySelector('.group.drop-enabled').classList.remove('drop-enabled');
g_dropElement = null;
previewReloader.scheduleCall();
}
});
return node;
}
function hookupAreaMove(node) {
for (const bt of node.querySelectorAll('a.area-move')) {
bt.addEventListener('click', (evt) => {
const controls = document.querySelector('#controls');
const group = evt.target.closest('.group');
if (g_dropElement == null) {
controls.classList.add('move-in-progress');
group.parentElement.classList.add('drop-enabled');
g_dropElement = group;
evt.target.innerText = "Cancel move";
} else {
controls.classList.remove('move-in-progress');
group.parentElement.classList.remove('drop-enabled');
g_dropElement = null;
evt.target.innerText = "Move";
}
});
}
}
function hookupPreviewUpdate(node) {
for (const elem of node.querySelectorAll('select, input')) {
elem.addEventListener('change', previewReloader.scheduleCall.bind(previewReloader));
}
}
function createLayoutItem(type) {
if (type == 'placeholder') {
return createPlaceholder();
}
const template = document.querySelector(`#tpl-g-${type}`);
const node = template.content.cloneNode(true).firstElementChild;
hookupPreviewUpdate(node);
hookupAreaRemove(node);
hookupAreaMove(node);
for (const bt of node.querySelectorAll(':scope > a.add-element')) {
bt.addEventListener('click', (evt) => {
evt.target.before(createPlaceholder());
evt.target.before(createDropTarget());
previewReloader.scheduleCall();
});
}
function updateShapeFilter(filterNode) {
console.log(filterNode);
for (elem of filterNode.closest('.group').querySelectorAll('.only-shape')) {
if (elem.classList.contains(filterNode.value)) {
elem.style.removeProperty('display');
} else {
elem.style.setProperty('display', 'none');
}
}
}
if (type == 'tht' || type == 'smd') {
const filterNode = node.querySelector('select[name="pad_shape"]');
updateShapeFilter(filterNode);
filterNode.addEventListener('change', (evt) => {
updateShapeFilter(evt.target);
});
}
return node;
}
function createPlaceholder() {
const node = document.querySelector('#tpl-g-placeholder').content.cloneNode(true).firstElementChild;
hookupAreaRemove(node);
hookupAreaMove(node);
for (const bt of node.querySelectorAll('.placeholder a[data-placeholder]')) {
bt.addEventListener('click', (evt) => {
const item = createLayoutItem(evt.target.getAttribute('data-placeholder'));
for (const elem of item.querySelectorAll('div.placeholder')) {
elem.replaceWith(createPlaceholder());
}
for (const elem of item.querySelectorAll('div.drop-target')) {
elem.replaceWith(createDropTarget());
}
evt.target.closest('.group').replaceWith(item);
previewReloader.scheduleCall();
});
}
return node;
}
function serializeNode(node) {
function serializeProperties(node) {
let obj = {};
for (const input of node.querySelectorAll(':scope > label > input, :scope > label > select')) {
if (input.type == 'checkbox') {
obj[input.name] = input.checked;
} else {
obj[input.name] = input.value;
}
}
return obj;
}
const obj = serializeProperties(node);
for (const expand of node.querySelectorAll(':scope > .group.expand')) {
obj[expand.getAttribute('data-group')] = serializeProperties(expand);
}
const children = [];
for (const elem of node.querySelectorAll(':scope > .group:not(.expand)')) {
const child = serializeNode(elem);
child['type'] = elem.getAttribute('data-type');
children.push(child);
}
obj['children'] = children;
return obj;
}
function serialize() {
const board = document.querySelector('.group.board');
return JSON.stringify(serializeNode(board));
}
function deserializeNode(node, obj) {
function deserializeProperties(node, obj) {
for (const input of node.querySelectorAll(':scope > label > input, :scope > label > select')) {
if (input.type == 'checkbox') {
input.checked = obj[input.name];
} else {
input.value = obj[input.name];
}
}
}
deserializeProperties(node, obj);
for (const expand of node.querySelectorAll(':scope > .group.expand')) {
deserializeProperties(expand, obj[expand.getAttribute('data-group')]);
}
for (const child of obj['children']) {
const type = child['type'];
if (type) {
const item = createLayoutItem(type);
deserializeNode(item, child);
if (type == 'layout') {
for (const elem of item.querySelectorAll('div.placeholder, div.drop-target')) {
elem.remove();
}
} else {
for (const elem of item.querySelectorAll('div.drop-target')) {
elem.replaceWith(createDropTarget());
}
}
if (obj['type'] == 'layout') {
const addLink = node.querySelector(':scope > a.add-element');
addLink.before(item);
addLink.before(createDropTarget());
} else {
const placeholder = node.querySelector('div.placeholder');
placeholder.replaceWith(item);
}
}
}
}
function deserialize(json) {
const board = document.querySelector('.group.board');
const data = JSON.parse(json);
deserializeNode(board, data);
previewReloader.scheduleCall();
}
class RateLimiter {
constructor(callback, interval_ms) {
this.callback = callback;
this.interval_ms = interval_ms;
this.lastRan = -1e99;
this.timerId = null;
}
callNow() {
const now = performance.timeOrigin + performance.now();
this.lastRan = now;
this.timerId = null;
this.callback();
}
scheduleCall() {
const now = performance.timeOrigin + performance.now();
const timeRemaining = this.interval_ms - (now - this.lastRan);
console.log('scheduling', timeRemaining);
if (!this.timerId) {
if (timeRemaining <= 0) {
this.callNow();
} else {
const callback = this.callback;
this.timerId = setTimeout(this.callNow.bind(this), timeRemaining);
}
}
}
}
let previewBlobURL = null;
previewReloader = new RateLimiter(async () => {
const response = await fetch('preview.svg', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data = await response.blob();
if (previewBlobURL) {
URL.revokeObjectURL(previewBlobURL);
}
previewBlobURL = URL.createObjectURL(data);
document.querySelector('#preview-image').src = previewBlobURL;
}, 1000);
document.querySelector('div.placeholder').replaceWith(createPlaceholder());
for (elem of document.querySelectorAll('select[name="units"]')) {
elem.addEventListener('change', (evt) => {
const style = evt.target.closest('.group').style;
for (const unit of ['metric', 'us']) {
const value = (unit == evt.target.value) ? 'default' : 'none';
style.setProperty(`--u-display-${unit}`, value);
}
});
}
let downloadObjectURL = null;
document.querySelector('#link-gerbers').addEventListener('click', async () => {
const response = await fetch('gerbers.zip', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data = await response.blob();
/* cf. https://gist.github.com/devloco/5f779216c988438777b76e7db113d05c */
const zipBlob = new Blob([data], { type: 'application/zip' });
if (downloadObjectURL) {
URL.revokeObjectURL(downloadObjectURL);
}
downloadObjectURL = URL.createObjectURL(zipBlob);
let link = document.createElement('a');
link.href = downloadObjectURL;
link.download = 'gerbers.zip';
link.click();
});
hookupPreviewUpdate(document.querySelector('.group.board'));
previewReloader.scheduleCall();
</script>
</body>
</html>

View file

@ -247,9 +247,11 @@ class Polyline:
return None return None
(x0, y0), *rest = self.coords (x0, y0), *rest = self.coords
d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest) d = f'M {float(x0):.6} {float(y0):.6} ' + ' '.join(f'L {float(x):.6} {float(y):.6}' for x, y in rest)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width:.6}; stroke-linejoin: round; stroke-linecap: round') return tag('path', d=d,
fill='none', stroke=color,
stroke_width=f'{float(width):.6}')
class CamFile: class CamFile:
@ -283,7 +285,7 @@ class CamFile:
content_min_x, content_min_y = float(content_min_x), float(content_min_y) content_min_x, content_min_y = float(content_min_x), float(content_min_y)
content_max_x, content_max_y = float(content_max_x), float(content_max_y) content_max_x, content_max_y = float(content_max_x), float(content_max_y)
content_w, content_h = content_max_x - content_min_x, content_max_y - content_min_y content_w, content_h = content_max_x - content_min_x, content_max_y - content_min_y
xform = f'translate({content_min_x:.6} {content_min_y+content_h:.6}) scale(1 -1) translate({-content_min_x:.6} {-content_min_y:.6})' xform = f'translate({float(content_min_x):.6} {float(content_min_y+content_h):.6}) scale(1 -1) translate({-float(content_min_x):.6} {-float(content_min_y):.6})'
tags = [tag('g', tags, transform=xform)] tags = [tag('g', tags, transform=xform)]
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
@ -444,5 +446,8 @@ class LazyCamFile:
def save(self, filename, *args, **kwargs): def save(self, filename, *args, **kwargs):
""" Copy this Gerber file to the new path. """ """ Copy this Gerber file to the new path. """
shutil.copy(self.original_path, filename) if 'instance' in self.__dict__: # instance has been loaded, and might have been modified
self.instance.save(filename, *args, **kwargs)
else:
shutil.copy(self.original_path, filename)

View file

File diff suppressed because it is too large Load diff

View file

@ -46,13 +46,15 @@ class ExcellonContext:
def select_tool(self, tool): def select_tool(self, tool):
""" Select the current tool. Retract drill first if necessary. """ """ Select the current tool. Retract drill first if necessary. """
if self.current_tool != tool: current_id = self.tools.get(id(self.current_tool))
new_id = self.tools[id(tool)]
if new_id != current_id:
if self.drill_down: if self.drill_down:
yield 'M16' # drill up yield 'M16' # drill up
self.drill_down = False self.drill_down = False
self.current_tool = tool self.current_tool = tool
yield f'T{self.tools[id(tool)]:02d}' yield f'T{new_id:02d}'
def drill_mode(self): def drill_mode(self):
""" Enter drill mode. """ """ Enter drill mode. """
@ -245,6 +247,16 @@ class ExcellonFile(CamFile):
""" Test if there are multiple plating values used in this file. """ """ Test if there are multiple plating values used in this file. """
return len({obj.plated for obj in self.objects}) > 1 return len({obj.plated for obj in self.objects}) > 1
@property
def is_plated_tristate(self):
if self.is_plated:
return True
if self.is_nonplated:
return False
return None
def append(self, obj_or_comment): def append(self, obj_or_comment):
""" Add a :py:class:`.GraphicObject` or a comment (str) to this file. """ """ Add a :py:class:`.GraphicObject` or a comment (str) to this file. """
if isinstance(obj_or_comment, str): if isinstance(obj_or_comment, str):
@ -252,11 +264,11 @@ class ExcellonFile(CamFile):
else: else:
self.objects.append(obj_or_comment) self.objects.append(obj_or_comment)
def to_excellon(self): def to_excellon(self, plated=None, errors='raise'):
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """ """ Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
return self return self
def to_gerber(self): def to_gerber(self, errros='raise'):
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """ """ Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
apertures = {} apertures = {}
out = GerberFile() out = GerberFile()
@ -274,14 +286,18 @@ class ExcellonFile(CamFile):
def generator(self): def generator(self):
return self.generator_hints[0] if self.generator_hints else None return self.generator_hints[0] if self.generator_hints else None
def merge(self, other): def merge(self, other, mode='ignored', keep_settings=False):
if other is None: if other is None:
return return
if not isinstance(other, ExcellonFile):
other = other.to_excellon(plated=self.is_plated_tristate)
self.objects += other.objects self.objects += other.objects
self.comments += other.comments self.comments += other.comments
self.generator_hints = None self.generator_hints = None
self.import_settings = None if not keep_settings:
self.import_settings = None
@classmethod @classmethod
def open(kls, filename, plated=None, settings=None, external_tools=None): def open(kls, filename, plated=None, settings=None, external_tools=None):
@ -359,25 +375,34 @@ class ExcellonFile(CamFile):
# Build tool index # Build tool index
tool_map = { id(obj.tool): obj.tool for obj in self.objects } tool_map = { id(obj.tool): obj.tool for obj in self.objects }
tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter)) tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter))
tools = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) }
# FIXME dedup tools
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1) mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
if mixed_plating: if mixed_plating:
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.') warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
if tools and max(tools.values()) >= 100: defined_tools = {}
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning) tool_indices = {}
index = 1
for tool_id, tool in tools:
xnc = tool.to_xnc(settings)
if (tool.plated, xnc) in defined_tools:
tool_indices[tool_id] = defined_tools[(tool.plated, xnc)]
for tool_id, index in tools.items(): else:
tool = tool_map[tool_id] if mixed_plating:
if mixed_plating: yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED'
yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED'
yield f'T{index:02d}' + tool.to_xnc(settings) yield f'T{index:02d}' + xnc
tool_indices[tool_id] = defined_tools[(tool.plated, xnc)] = index
index += 1
if index >= 100:
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
yield '%' yield '%'
ctx = ExcellonContext(settings, tools) ctx = ExcellonContext(settings, tool_indices)
# Export objects # Export objects
for obj in self.objects: for obj in self.objects:

View file

@ -77,7 +77,7 @@ class Circle(GraphicPrimitive):
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), style=f'fill: {color}') return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -136,18 +136,18 @@ class ArcPoly(GraphicPrimitive):
if len(self.outline) == 0: if len(self.outline) == 0:
return return
yield f'M {self.outline[0][0]:.6} {self.outline[0][1]:.6}' yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
for old, new, arc in self.segments: for old, new, arc in self.segments:
if not arc: if not arc:
yield f'L {new[0]:.6} {new[1]:.6}' yield f'L {float(new[0]):.6} {float(new[1]):.6}'
else: else:
clockwise, center = arc clockwise, center = arc
yield svg_arc(old, new, center, clockwise) yield svg_arc(old, new, center, clockwise)
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
return tag('path', d=' '.join(self.path_d()), style=f'fill: {color}') return tag('path', d=' '.join(self.path_d()), fill=color)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -188,8 +188,8 @@ class Line(GraphicPrimitive):
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}', return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round') fill='none', stroke=color, stroke_width=str(width))
@dataclass(frozen=True) @dataclass(frozen=True)
class Arc(GraphicPrimitive): class Arc(GraphicPrimitive):
@ -214,7 +214,7 @@ class Arc(GraphicPrimitive):
def flip(self): def flip(self):
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1,
cx=(self.x + self.cx) - self.x2, cy=(self.y + self.cy) - self.y2, clockwise=not self.clockwise) cx=(self.x1 + self.cx) - self.x2, cy=(self.y1 + self.cy) - self.y2, clockwise=not self.clockwise)
def bounding_box(self): def bounding_box(self):
r = self.width/2 r = self.width/2
@ -240,8 +240,8 @@ class Arc(GraphicPrimitive):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise) arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}', return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}',
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round; fill: none') fill='none', stroke=color, stroke_width=width)
@dataclass(frozen=True) @dataclass(frozen=True)
class Rectangle(GraphicPrimitive): class Rectangle(GraphicPrimitive):
@ -275,5 +275,5 @@ class Rectangle(GraphicPrimitive):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
x, y = self.x - self.w/2, self.y - self.h/2 x, y = self.x - self.w/2, self.y - self.h/2
return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h), return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}') *svg_rotation(self.rotation, self.x, self.y), fill=color)

View file

@ -47,7 +47,9 @@ MATCH_RULES = {
'bottom paste': r'.*\.gbp|.*b.paste.(gbr|gbp)', 'bottom paste': r'.*\.gbp|.*b.paste.(gbr|gbp)',
'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.(?:gbr|g[0-9]+)', 'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.(?:gbr|g[0-9]+)',
'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.(gbr|gm1)', 'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.(gbr|gm1)',
'drill plated': r'.*\.(drl)', 'drill nonplated': r'.*\-NPTH.(drl)',
'drill plated': r'.*\-PTH.(drl)',
'drill unknown': r'.*\.(drl)',
'other netlist': r'.*\.d356', 'other netlist': r'.*\.d356',
}, },

View file

@ -36,7 +36,7 @@ from .rs274x import GerberFile
from .ipc356 import Netlist from .ipc356 import Netlist
from .cam import FileSettings, LazyCamFile from .cam import FileSettings, LazyCamFile
from .layer_rules import MATCH_RULES from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
from . import graphic_objects as go from . import graphic_objects as go
from . import graphic_primitives as gp from . import graphic_primitives as gp
@ -74,9 +74,15 @@ class NamingScheme:
'bottom paste': '{board_name}-B.Paste.gbr', 'bottom paste': '{board_name}-B.Paste.gbr',
'inner copper': '{board_name}-In{layer_number}.Cu.gbr', 'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
'mechanical outline': '{board_name}-Edge.Cuts.gbr', 'mechanical outline': '{board_name}-Edge.Cuts.gbr',
'unknown drill': '{board_name}.drl', 'drill unknown': '{board_name}.drl',
'plated drill': '{board_name}.plated.drl', 'drill plated': '{board_name}-PTH.drl',
'nonplated drill': '{board_name}.nonplated.drl', 'drill nonplated': '{board_name}-NPTH.drl',
'other comments': '{board_name}-Cmts.User.gbr',
'other drawings': '{board_name}-Dwgs.User.gbr',
'top fabrication': '{board_name}-F.Fab.gbr',
'bottom fabrication': '{board_name}-B.Fab.gbr',
'top courtyard': '{board_name}-F.CrtYd.gbr',
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
'other netlist': '{board_name}.d356', 'other netlist': '{board_name}.d356',
} }
@ -91,9 +97,15 @@ class NamingScheme:
'bottom paste': '{board_name}.gbp', 'bottom paste': '{board_name}.gbp',
'inner copper': '{board_name}.gp{layer_number}', 'inner copper': '{board_name}.gp{layer_number}',
'mechanical outline': '{board_name}.gko', 'mechanical outline': '{board_name}.gko',
'unknown drill': '{board_name}.drl', 'drill unknown': '{board_name}.drl',
'plated drill': '{board_name}.plated.drl', 'drill plated': '{board_name}.plated.drl',
'nonplated drill': '{board_name}.nonplated.drl', 'drill nonplated': '{board_name}.nonplated.drl',
'other comments': '{board_name}.gm2',
'other drawings': '{board_name}.gm3',
'top courtyard': '{board_name}.gm13',
'bottom courtyard': '{board_name}.gm14',
'top fabrication': '{board_name}.gm15',
'bottom fabrication': '{board_name}.gm16',
} }
@ -101,16 +113,22 @@ class NamingScheme:
def _match_files(filenames): def _match_files(filenames):
matches = {} matches = {}
for generator, rules in MATCH_RULES.items(): for generator, rules in MATCH_RULES.items():
already_matched = set()
gen = {} gen = {}
matches[generator] = gen matches[generator] = gen
for layer, regex in rules.items(): for layer, regex in rules.items():
for fn in filenames: for fn in filenames:
if fn in already_matched:
continue
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)): if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
if layer == 'inner copper': if layer == 'inner copper':
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper' target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
else: else:
target = layer target = layer
gen[target] = gen.get(target, []) + [fn] gen[target] = gen.get(target, []) + [fn]
already_matched.add(fn)
return matches return matches
@ -206,14 +224,14 @@ def _layername_autoguesser(fn):
use = 'mask' use = 'mask'
elif re.search('drill|rout?e?', fn): elif re.search('drill|rout?e?', fn):
use = 'drill' side = 'drill'
side = 'unknown' use = 'unknown'
if re.search(r'np(th|lt)?|(non|un)\W*plated|(non|un)\Wgalv', fn): if re.search(r'np(th|lt)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
side = 'nonplated' use = 'nonplated'
elif re.search('pth|plated|galv|plt', fn): elif re.search('pth|plated|galv|plt', fn):
side = 'plated' use = 'plated'
elif (m := re.search(r'(la?y?e?r?|in(ner)?|conduct(or|ive)?)\W*(?P<num>[0-9]+)', fn)): elif (m := re.search(r'(la?y?e?r?|in(ner)?|conduct(or|ive)?)\W*(?P<num>[0-9]+)', fn)):
use = 'copper' use = 'copper'
@ -243,6 +261,16 @@ def _layername_autoguesser(fn):
return f'{side} {use}' return f'{side} {use}'
def _sort_layername(val):
(side, use), _layer = val
if side == 'top':
return -1
if side == 'bottom':
return 1e99
assert side.startswith('inner_')
return int(side[len('inner_'):])
class LayerStack: class LayerStack:
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board. """ :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
@ -261,9 +289,21 @@ class LayerStack:
:py:obj:`"altium"` :py:obj:`"altium"`
""" """
def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None): def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None):
if not drill_layers and (graphic_layers, drill_pth, drill_npth) == (None, None, None):
graphic_layers = {tuple(layer.split()): GerberFile()
for layer in ('top paste', 'top silk', 'top mask', 'top copper',
'bottom copper', 'bottom mask', 'bottom silk', 'bottom paste',
'mechanical outline')}
drill_pth = ExcellonFile()
drill_npth = ExcellonFile()
self.graphic_layers = graphic_layers self.graphic_layers = graphic_layers
self.drill_layers = drill_layers self.drill_pth = drill_pth
self.drill_npth = drill_npth
self._drill_layers = list(drill_layers)
self.drill_mixed = None
self.board_name = board_name self.board_name = board_name
self.netlist = netlist self.netlist = netlist
self.original_path = original_path self.original_path = original_path
@ -447,6 +487,7 @@ class LayerStack:
if ambiguous: if ambiguous:
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}') raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
drill_pth, drill_npth = None, None
drill_layers = [] drill_layers = []
netlist = None netlist = None
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS } layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
@ -484,7 +525,12 @@ class LayerStack:
layers['mechanical', 'outline'] = layer layers['mechanical', 'outline'] = layer
elif 'drill' in key: elif 'drill' in key:
drill_layers.append(layer) if 'nonplated' in key and drill_npth is None:
drill_npth = layer
elif 'plated' in key and drill_pth is None:
drill_pth = layer
else:
drill_layers.append(layer)
elif 'netlist' in key: elif 'netlist' in key:
if netlist: if netlist:
@ -508,10 +554,10 @@ class LayerStack:
board_name = re.sub(r'^\W+', '', board_name) board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name) board_name = re.sub(r'\W+$', '', board_name)
return kls(layers, drill_layers, netlist, board_name=board_name, return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0]) original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, naming_scheme={}, def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
gerber_settings=None, excellon_settings=None): gerber_settings=None, excellon_settings=None):
""" Save this board into a zip file at the given path. For other options, see """ Save this board into a zip file at the given path. For other options, see
:py:meth:`~.layers.LayerStack.save_to_directory`. :py:meth:`~.layers.LayerStack.save_to_directory`.
@ -519,6 +565,7 @@ class LayerStack:
:param path: Path of output zip file :param path: Path of output zip file
:param overwrite_existing: Bool specifying whether override an existing zip file. If :py:obj:`False` and :param overwrite_existing: Bool specifying whether override an existing zip file. If :py:obj:`False` and
:py:obj:`path` exists, a :py:obj:`ValueError` is raised. :py:obj:`path` exists, a :py:obj:`ValueError` is raised.
:param board_name: Board name to use when naming the Gerber/Excellon files
:param prefix: Store output files under the given prefix inside the zip file :param prefix: Store output files under the given prefix inside the zip file
""" """
@ -532,11 +579,11 @@ class LayerStack:
excellon_settings = gerber_settings excellon_settings = gerber_settings
with ZipFile(path, 'w') as le_zip: with ZipFile(path, 'w') as le_zip:
for path, layer in self._save_files_iter(naming_scheme=naming_scheme): for path, layer in self._save_files_iter(board_name=board_name, naming_scheme=naming_scheme):
with le_zip.open(prefix + str(path), 'w') as out: with le_zip.open(prefix + str(path), 'w') as out:
out.write(layer.instance.write_to_bytes()) out.write(layer.instance.write_to_bytes())
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True, def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True, board_name=None,
gerber_settings=None, excellon_settings=None): gerber_settings=None, excellon_settings=None):
""" Save this board into a directory at the given path. If the given path does not exist, a new directory is """ Save this board into a directory at the given path. If the given path does not exist, a new directory is
created in its place. created in its place.
@ -547,6 +594,7 @@ class LayerStack:
scheme is used. You can provide your own :py:obj:`dict` here, mapping :py:obj:`"side use"` scheme is used. You can provide your own :py:obj:`dict` here, mapping :py:obj:`"side use"`
strings to filenames, or use one of :py:attr:`~.layers.NamingScheme.kicad` or strings to filenames, or use one of :py:attr:`~.layers.NamingScheme.kicad` or
:py:attr:`~.layers.NamingScheme.kicad`. :py:attr:`~.layers.NamingScheme.kicad`.
:param board_name: Board name to use when naming the Gerber/Excellon files
:param overwrite_existing: Bool specifying whether override an existing directory. If :py:obj:`False` and :param overwrite_existing: Bool specifying whether override an existing directory. If :py:obj:`False` and
:py:obj:`path` exists, a :py:obj:`ValueError` is raised. Note that a :py:obj:`path` exists, a :py:obj:`ValueError` is raised. Note that a
:py:obj:`ValueError` will still be raised if the target exists and is not a :py:obj:`ValueError` will still be raised if the target exists and is not a
@ -562,15 +610,32 @@ class LayerStack:
if gerber_settings and not excellon_settings: if gerber_settings and not excellon_settings:
excellon_settings = gerber_settings excellon_settings = gerber_settings
for path, layer in self._save_files_iter(naming_scheme=naming_scheme): for path, layer in self._save_files_iter(board_name=board_name, naming_scheme=naming_scheme):
out = outdir / path out = outdir / path
if out.exists() and not overwrite_existing: if out.exists() and not overwrite_existing:
raise SystemError(f'Path exists but overwrite_existing is False: {out}') raise SystemError(f'Path exists but overwrite_existing is False: {out}')
layer.instance.save(out) layer.instance.save(out)
def _save_files_iter(self, naming_scheme={}): def _save_files_iter(self, board_name=None, naming_scheme={}):
board_name = board_name or self.board_name
if board_name is None:
import inspect
frame = inspect.currentframe()
if frame is None:
board_name = 'board'
else:
while frame is not None:
import sys
if not frame.f_globals['__name__'].startswith('gerbonara'):
board_name = frame.f_code.co_name
del frame
break
old_frame, frame = frame, frame.f_back
del old_frame
def get_name(layer_type, layer): def get_name(layer_type, layer):
nonlocal naming_scheme nonlocal naming_scheme, board_name
if (m := re.match('inner_([0-9]+) copper', layer_type)): if (m := re.match('inner_([0-9]+) copper', layer_type)):
layer_type = 'inner copper' layer_type = 'inner copper'
@ -579,11 +644,13 @@ class LayerStack:
num = None num = None
if layer_type in naming_scheme: if layer_type in naming_scheme:
path = naming_scheme[layer_type].format(layer_number=num, board_name=self.board_name) path = naming_scheme[layer_type].format(layer_number=num, board_name=board_name)
elif layer.original_path and layer.original_path.name: elif layer.original_path and layer.original_path.name:
path = layer.original_path.name path = layer.original_path.name
else: else:
path = f'{self.board_name}-{layer_type.replace(" ", "_")}.gbr' path = NamingScheme.kicad[layer_type].format(layer_number=num, board_name=board_name)
#ext = 'drl' if isinstance(layer, ExcellonFile) else 'gbr'
#path = f'{board_name}-{layer_type.replace(" ", "_")}.{ext}'
return path return path
@ -593,18 +660,21 @@ class LayerStack:
#self.normalize_drill_layers() #self.normalize_drill_layers()
if self.drill_pth is not None: if self.drill_pth is not None:
yield get_name('plated drill', self.drill_pth), self.drill_pth yield get_name('drill plated', self.drill_pth), self.drill_pth
if self.drill_npth is not None: if self.drill_npth is not None:
yield get_name('nonplated drill', self.drill_npth), self.drill_npth yield get_name('drill nonplated', self.drill_npth), self.drill_npth
if self.drill_unknown is not None:
yield get_name('unknown drill', self.drill_unknown), self.drill_unknown for layer in self._drill_layers:
yield get_name('drill unknown', layer), layer
if self.netlist: if self.netlist:
yield get_name('other netlist', self.netlist), self.netlist yield get_name('other netlist', self.netlist), self.netlist
def __str__(self): def __str__(self):
names = [ f'{side} {use}' for side, use in self.graphic_layers ] names = [ f'{side} {use}' for side, use in self.graphic_layers ]
return f'<LayerStack {self.board_name} [{", ".join(names)}] and {len(self.drill_layers)} drill layers>' num_drill_layers = len(list(self.drill_layers))
return f'<LayerStack {self.board_name} [{", ".join(names)}] and {num_drill_layers} drill layers>'
def __repr__(self): def __repr__(self):
return str(self) return str(self)
@ -615,6 +685,9 @@ class LayerStack:
unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools
such as Inkscape, use :py:meth:`~.layers.LayerStack.to_pretty_svg` instead. such as Inkscape, use :py:meth:`~.layers.LayerStack.to_pretty_svg` instead.
WARNING: The SVG files generated by this function preserve the Gerber coordinates 1:1, so the file will be
mirrored vertically.
:param margin: Export SVG file with given margin around the board's bounding box. :param margin: Export SVG file with given margin around the board's bounding box.
:param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and :param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and
``force_bounds`` are specified in. Default: mm ``force_bounds`` are specified in. Default: mm
@ -630,17 +703,19 @@ class LayerStack:
bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds) bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds)
else: else:
bounds = self.bounding_box(svg_unit, default=((0, 0), (0, 0))) bounds = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
tags = [] tags = []
for (side, use), layer in self.graphic_layers.items(): for (side, use), layer in self.graphic_layers.items():
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)), tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
id=f'l-{side}-{use}')) **stroke_attrs, id=f'l-{side}-{use}'))
for i, layer in enumerate(self.drill_layers): for i, layer in enumerate(self.drill_layers):
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)), tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
id=f'l-drill-{i}')) **stroke_attrs, id=f'l-drill-{i}'))
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor=page_bg, tag=tag) return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag)
def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False, def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False,
colors=None): colors=None):
@ -706,6 +781,7 @@ class LayerStack:
</filter>'''.strip())) </filter>'''.strip()))
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {} inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
layers = [] layers = []
for use in ['copper', 'mask', 'silk', 'paste']: for use in ['copper', 'mask', 'silk', 'paste']:
@ -715,18 +791,36 @@ class LayerStack:
layer = self[(side, use)] layer = self[(side, use)]
fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white') fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
objects = list(layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, tag=Tag))
default_fill = {'copper': fg, 'mask': fg, 'silk': 'none', 'paste': fg}[use]
default_stroke = {'copper': 'none', 'mask': 'none', 'silk': fg, 'paste': 'none'}[use]
objects = []
for obj in layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, tag=Tag):
if obj.attrs.get('fill') == default_fill:
del obj.attrs['fill']
elif 'fill' not in obj.attrs:
obj.attrs['fill'] = 'none'
if obj.attrs.get('stroke') == default_stroke:
del obj.attrs['stroke']
elif default_stroke != 'none' and 'stroke' not in obj.attrs:
obj.attrs['stroke'] = 'none'
objects.append(obj)
if use == 'mask': if use == 'mask':
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), style='fill:white')) 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})', **inkscape_attrs(f'{side} {use}'))) 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}')))
for i, layer in enumerate(self.drill_layers): 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)), 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)', **inkscape_attrs(f'drill-{i}'))) id=f'g-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}')))
if self.outline: if self.outline:
layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)), layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
id=f'l-outline-{i}', **inkscape_attrs(f'outline-{i}'))) id=f'g-outline-{i}', **stroke_attrs, **inkscape_attrs(f'outline-{i}')))
layer_group = tag('g', layers, transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)') layer_group = tag('g', layers, transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)')
tags = [tag('defs', filter_defs), layer_group] tags = [tag('defs', filter_defs), layer_group]
@ -799,8 +893,8 @@ class LayerStack:
layer.scale(factor) layer.scale(factor)
def merge_drill_layers(self): def merge_drill_layers(self):
""" Merge all drill layers of this board into a single drill layer containing all objetcs. You can access this """ Merge all drill layers of this board into a single drill layer containing all objects. You can access this
drill layer under the :py:attr:`.LayerStack.drill_unknown` attribute. The original layers are removed from the drill layer under the :py:attr:`.LayerStack.drill_mixed` attribute. The original layers are removed from the
board. """ board. """
target = ExcellonFile(comments=['Drill files merged by gerbonara']) target = ExcellonFile(comments=['Drill files merged by gerbonara'])
@ -811,7 +905,7 @@ class LayerStack:
target.merge(layer) target.merge(layer)
self.drill_pth = self.drill_npth = None self.drill_pth = self.drill_npth = None
self.drill_unknown = target self.drill_mixed = target
def normalize_drill_layers(self): def normalize_drill_layers(self):
""" Take everything from all drill layers of this board, and sort it into three new drill layers: One with all """ Take everything from all drill layers of this board, and sort it into three new drill layers: One with all
@ -850,23 +944,24 @@ class LayerStack:
npth_out.append(obj) npth_out.append(obj)
self.drill_pth, self.drill_npth = pth_out, npth_out self.drill_pth, self.drill_npth = pth_out, npth_out
self.drill_unknown = unknown_out if unknown_out else None self._drill_layers = [unknown_out] if unknown_out else []
self._drill_layers = []
@property @property
def drill_layers(self): def drill_layers(self):
""" Return all of this board's drill layers as a list. Returns an empty list if the board does not have any """ Generator iterating all of this board's drill layers. """
drill layers. """ if self.drill_pth:
yield self.drill_pth
if self.drill_npth:
yield self.drill_npth
if self._drill_layers: if self._drill_layers:
return self._drill_layers yield from self._drill_layers
if self.drill_pth or self.drill_npth or self.drill_unknown:
return [self.drill_pth, self.drill_npth, self.drill_unknown]
return []
@drill_layers.setter @drill_layers.setter
def drill_layers(self, value): def drill_layers(self, value):
self._drill_layers = value self._drill_layers = value
self.drill_pth = self.drill_npth = self.drill_unknown = None self.drill_pth = self.drill_npth = None
def __len__(self): def __len__(self):
return len(self.graphic_layers) return len(self.graphic_layers)
@ -895,24 +990,21 @@ class LayerStack:
elif isinstance(index, tuple): elif isinstance(index, tuple):
return self.graphic_layers[index] return self.graphic_layers[index]
return self.copper_layers[index] return self.copper_layers[index][1]
@property @property
def copper_layers(self): def copper_layers(self):
""" Return all copper layers of this board as a list. Returns an empty list if the board does not have any """ Return all copper layers of this board as a list of ((side, use), layer) tuples. Returns an empty list if
copper layers. """ the board does not have any copper layers. """
copper_layers = [ ((side, use), layer) for (side, use), layer in self.graphic_layers.items() if use == 'copper' ] layers = [((side, use), layer) for (side, use), layer in self.graphic_layers.items() if use == 'copper']
return sorted(layers, key=_sort_layername)
def sort_layername(val): @property
(side, use), _layer = val def inner_layers(self):
if side == 'top': """ Return all inner copper layers of this board as a list of ((side, use), layer) tuples. Returns an empty list
return -1 if the board does not have any inner layers. """
if side == 'bottom': layers = [((side, use), layer) for (side, use), layer in self.graphic_layers.items() if side.startswith('inner')]
return 1e99 return sorted(layers, key=_sort_layername)
assert side.startswith('inner_')
return int(side[len('inner_'):])
return [ layer for _key, layer in sorted(copper_layers, key=sort_layername) ]
@property @property
def top_side(self): def top_side(self):
@ -946,6 +1038,22 @@ class LayerStack:
polys.append(' '.join(poly.path_d()) + ' Z') polys.append(' '.join(poly.path_d()) + ' Z')
return ' '.join(polys) return ' '.join(polys)
def outline_convex_hull(self, tol=0.01, unit=MM):
points = []
for obj in self.outline.instance.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def outline_polygons(self, tol=0.01, unit=MM): def outline_polygons(self, tol=0.01, unit=MM):
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and """ Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into :py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
@ -968,7 +1076,7 @@ class LayerStack:
joins = {} joins = {}
for cur in lines: for cur in lines:
for i, (x, y) in enumerate([(cur.x1, cur.y1), (cur.x2, cur.y2)]): for (i, x, y) in [(0, cur.x1, cur.y1), (1, cur.x2, cur.y2)]:
x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol) x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol)
x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol) x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol)
selected = { elem for elem_x, elem in by_x[x_left:x_right] if elem != cur } selected = { elem for elem_x, elem in by_x[x_left:x_right] if elem != cur }
@ -982,19 +1090,20 @@ class LayerStack:
j = 0 if d1 < d2 else 1 j = 0 if d1 < d2 else 1
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i): if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
raise ValueError(f'Error: three-way intersection of {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}') warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
return self.outline_convex_hull(tol, unit)
if (cur, i) in joins and joins[(cur, i)] != (nearest, j): if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
raise ValueError(f'Error: three-way intersection of {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}') warnings.warn(f'three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
return self.outline_convex_hull(tol, unit)
joins[(cur, i)] = (nearest, j) joins[(cur, i)] = (nearest, j)
joins[(nearest, j)] = (cur, i) joins[(nearest, j)] = (cur, i)
def flip_if(obj, i): def flip_if(obj, cond):
if i: if cond:
c = copy.copy(obj) return obj.flip()
c.flip()
return c
else: else:
return obj return obj
@ -1072,7 +1181,7 @@ class LayerStack:
self.drill_pth.merge(other.drill_pth) self.drill_pth.merge(other.drill_pth)
self.drill_npth.merge(other.drill_npth) self.drill_npth.merge(other.drill_npth)
self.drill_unknown.merge(other.drill_unknown) self._drill_layers.extend(other._drill_layers)
if self.netlist: if self.netlist:
self.netlist.merge(other.netlist) self.netlist.merge(other.netlist)

93
gerbonara/newstroke.py Normal file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env python
from pathlib import Path
import unicodedata
import re
import ast
from importlib.resources import files
from . import data
STROKE_FONT_SCALE = 1/21
FONT_OFFSET = -10
DEFAULT_SPACE_WIDTH = 0.6
DEFAULT_CHAR_GAP = 0.2
_dec = lambda c: ord(c)-ord('R')
class Newstroke:
def __init__(self, newstroke_cpp=None):
if newstroke_cpp is None:
newstroke_cpp = files(data).joinpath('newstroke_font.cpp').read_bytes()
self.glyphs = dict(self.load(newstroke_cpp))
def render(self, text, size=1.0, space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP):
text = unicodedata.normalize('NFC', text)
missing_glyph = self.glyphs['?']
x = 0
for c in text:
if c == ' ':
x += space_width*size
continue
width, strokes = self.glyphs.get(c, missing_glyph)
glyph_w = max(width, max(x for st in strokes for x, _y in st))
for st in strokes:
yield self.transform_stroke(st, translate=(x, 0), scale=(size, size))
x += glyph_w*size
@classmethod
def transform_stroke(kls, stroke, translate, scale):
dx, dy = translate
sx, sy = scale
return [(x*sx+dx, y*sy+dy) for x, y in stroke]
def load(self, newstroke_cpp):
e = []
for char, (width, strokes) in self.load_glyphs(newstroke_cpp):
yield char, (width, strokes)
@classmethod
def decode_stroke(kls, stroke, start_x):
for i in range(0, len(stroke), 2):
x = (stroke[i]-0x52-start_x)*STROKE_FONT_SCALE
y = (stroke[i+1]-0x52+FONT_OFFSET)*STROKE_FONT_SCALE
yield (x, y)
@classmethod
def decode_glyph(kls, data):
start_x, end_x = data[0]-0x52, data[1]-0x52
width = end_x - start_x
strokes = tuple(tuple(kls.decode_stroke(st, start_x)) for st in data[2:].split(b' R'))
return width*STROKE_FONT_SCALE, strokes
@classmethod
def load_glyphs(kls, newstroke_cpp):
it = iter(newstroke_cpp.splitlines())
for line in it:
if re.search(rb'char.*\*', line):
break
charcode = 0x20
for line in it:
if (match := re.search(rb'".*"', line)):
yield chr(charcode), kls.decode_glyph(match.group(0)[1:-1].replace(b'\\\\', b'\\'))
charcode += 1
else:
if b'}' in line:
break
if __name__ == '__main__':
import time
t1 = time.time()
Newstroke()
t2 = time.time()
print((t2-t1)*1000)

View file

@ -73,7 +73,10 @@ class GerberFile(CamFile):
self.apertures = [] # FIXME get rid of this? apertures are already in the objects. self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
self.file_attrs = file_attrs or {} self.file_attrs = file_attrs or {}
def to_excellon(self, plated=None): def sync_apertures(self):
self.apertures = list({id(obj.aperture): obj.aperture for obj in self.objects if hasattr(obj, 'aperture')}.values())
def to_excellon(self, plated=None, errors='raise'):
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines """ Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
@ -83,18 +86,26 @@ class GerberFile(CamFile):
for obj in self.objects: for obj in self.objects:
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \ if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
not isinstance(obj.aperture, apertures.CircleAperture): not isinstance(obj.aperture, apertures.CircleAperture):
raise ValueError(f'Cannot convert {obj} to excellon!') if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.')
elif errors == 'warn':
warnings.warn(f'Gerber to Excellon conversion: Cannot convert {obj} to excellon.')
continue
elif errors == 'ignore':
continue
else:
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
if not (new_tool := new_tools.get(obj.aperture)): if not (new_tool := new_tools.get(id(obj.aperture))):
# TODO plating? # TODO plating?
new_tool = new_tools[id(obj.aperture)] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit) new_tool = new_tools[id(obj.aperture)] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
new_objs.append(dataclasses.replace(obj, aperture=new_tool)) new_objs.append(dataclasses.replace(obj, aperture=new_tool))
return ExcellonFile(objects=new_objs, comments=self.comments) return ExcellonFile(objects=new_objs, comments=self.comments)
def to_gerber(self): def to_gerber(self, errors='raise'):
""" Counterpart to :py:meth:`~.excellon.ExcellonFile.to_gerber`. Does nothing and returns :py:obj:`self`. """ """ Counterpart to :py:meth:`~.excellon.ExcellonFile.to_gerber`. Does nothing and returns :py:obj:`self`. """
return return self
def merge(self, other, mode='above', keep_settings=False): def merge(self, other, mode='above', keep_settings=False):
""" Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets """ Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets
@ -110,6 +121,8 @@ class GerberFile(CamFile):
if other is None: if other is None:
return return
other = other.to_gerber()
if not keep_settings: if not keep_settings:
self.import_settings = None self.import_settings = None
self.comments += other.comments self.comments += other.comments
@ -217,6 +230,8 @@ class GerberFile(CamFile):
def _generate_statements(self, settings, drop_comments=True): def _generate_statements(self, settings, drop_comments=True):
""" Export this file as Gerber code, yields one str per line. """ """ Export this file as Gerber code, yields one str per line. """
self.sync_apertures()
yield 'G04 Gerber file generated by Gerbonara*' yield 'G04 Gerber file generated by Gerbonara*'
for name, value in self.file_attrs.items(): for name, value in self.file_attrs.items():
attrdef = ','.join([name, *map(str, value)]) attrdef = ','.join([name, *map(str, value)])
@ -247,7 +262,9 @@ class GerberFile(CamFile):
processed_macros = set() processed_macros = set()
aperture_map = {} aperture_map = {}
for number, aperture in enumerate(self.apertures, start=10): defined_apertures = {}
number = 10
for aperture in self.apertures:
if isinstance(aperture, apertures.ApertureMacroInstance): if isinstance(aperture, apertures.ApertureMacroInstance):
macro_def = am_stmt(aperture._rotated().macro) macro_def = am_stmt(aperture._rotated().macro)
@ -255,9 +272,15 @@ class GerberFile(CamFile):
processed_macros.add(macro_def) processed_macros.add(macro_def)
yield macro_def yield macro_def
yield f'%ADD{number}{aperture.to_gerber(settings)}*%' ap_def = aperture.to_gerber(settings)
if ap_def in defined_apertures:
aperture_map[id(aperture)] = defined_apertures[ap_def]
aperture_map[id(aperture)] = number else:
yield f'%ADD{number}{ap_def}*%'
defined_apertures[ap_def] = number
aperture_map[id(aperture)] = number
number += 1
def warn(msg, kls=SyntaxWarning): def warn(msg, kls=SyntaxWarning):
warnings.warn(msg, kls) warnings.warn(msg, kls)
@ -526,9 +549,11 @@ class GraphicsState:
yield '%LPD*%' if polarity_dark else '%LPC*%' yield '%LPD*%' if polarity_dark else '%LPC*%'
def set_aperture(self, aperture): def set_aperture(self, aperture):
if self.aperture != aperture: ap_id = self.aperture_map[id(aperture)]
old_ap_id = self.aperture_map.get(id(self.aperture), None)
if ap_id != old_ap_id:
self.aperture = aperture self.aperture = aperture
yield f'D{self.aperture_map[id(aperture)]}*' yield f'D{ap_id}*'
def set_current_point(self, point, unit=None): def set_current_point(self, point, unit=None):
point_mm = MM(point[0], unit), MM(point[1], unit) point_mm = MM(point[0], unit), MM(point[1], unit)

View file

@ -0,0 +1,5 @@
from ..utils import setup_svg
from ..cad import primitives
def test_route

View file

@ -335,7 +335,7 @@ def test_layer_classifier(ref_dir):
else: # not in file_map else: # not in file_map
assert (side, layer) not in stack assert (side, layer) not in stack
assert len(stack.drill_layers) == len(drill_files) assert len(list(stack.drill_layers)) == len(drill_files)
for filename, role in drill_files.items(): for filename, role in drill_files.items():
print('drill:', filename, role) print('drill:', filename, role)

View file

@ -17,8 +17,14 @@
# limitations under the License. # limitations under the License.
# #
import math
import random
import pytest import pytest
from ..cam import FileSettings from ..cam import FileSettings
from ..utils import convex_hull, point_in_polygon, setup_svg, Tag
from .utils import *
def test_zero_suppression(): def test_zero_suppression():
@ -103,3 +109,25 @@ def test_write_format_validation():
settings = FileSettings(number_format=fmt) settings = FileSettings(number_format=fmt)
settings.write_gerber_value(69.0) settings.write_gerber_value(69.0)
def test_convex_hull_and_point_in_polygon(tmpfile):
svg = tmpfile('Visualization', '.svg')
st = random.Random(0)
for _ in range(50):
for n in [*range(1, 10), 12, 15, 20, 30, 50, 300, 1000, 5000]:
w = math.sqrt(n) * 10
rd = lambda: round(st.random() * w)
rp = lambda: (rd(), rd())
points = {rp() for _ in range(n)}
hull_l = convex_hull(points)
hull = set(hull_l)
tags = [Tag('circle', cx=x, cy=y, r=0.2, fill=('red' if (x, y) in hull else 'black')) for x, y in points]
for (x0, y0), (x1, y1) in zip([hull_l[-1], *hull_l[:-1]], hull_l):
tags.append(Tag('path', d=f'M {x0},{y0} L {x1},{y1}', stroke_width='0.1', stroke='red', fill='none'))
svg.write_text(str(setup_svg(tags, bounds=((0, 0), (w, w)), margin=1)))
# all hull corners must be in the set of original points
assert not (hull-points)
for p in points-hull:
assert point_in_polygon(p, hull_l)

View file

@ -28,6 +28,7 @@ This module provides utility functions for working with Gerber and Excellon file
import os import os
import re import re
import textwrap import textwrap
from functools import reduce
from enum import Enum from enum import Enum
import math import math
@ -396,6 +397,33 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy) return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)
def convex_hull(points):
'''
Returns points on convex hull in CCW order according to Graham's scan algorithm.
By Tom Switzer <thomas.switzer@gmail.com>.
'''
# https://gist.github.com/arthur-e/5cf52962341310f438e96c1f3c3398b8
TURN_LEFT, TURN_RIGHT, TURN_NONE = (1, -1, 0)
def cmp(a, b):
return (a > b) - (a < b)
def turn(p, q, r):
return cmp((q[0] - p[0])*(r[1] - p[1]) - (r[0] - p[0])*(q[1] - p[1]), 0)
def keep_left(hull, r):
while len(hull) > 1 and turn(hull[-2], hull[-1], r) != TURN_LEFT:
hull.pop()
if not len(hull) or hull[-1] != r:
hull.append(r)
return hull
points = sorted(points)
l = reduce(keep_left, points, [])
u = reduce(keep_left, reversed(points), [])
return l.extend(u[i] for i in range(1, len(u) - 1)) or l
def point_line_distance(l1, l2, p): def point_line_distance(l1, l2, p):
""" Calculate distance between infinite line through l1 and l2, and point p. """ """ Calculate distance between infinite line through l1 and l2, and point p. """
# https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
@ -414,7 +442,7 @@ def svg_arc(old, new, center, clockwise):
:rtype: str :rtype: str
""" """
r = math.hypot(*center) r = float(math.hypot(*center))
# invert sweep flag since the svg y axis is mirrored # invert sweep flag since the svg y axis is mirrored
sweep_flag = int(not clockwise) sweep_flag = int(not clockwise)
# In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc" # In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
@ -423,17 +451,20 @@ def svg_arc(old, new, center, clockwise):
intermediate = old[0] + 2*center[0], old[1] + 2*center[1] intermediate = old[0] + 2*center[0], old[1] + 2*center[1]
# Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of # Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of
# a circular cutin # a circular cutin
return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {intermediate[0]:.6} {intermediate[1]:.6} ' +\ return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(intermediate[0]):.6} {float(intermediate[1]):.6} ' +\
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}' f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}'
else: # normal case else: # normal case
d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1])) d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
large_arc = int((d < 0) == clockwise) large_arc = int((d < 0) == clockwise)
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}' return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}'
def svg_rotation(angle_rad, cx=0, cy=0): def svg_rotation(angle_rad, cx=0, cy=0):
return f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})' if math.isclose(angle_rad, 0.0, abs_tol=1e-3):
return {}
else:
return {'transform': f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'}
def setup_svg(tags, bounds, margin=0, arg_unit=MM, svg_unit=MM, pagecolor='white', tag=Tag, inkscape=False): def setup_svg(tags, bounds, margin=0, arg_unit=MM, svg_unit=MM, pagecolor='white', tag=Tag, inkscape=False):
(min_x, min_y), (max_x, max_y) = bounds (min_x, min_y), (max_x, max_y) = bounds
@ -471,3 +502,42 @@ def setup_svg(tags, bounds, margin=0, arg_unit=MM, svg_unit=MM, pagecolor='white
**namespaces, **namespaces,
root=True) root=True)
def point_in_polygon(point, poly):
# https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon
# https://wrfranklin.org/Research/Short_Notes/pnpoly.html
if not poly:
return False
res = False
tx, ty = point
xp, yp = poly[-1]
for x, y in poly:
if yp == ty == y and ((x > tx) != (xp > tx)): # test point on horizontal segment
return True
if xp == tx == x and ((y > ty) != (yp > ty)): # test point on vertical segment
return True
if ((y > ty) != (yp > ty)):
tmp = ((xp-x) * (ty-y) / (yp-y) + x)
if tx == tmp: # test point on diagonal segment
return True
elif tx < tmp:
res = not res
xp, yp = x, y
return res
def bbox_intersect(a, b):
if a is None or b is None:
return False
(xa_min, ya_min), (xa_max, ya_max) = a
(xb_min, yb_min), (xb_max, yb_max) = b
x_overlap = not (xa_max < xb_min or xb_max < xa_min)
y_overlap = not (ya_max < yb_min or yb_max < ya_min)
return x_overlap and y_overlap