Compare commits
29 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60ae26db2 | ||
|
|
f74bd30c0f | ||
|
|
c9dff5450f | ||
|
|
3b5fb41ecb | ||
|
|
10cd29b96c | ||
|
|
48d4aeee94 | ||
|
|
6378a91f36 | ||
|
|
ef2864cfb3 | ||
|
|
0a059353d7 | ||
|
|
51327ccfeb | ||
|
|
c10616094c | ||
|
|
4c558f8111 | ||
|
|
ee0c1d38e6 | ||
|
|
513f6ebf1b | ||
|
|
5cf9837484 | ||
|
|
d437e06325 | ||
|
|
495ae6e932 | ||
|
|
15867450d9 | ||
|
|
82fcc24456 | ||
|
|
a877261256 | ||
|
|
db2bacebc7 | ||
|
|
8d4430ea61 | ||
|
|
909766a3a0 | ||
|
|
845224e2d6 | ||
|
|
0ae72f3159 | ||
|
|
84ec7b26e6 | ||
|
|
36e355cbd8 | ||
|
|
0037195543 | ||
|
|
2a3deb6c00 |
21 changed files with 68586 additions and 142 deletions
|
|
@ -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}'
|
||||||
|
|
|
||||||
|
|
@ -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', [
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
0
gerbonara/cad/__init__.py
Normal file
0
gerbonara/cad/__init__.py
Normal file
700
gerbonara/cad/primitives.py
Normal file
700
gerbonara/cad/primitives.py
Normal 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
525
gerbonara/cad/protoboard.py
Normal 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
154
gerbonara/cad/protoserve.py
Normal 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()
|
||||||
|
|
||||||
941
gerbonara/cad/protoserve_data/protoserve.html
Normal file
941
gerbonara/cad/protoserve_data/protoserve.html
Normal 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>
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
0
gerbonara/data/__init__.py
Normal file
0
gerbonara/data/__init__.py
Normal file
65743
gerbonara/data/newstroke_font.cpp
Normal file
65743
gerbonara/data/newstroke_font.cpp
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
93
gerbonara/newstroke.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
5
gerbonara/tests/test_cad.py
Normal file
5
gerbonara/tests/test_cad.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
|
||||||
|
from ..utils import setup_svg
|
||||||
|
from ..cad import primitives
|
||||||
|
|
||||||
|
def test_route
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue