summaryrefslogtreecommitdiff
path: root/gerbonara/cad
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara/cad')
-rw-r--r--gerbonara/cad/data/__init__.py0
-rw-r--r--gerbonara/cad/kicad/__init__.py1
-rw-r--r--gerbonara/cad/kicad/base_types.py108
-rw-r--r--gerbonara/cad/kicad/footprints.py100
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py91
-rw-r--r--gerbonara/cad/kicad/pcb.py119
-rw-r--r--gerbonara/cad/kicad/primitives.py33
-rw-r--r--gerbonara/cad/kicad/schematic.py13
-rw-r--r--gerbonara/cad/kicad/sexp_mapper.py18
-rw-r--r--gerbonara/cad/kicad/symbols.py11
-rw-r--r--gerbonara/cad/primitives.py66
-rw-r--r--gerbonara/cad/protoboard.py869
-rw-r--r--gerbonara/cad/protoserve.py40
-rw-r--r--gerbonara/cad/protoserve_data/__init__.py0
-rw-r--r--gerbonara/cad/protoserve_data/protoserve.html133
15 files changed, 1186 insertions, 416 deletions
diff --git a/gerbonara/cad/data/__init__.py b/gerbonara/cad/data/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gerbonara/cad/data/__init__.py
diff --git a/gerbonara/cad/kicad/__init__.py b/gerbonara/cad/kicad/__init__.py
index e69de29..8b13789 100644
--- a/gerbonara/cad/kicad/__init__.py
+++ b/gerbonara/cad/kicad/__init__.py
@@ -0,0 +1 @@
+
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
index 1161996..d800a36 100644
--- a/gerbonara/cad/kicad/base_types.py
+++ b/gerbonara/cad/kicad/base_types.py
@@ -9,7 +9,8 @@ from itertools import cycle
from .sexp import *
from .sexp_mapper import *
from ...newstroke import Newstroke
-from ...utils import rotate_point, Tag, MM
+from ...utils import rotate_point, sum_bounds, Tag, MM
+from ...layers import LayerStack
from ... import apertures as ap
from ... import graphic_objects as go
@@ -37,11 +38,39 @@ LAYER_MAP_K2G = {
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
+class BBoxMixin:
+ def bounding_box(self, unit=MM):
+ if not hasattr(self, '_bounding_box'):
+ (min_x, min_y), (max_x, max_y) = sum_bounds(fe.bounding_box(unit) for fe in self.render())
+ # Convert back from gerbonara's coordinates to kicad coordinates.
+ self._bounding_box = (min_x, -max_y), (max_x, -min_y)
+
+ return self._bounding_box
+
+
+@sexp_type('uuid')
+class UUID:
+ value: str = field(default_factory=uuid.uuid4)
+
+ def __deepcopy__(self, memo):
+ return UUID()
+
+ def __after_parse__(self, parent):
+ self.value = str(self.value)
+
+ def before_sexp(self):
+ self.value = str(self.value)
+
+ def bump(self):
+ self.value = uuid.uuid4()
+
+
@sexp_type('group')
class Group:
name: str = ""
- id: Named(str) = ""
- members: Named(List(str)) = field(default_factory=list)
+ id: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
+ members: Named(Array(str)) = field(default_factory=list)
@sexp_type('color')
@@ -94,10 +123,18 @@ class Stroke:
return attrs
+class WidthMixin:
+ def __post_init__(self):
+ if self.width is None:
+ self.width = self.stroke.width
+ else:
+ self.stroke = Stroke(self.width)
+
+
class Dasher:
def __init__(self, obj):
if obj.stroke:
- w = obj.stroke.width if obj.stroke.width is not None else 0.254
+ w = obj.stroke.width if obj.stroke.width not in (None, 0, 0.0) else 0.254
t = obj.stroke.type
else:
w = obj.width or 0
@@ -241,7 +278,14 @@ class XYCoord:
@sexp_type('pts')
class PointList:
- xy : List(XYCoord) = field(default_factory=list)
+ @classmethod
+ def __map__(kls, obj, parent=None):
+ _tag, *values = obj
+ return [map_sexp(XYCoord, elem, parent=parent) for elem in values]
+
+ @classmethod
+ def __sexp__(kls, value):
+ yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
@sexp_type('arc')
@@ -263,6 +307,29 @@ class ArcPointList:
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
+@sexp_type('net')
+class Net:
+ index: int = 0
+ name: str = ''
+
+
+class NetMixin:
+ def reset_net(self):
+ self.net = Net()
+
+ @property
+ def net_index(self):
+ if self.net is None:
+ return 0
+ return self.net.index
+
+ @property
+ def net_name(self):
+ if self.net is None:
+ return ''
+ return self.net.name
+
+
@sexp_type('xyz')
class XYZCoord:
x: float = 0
@@ -298,8 +365,8 @@ class FontSpec:
face: Named(str) = None
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
thickness: Named(float) = None
- bold: Flag() = False
- italic: Flag() = False
+ bold: OmitDefault(Named(LegacyCompatibleFlag())) = False
+ italic: OmitDefault(Named(LegacyCompatibleFlag())) = False
line_spacing: Named(float) = None
@@ -327,8 +394,8 @@ class Justify:
@sexp_type('effects')
class TextEffect:
font: FontSpec = field(default_factory=FontSpec)
- hide: Flag() = False
justify: OmitDefault(Justify) = field(default_factory=Justify)
+ hide: OmitDefault(Named(LegacyCompatibleFlag())) = False
class TextMixin:
@@ -469,23 +536,6 @@ class Timestamp:
self.value = uuid.uuid4()
-@sexp_type('uuid')
-class UUID:
- value: str = field(default_factory=uuid.uuid4)
-
- def __deepcopy__(self, memo):
- return UUID()
-
- def __after_parse__(self, parent):
- self.value = str(self.value)
-
- def before_sexp(self):
- self.value = Atom(str(self.value))
-
- def bump(self):
- self.value = uuid.uuid4()
-
-
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)
@@ -522,11 +572,13 @@ class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
- at: AtPos = field(default_factory=AtPos)
+ at: AtPos = None
+ unlocked: OmitDefault(Named(YesNoAtom())) = True
layer: Named(str) = None
- hide: Flag() = False
+ hide: OmitDefault(Named(YesNoAtom())) = False
+ uuid: UUID = None
tstamp: Timestamp = None
- effects: TextEffect = field(default_factory=TextEffect)
+ effects: OmitDefault(TextEffect) = field(default_factory=TextEffect)
_ : SEXP_END = None
parent: object = None
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index 9debaa9..1d7ee08 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -21,6 +21,7 @@ from . import graphical_primitives as gr
from ..primitives import Positioned
+from ... import __version__
from ... import graphic_primitives as gp
from ... import graphic_objects as go
from ... import apertures as ap
@@ -54,8 +55,9 @@ class Text:
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
text: str = ""
at: AtPos = field(default_factory=AtPos)
- unlocked: Flag() = False
+ unlocked: OmitDefault(Named(YesNoAtom())) = False
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None
@@ -72,12 +74,14 @@ class TextBox:
locked: Flag() = False
text: str = None
start: Rename(XYCoord) = None
- end: Named(XYCoord) = None
+ end: Rename(XYCoord) = None
pts: PointList = None
angle: Named(float) = 0.0
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
+ border: Named(YesNoAtom()) = False
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
@@ -90,6 +94,7 @@ class Line:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
@@ -113,6 +118,7 @@ class Rectangle:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
@@ -146,6 +152,7 @@ class Circle:
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
@@ -184,6 +191,7 @@ class Arc:
width: Named(float) = None
stroke: Stroke = None
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
locked: Flag() = False
tstamp: Timestamp = None
@@ -235,8 +243,9 @@ class Arc:
@sexp_type('fp_poly')
class Polygon:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
@@ -244,13 +253,13 @@ class Polygon:
tstamp: Timestamp = None
def render(self, variables=None, cache=None):
- if len(self.pts.xy) < 2:
+ if len(self.pts) < 2:
return
dasher = Dasher(self)
- start = self.pts.xy[0]
+ start = self.pts[0]
dasher.move(start.x, start.y)
- for point in self.pts.xy[1:]:
+ for point in self.pts[1:]:
dasher.line(point.x, point.y)
if dasher.width > 0:
@@ -259,13 +268,14 @@ class Polygon:
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
if self.fill == Atom.solid:
- yield go.Region([(pt.x, -pt.y) for pt in self.pts.xy], unit=MM)
+ yield go.Region([(pt.x, -pt.y) for pt in self.pts], unit=MM)
@sexp_type('fp_curve')
class Curve:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
@@ -302,8 +312,9 @@ class Dimension:
locked: Flag() = False
type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None
layer: Named(str) = None
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
height: Named(float) = None
orientation: Named(int) = 0
leader_length: Named(float) = None
@@ -323,12 +334,6 @@ class Drill:
offset: Rename(XYCoord) = None
-@sexp_type('net')
-class NetDef:
- number: int = None
- name: str = None
-
-
@sexp_type('options')
class CustomPadOptions:
clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
@@ -365,7 +370,7 @@ class Chamfer:
@sexp_type('pad')
-class Pad:
+class Pad(NetMixin):
number: str = None
type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None
shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
@@ -375,15 +380,16 @@ class Pad:
drill: Drill = None
layers: Named(Array(str)) = field(default_factory=list)
properties: List(Property) = field(default_factory=list)
- remove_unused_layers: Wrap(Flag()) = False
- keep_end_layers: Wrap(Flag()) = False
+ remove_unused_layers: Named(YesNoAtom()) = False
+ keep_end_layers: Named(YesNoAtom()) = False
+ uuid: UUID = field(default_factory=UUID)
rect_delta: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None
thermal_bridge_angle: Named(int) = 45
thermal_bridge_width: Named(float) = 0.5
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
- net: NetDef = None
+ net: Net = None
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
@@ -399,7 +405,7 @@ class Pad:
options: OmitDefault(CustomPadOptions) = None
primitives: OmitDefault(CustomPadPrimitives) = None
_: SEXP_END = None
- footprint: object = None
+ footprint: object = field(repr=False, default=None)
def __after_parse__(self, parent=None):
self.layers = unfuck_layers(self.layers)
@@ -595,6 +601,7 @@ class Pad:
@sexp_type('model')
class Model:
name: str = ''
+ hide: Flag() = False
at: Named(XYZCoord) = field(default_factory=XYZCoord)
offset: Named(XYZCoord) = field(default_factory=XYZCoord)
scale: Named(XYZCoord) = field(default_factory=XYZCoord)
@@ -606,7 +613,9 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
class Footprint:
name: str = None
_version: Named(int, name='version') = 20221018
- generator: Named(Atom) = Atom.gerbonara
+ uuid: UUID = field(default_factory=UUID)
+ generator: Named(str) = Atom.gerbonara
+ generator_version: Named(str) = __version__
locked: Flag() = False
placed: Flag() = False
layer: Named(str) = 'F.Cu'
@@ -643,11 +652,11 @@ class Footprint:
pads: List(Pad) = field(default_factory=list)
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
+ embedded_fonts: Named(YesNoAtom()) = False
models: List(Model) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
- _bounding_box: tuple = None
- board: object = None
+ board: object = field(repr=False, default=None)
def __after_parse__(self, parent):
for pad in self.pads:
@@ -694,6 +703,10 @@ class Footprint:
if not self.property_value('Description', None):
self.set_property('Description', self.descr or '', 0, 0, 0)
+ def reset_nets(self):
+ for pad in self.pads:
+ pad.reset_net()
+
@property
def pads_by_number(self):
return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number}
@@ -720,6 +733,14 @@ class Footprint:
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
+ def copy_placement(self, template):
+ # Fix up rotation of pads - KiCad saves each pad's rotation in *absolute* coordinates, not relative to the
+ # footprint. Because we overwrite the footprint's rotation below, we have to first fix all pads to match the
+ # new rotation.
+ self.rotate(math.radians(template.at.rotation - self.at.rotation))
+ self.at = copy.copy(template.at)
+ self.side = template.side
+
@property
def version(self):
return self._version
@@ -804,7 +825,7 @@ class Footprint:
self.layer = flip_layer(self.layer)
for obj in self.objects():
- if hasattr(obj, 'layer'):
+ if getattr(obj, 'layer', None) is not None:
obj.layer = flip_layer(obj.layer)
if hasattr(obj, 'layers'):
@@ -814,8 +835,9 @@ class Footprint:
obj.effects.justify.mirror = not obj.effects.justify.mirror
for obj in self.properties:
- obj.effects.justify.mirror = not obj.effects.justify.mirror
- obj.layer = flip_layer(obj.layer)
+ if obj.layer is not None:
+ obj.effects.justify.mirror = not obj.effects.justify.mirror
+ obj.layer = flip_layer(obj.layer)
@property
def single_sided(self):
@@ -854,19 +876,20 @@ class Footprint:
around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """
if (cx, cy) != (None, None):
x, y = self.at.x-cx, self.at.y-cy
- self.at.x = math.cos(angle)*x - math.sin(angle)*y + cx
- self.at.y = math.sin(angle)*x + math.cos(angle)*y + cy
+ self.at.x = math.cos(-angle)*x - math.sin(-angle)*y + cx
+ self.at.y = math.sin(-angle)*x + math.cos(-angle)*y + cy
- self.at.rotation = (self.at.rotation - math.degrees(angle)) % 360
+ self.at.rotation = (self.at.rotation + math.degrees(angle)) % 360
for pad in self.pads:
- pad.at.rotation = (pad.at.rotation - math.degrees(angle)) % 360
+ pad.at.rotation = (pad.at.rotation + math.degrees(angle)) % 360
for prop in self.properties:
- prop.at.rotation = (prop.at.rotation - math.degrees(angle)) % 360
+ if prop.at is not None:
+ prop.at.rotation = (prop.at.rotation + math.degrees(angle)) % 360
for text in self.texts:
- text.at.rotation = (text.at.rotation - math.degrees(angle)) % 360
+ text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360
def set_rotation(self, angle):
old_deg = self.at.rotation
@@ -877,7 +900,8 @@ class Footprint:
pad.at.rotation = (pad.at.rotation + delta) % 360
for prop in self.properties:
- prop.at.rotation = (prop.at.rotation + delta) % 360
+ if prop.at is not None:
+ prop.at.rotation = (prop.at.rotation + delta) % 360
for text in self.texts:
text.at.rotation = (text.at.rotation + delta) % 360
@@ -902,13 +926,13 @@ class Footprint:
y += self.at.y
rotation += math.radians(self.at.rotation)
- for obj in self.objects(pads=False, text=text, zones=False):
+ for obj in self.objects(pads=False, text=text, zones=False, groups=False):
if not (layer := layer_map.get(obj.layer)):
continue
for fe in obj.render(variables=variables):
fe.rotate(rotation)
- fe.offset(x, -y, MM)
+ fe.offset(x, y, MM)
layer_stack[layer].objects.append(fe)
for obj in self.pads:
@@ -940,7 +964,7 @@ class Footprint:
for fe in obj.render(margin=margin, cache=cache):
fe.rotate(rotation)
- fe.offset(x, -y, MM)
+ fe.offset(x, y, MM)
if isinstance(fe, go.Flash) and fe.aperture:
fe.aperture = fe.aperture.rotated(rotation)
layer_stack[layer_map[layer]].objects.append(fe)
@@ -948,7 +972,7 @@ class Footprint:
for obj in self.pads:
for fe in obj.render_drill():
fe.rotate(rotation)
- fe.offset(x, -y, MM)
+ fe.offset(x, y, MM)
if obj.type == Atom.np_thru_hole:
layer_stack.drill_npth.append(fe)
@@ -956,7 +980,7 @@ class Footprint:
layer_stack.drill_pth.append(fe)
def bounding_box(self, unit=MM):
- if not self._bounding_box:
+ if not hasattr(self, '_bounding_box'):
stack = LayerStack()
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index 94a61a4..f86fcc7 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -9,7 +9,7 @@ from .primitives import *
from ... import graphic_objects as go
from ... import apertures as ap
from ...newstroke import Newstroke
-from ...utils import rotate_point, MM
+from ...utils import rotate_point, MM, arc_bounds
@sexp_type('layer')
class TextLayer:
@@ -18,10 +18,11 @@ class TextLayer:
@sexp_type('gr_text')
-class Text(TextMixin):
+class Text(TextMixin, BBoxMixin):
text: str = ''
at: AtPos = field(default_factory=AtPos)
layer: TextLayer = field(default_factory=TextLayer)
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
render_cache: RenderCache = None
@@ -31,14 +32,15 @@ class Text(TextMixin):
@sexp_type('gr_text_box')
-class TextBox:
+class TextBox(BBoxMixin):
locked: Flag() = False
text: str = ''
start: Named(XYCoord) = None
end: Named(XYCoord) = None
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
angle: OmitDefault(Named(float)) = 0.0
layer: Named(str) = ""
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
@@ -53,7 +55,7 @@ class TextBox:
raise ValueError('Vector font text with empty render cache')
for poly in render_cache.polygons:
- reg = go.Region([(p.x, -p.y) for p in poly.pts.xy], unit=MM)
+ reg = go.Region([(p.x, -p.y) for p in poly.pts], unit=MM)
if self.stroke:
if self.stroke.type not in (None, Atom.default, Atom.solid):
@@ -69,13 +71,14 @@ class TextBox:
@sexp_type('gr_line')
-class Line:
+class Line(WidthMixin):
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def rotate(self, angle, cx=None, cy=None):
@@ -98,6 +101,12 @@ class Line:
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
+ def bounding_box(self, unit=MM):
+ x_min, x_max = min(self.start.x, self.end.x), max(self.start.x, self.end.x)
+ y_min, y_max = min(self.start.y, self.end.y), max(self.start.y, self.end.y)
+ w = self.stroke.width if self.stroke else self.width
+ return (x_min-w, y_max-w), (x_max+w, y_max+w)
+
@sexp_type('fill')
class FillMode:
@@ -113,13 +122,14 @@ class FillMode:
yield [Atom.fill, Atom.solid if value else Atom.none]
@sexp_type('gr_rect')
-class Rectangle:
+class Rectangle(BBoxMixin, WidthMixin):
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = False
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
@@ -130,9 +140,9 @@ class Rectangle:
if self.fill:
yield rect
- if self.width:
+ if (w := self.stroke.width if self.stroke else self.width):
# FIXME stroke support
- yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
+ yield from rect.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
@property
def top_left(self):
@@ -145,21 +155,23 @@ class Rectangle:
@sexp_type('gr_circle')
-class Circle:
+class Circle(BBoxMixin, WidthMixin):
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = False
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
r = math.dist((self.center.x, -self.center.y), (self.end.x, -self.end.y))
- aperture = ap.CircleAperture(self.width or 0, unit=MM)
+ w = self.stroke.width if self.stroke else self.width
+ aperture = ap.CircleAperture(w or 0, unit=MM)
arc = go.Arc.from_circle(self.center.x, -self.center.y, r, aperture=aperture, unit=MM)
- if self.width:
+ if w:
# FIXME stroke support
yield arc
@@ -170,15 +182,20 @@ class Circle:
self.center = self.center.with_offset(x, y)
self.end = self.end.with_offset(x, y)
+ def rotate(self, angle, cx=0, cy=0):
+ self.center = self.center.with_rotation(angle, cx, cy)
+ self.end = self.end.with_rotation(angle, cx, cy)
+
@sexp_type('gr_arc')
-class Arc:
+class Arc(WidthMixin, BBoxMixin):
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
_: SEXP_END = None
center: XYCoord = None
@@ -192,35 +209,35 @@ class Arc:
self.mid = center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
- def rotate(self, angle, cx=None, cy=None):
- self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
- self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
- self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
-
def render(self, variables=None):
- # FIXME stroke support
- if not self.width:
+ if not (w := self.stroke.width if self.stroke else self.width):
return
- aperture = ap.CircleAperture(self.width, unit=MM)
+ aperture = ap.CircleAperture(w, unit=MM)
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
- (cx, cy), _r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
- yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=False, unit=MM)
+ (cx, cy), _r, clockwise = kicad_mid_to_center_arc(self.mid, self.start, self.end)
+ yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=not clockwise, unit=MM)
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.mid = self.mid.with_offset(x, y)
self.end = self.end.with_offset(x, y)
+ def rotate(self, angle, cx=None, cy=None):
+ self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
+ self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
+ self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
+
@sexp_type('gr_poly')
-class Polygon:
+class Polygon(BBoxMixin, WidthMixin):
pts: ArcPointList = field(default_factory=list)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = True
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
@@ -236,35 +253,40 @@ class Polygon:
else: # base_types.Arc
points.append((point_or_arc.start.x, -point_or_arc.start.y))
points.append((point_or_arc.end.x, -point_or_arc.end.y))
- (cx, cy), _r = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
- centers.append((False, (cx, -cy)))
+ (cx, cy), _r, clockwise = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
+ centers.append((not clockwise, (cx, -cy)))
reg = go.Region(points, centers, unit=MM)
reg.close()
+ w = self.stroke.width if self.stroke else self.width
# FIXME stroke support
- if self.width and self.width >= 0.005 or self.stroke.width and self.stroke.width > 0.005:
- yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
+ if w and w >= 0.005:
+ yield from reg.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
if self.fill:
yield reg
def offset(self, x=0, y=0):
- self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
+ self.pts = [pt.with_offset(x, y) for pt in self.pts]
+
+ def rotate(self, angle, cx=0, cy=0):
+ self.pts = [pt.with_rotation(angle, cx, cy) for pt in self.pts]
@sexp_type('gr_curve')
-class Curve:
- pts: PointList = field(default_factory=PointList)
+class Curve(BBoxMixin, WidthMixin):
+ pts: PointList = field(default_factory=list)
layer: Named(str) = None
width: Named(float) = None
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
def offset(self, x=0, y=0):
- self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
+ self.pts =[pt.with_offset(x, y) for pt in self.pts]
@sexp_type('gr_bbox')
@@ -319,8 +341,9 @@ class Dimension:
locked: Flag() = False
dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
layer: Named(str) = 'Dwgs.User'
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = field(default_factory=Timestamp)
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
height: Named(float) = None
orientation: Named(int) = None
leader_length: Named(float) = None
@@ -332,5 +355,5 @@ class Dimension:
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
def offset(self, x=0, y=0):
- self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
+ self.pts = [pt.with_offset(x, y) for pt in self.pts]
diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py
index c5b2bd3..6bbcad1 100644
--- a/gerbonara/cad/kicad/pcb.py
+++ b/gerbonara/cad/kicad/pcb.py
@@ -59,13 +59,14 @@ def gn_layer_to_kicad(layer, flip=False):
@sexp_type('general')
class GeneralSection:
thickness: Named(float) = 1.60
+ legacy_teardrops: Named(YesNoAtom()) = False
@sexp_type('layers')
class LayerSettings:
index: int = 0
canonical_name: str = None
- layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user) = Atom.signal
+ layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user, Atom.auxiliary) = Atom.signal
custom_name: str = None
@@ -92,43 +93,44 @@ class StackupSettings:
edge_plating: Named(YesNoAtom()) = None
-TFBool = YesNoAtom(yes=Atom.true, no=Atom.false)
-
@sexp_type('pcbplotparams')
class ExportSettings:
layerselection: Named(Atom) = None
plot_on_all_layers_selection: Named(Atom) = None
- disableapertmacros: Named(TFBool) = False
- usegerberextensions: Named(TFBool) = True
- usegerberattributes: Named(TFBool) = True
- usegerberadvancedattributes: Named(TFBool) = True
- creategerberjobfile: Named(TFBool) = True
+ disableapertmacros: Named(YesNoAtom()) = False
+ usegerberextensions: Named(YesNoAtom()) = True
+ usegerberattributes: Named(YesNoAtom()) = True
+ usegerberadvancedattributes: Named(YesNoAtom()) = True
+ creategerberjobfile: Named(YesNoAtom()) = True
dashed_line_dash_ratio: Named(float) = 12.0
dashed_line_gap_ratio: Named(float) = 3.0
- svguseinch: Named(TFBool) = False
+ svguseinch: Named(YesNoAtom()) = False
svgprecision: Named(float) = 4
- excludeedgelayer: Named(TFBool) = False
- plotframeref: Named(TFBool) = False
- viasonmask: Named(TFBool) = False
+ excludeedgelayer: Named(YesNoAtom()) = False
+ plotframeref: Named(YesNoAtom()) = False
+ viasonmask: Named(YesNoAtom()) = False
mode: Named(int) = 1
- useauxorigin: Named(TFBool) = False
+ useauxorigin: Named(YesNoAtom()) = False
hpglpennumber: Named(int) = 1
hpglpenspeed: Named(int) = 20
hpglpendiameter: Named(float) = 15.0
- pdf_front_fp_property_popups: Named(TFBool) = True
- pdf_back_fp_property_popups: Named(TFBool) = True
- dxfpolygonmode: Named(TFBool) = True
- dxfimperialunits: Named(TFBool) = False
- dxfusepcbnewfont: Named(TFBool) = True
- psnegative: Named(TFBool) = False
- psa4output: Named(TFBool) = False
- plotreference: Named(TFBool) = True
- plotvalue: Named(TFBool) = True
- plotinvisibletext: Named(TFBool) = False
- sketchpadsonfab: Named(TFBool) = False
- subtractmaskfromsilk: Named(TFBool) = False
+ pdf_front_fp_property_popups: Named(YesNoAtom()) = True
+ pdf_back_fp_property_popups: Named(YesNoAtom()) = True
+ pdf_metadata: Named(YesNoAtom()) = True
+ dxfpolygonmode: Named(YesNoAtom()) = True
+ dxfimperialunits: Named(YesNoAtom()) = False
+ dxfusepcbnewfont: Named(YesNoAtom()) = True
+ psnegative: Named(YesNoAtom()) = False
+ psa4output: Named(YesNoAtom()) = False
+ plotreference: Named(YesNoAtom()) = True
+ plotvalue: Named(YesNoAtom()) = True
+ plotfptext: Named(YesNoAtom()) = True
+ plotinvisibletext: Named(YesNoAtom()) = False
+ sketchpadsonfab: Named(YesNoAtom()) = False
+ plotpadnumbers: Named(YesNoAtom()) = False
+ subtractmaskfromsilk: Named(YesNoAtom()) = False
outputformat: Named(int) = 1
- mirror: Named(TFBool) = False
+ mirror: Named(YesNoAtom()) = False
drillshape: Named(int) = 0
scaleselection: Named(int) = 1
outputdirectory: Named(str) = "gerber"
@@ -141,26 +143,23 @@ class BoardSetup:
solder_mask_min_width: Named(float) = None
pad_to_past_clearance: Named(float) = None
pad_to_paste_clearance_ratio: Named(float) = None
+ allow_soldermask_bridges_in_footprints: Named(YesNoAtom()) = False
+ tenting: Named(Array(AtomChoice(Atom.front, Atom.back))) = field(default_factory=lambda: [Atom.front, Atom.back])
aux_axis_origin: Rename(XYCoord) = None
grid_origin: Rename(XYCoord) = None
export_settings: ExportSettings = field(default_factory=ExportSettings)
-@sexp_type('net')
-class Net:
- index: int = 0
- name: str = ''
-
-
@sexp_type('segment')
-class TrackSegment:
+class TrackSegment(BBoxMixin):
start: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
width: Named(float) = 0.5
layer: Named(str) = 'F.Cu'
locked: Flag() = False
net: Named(int) = 0
- tstamp: Timestamp = field(default_factory=Timestamp)
+ uuid: UUID = field(default_factory=UUID)
+ tstamp: Timestamp = None
@classmethod
def from_footprint_line(kls, line, flip=False):
@@ -195,7 +194,7 @@ class TrackSegment:
@sexp_type('arc')
-class TrackArc:
+class TrackArc(BBoxMixin):
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
@@ -203,14 +202,15 @@ class TrackArc:
layer: Named(str) = 'F.Cu'
locked: Flag() = False
net: Named(int) = 0
- tstamp: Timestamp = field(default_factory=Timestamp)
+ uuid: UUID = field(default_factory=UUID)
+ tstamp: Timestamp = None
_: SEXP_END = None
center: XYCoord = None
def __post_init__(self):
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
- self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
+ self.mid = XYCoord(self.mid) if self.center is None else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
@property
@@ -239,7 +239,7 @@ class TrackArc:
@sexp_type('via')
-class Via:
+class Via(BBoxMixin):
via_type: AtomChoice(Atom.blind, Atom.micro) = None
locked: Flag() = False
at: Rename(XYCoord) = field(default_factory=XYCoord)
@@ -248,9 +248,10 @@ class Via:
layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu'])
remove_unused_layers: Flag() = False
keep_end_layers: Flag() = False
- free: Wrap(Flag()) = False
+ free: Named(YesNoAtom()) = False
net: Named(int) = 0
- tstamp: Timestamp = field(default_factory=Timestamp)
+ uuid: UUID = field(default_factory=UUID)
+ tstamp: Timestamp = None
@classmethod
def from_pad(kls, pad):
@@ -307,7 +308,8 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('kicad_pcb')
class Board:
_version: Named(int, name='version') = 20230517
- generator: Named(Atom) = Atom.gerbonara
+ generator: Named(str) = Atom.gerbonara
+ generator_version: Named(str) = Atom.gerbonara
general: GeneralSection = None
page: PageSettings = None
layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list)
@@ -333,10 +335,10 @@ class Board:
# Other stuff
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
+ embedded_fonts: Named(YesNoAtom()) = False
_ : SEXP_END = None
original_filename: str = None
- _bounding_box: tuple = None
_trace_index: rtree.index.Index = None
_trace_index_map: dict = None
@@ -375,15 +377,15 @@ class Board:
(47, 'F.CrtYd', 'user', 'F.Courtyard'),
(48, 'B.Fab', 'user', None),
(49, 'F.Fab', 'user', None),
- (50, 'User.1', 'user', None),
- (51, 'User.2', 'user', None),
- (52, 'User.3', 'user', None),
- (53, 'User.4', 'user', None),
- (54, 'User.5', 'user', None),
- (55, 'User.6', 'user', None),
- (56, 'User.7', 'user', None),
- (57, 'User.8', 'user', None),
- (58, 'User.9', 'user', None)]]
+ (50, 'User.1', 'auxiliary', None),
+ (51, 'User.2', 'auxiliary', None),
+ (52, 'User.3', 'auxiliary', None),
+ (53, 'User.4', 'auxiliary', None),
+ (54, 'User.5', 'auxiliary', None),
+ (55, 'User.6', 'auxiliary', None),
+ (56, 'User.7', 'auxiliary', None),
+ (57, 'User.8', 'auxiliary', None),
+ (58, 'User.9', 'auxiliary', None)]]
def rebuild_trace_index(self):
@@ -660,11 +662,11 @@ class Board:
for fp in self.footprints:
if name and not match_filter(name, fp.name):
continue
- if value and not match_filter(value, fp.properties.get('value', '')):
+ if value and not match_filter(value, fp.value):
continue
- if reference and not match_filter(reference, fp.properties.get('reference', '')):
+ if reference and not match_filter(reference, fp.reference):
continue
- if net and not any(match_filter(net, pad.net.name) for pad in fp.pads):
+ if net and not any(pad.net and match_filter(net, pad.net.name) for pad in fp.pads):
continue
if sheetname and not match_filter(sheetname, fp.sheetname):
continue
@@ -780,15 +782,6 @@ class Board:
fe.offset(x, -y, MM)
layer_stack.drill_pth.append(fe)
- def bounding_box(self, unit=MM):
- if not self._bounding_box:
- stack = LayerStack()
- layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
- self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
- self._bounding_box = stack.bounding_box(unit)
- return self._bounding_box
-
-
@dataclass
class BoardInstance(cad_pr.Positioned):
sexp: Board = None
diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py
index fa55568..ad1cc90 100644
--- a/gerbonara/cad/kicad/primitives.py
+++ b/gerbonara/cad/kicad/primitives.py
@@ -38,7 +38,7 @@ def layer_mask(layers):
case 'B.Cu':
mask |= 1<<31
case _:
- if (m := re.match(f'In([0-9]+)\.Cu', layer)):
+ if (m := re.match(fr'In([0-9]+)\.Cu', layer)):
mask |= 1<<int(m.group(1))
return mask
@@ -62,6 +62,8 @@ def center_arc_to_kicad_mid(center, start, end):
def kicad_mid_to_center_arc(mid, start, end):
""" Convert kicad's slightly insane midpoint notation to standrad center/p1/p2 notation.
+ returns a ((center_x, center_y), radius, clockwise) tuple in KiCad coordinates.
+
Returns the center and radius of the circle passing the given 3 points.
In case the 3 points form a line, raises a ValueError.
"""
@@ -81,7 +83,7 @@ def kicad_mid_to_center_arc(mid, start, end):
cy = ((p1[0] - p2[0]) * cd - (p2[0] - p3[0]) * bc) / det
radius = math.sqrt((cx - p1[0])**2 + (cy - p1[1])**2)
- return ((cx, cy), radius)
+ return (cx, cy), radius, det < 0
@sexp_type('hatch')
@@ -92,7 +94,7 @@ class Hatch:
@sexp_type('connect_pads')
class PadConnection:
- type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
+ type: AtomChoice(Atom.yes, Atom.thru_hole_only, Atom.full, Atom.no) = None
clearance: Named(float) = 0
@@ -133,18 +135,18 @@ class ZoneFill:
class FillPolygon:
layer: Named(str) = ""
island: Wrap(Flag()) = False
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('fill_segments')
class FillSegment:
layer: Named(str) = ""
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('polygon')
class ZonePolygon:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('zone')
@@ -153,6 +155,7 @@ class Zone:
net_name: Named(str) = ""
layer: Named(str) = None
layers: Named(Array(str)) = None
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
name: Named(str) = None
hatch: Hatch = None
@@ -177,10 +180,26 @@ class Zone:
self.fill_polygons = []
self.fill_segments = []
+ def rotate(self, angle, cx=None, cy=None):
+ self.unfill()
+ self.polygon.pts = [pt.with_rotation(angle, cx, cy) for pt in self.polygon.pts]
+
+ def offset(self, x=0, y=0):
+ self.unfill()
+ self.polygon.pts = [pt.with_offset(x, y) for pt in self.polygon.pts]
+
+
+ def bounding_box(self):
+ min_x = min(pt.x for pt in self.polygon.pts)
+ min_y = min(pt.y for pt in self.polygon.pts)
+ max_x = max(pt.x for pt in self.polygon.pts)
+ max_y = max(pt.y for pt in self.polygon.pts)
+ return (min_x, min_y), (max_x, max_y)
+
@sexp_type('polygon')
class RenderCachePolygon:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('render_cache')
diff --git a/gerbonara/cad/kicad/schematic.py b/gerbonara/cad/kicad/schematic.py
index 45a022e..9e7c6d4 100644
--- a/gerbonara/cad/kicad/schematic.py
+++ b/gerbonara/cad/kicad/schematic.py
@@ -132,7 +132,7 @@ def _polyline_bounds(self):
@sexp_type('wire')
class Wire:
- points: PointList = field(default_factory=PointList)
+ points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@@ -145,7 +145,7 @@ class Wire:
@sexp_type('bus')
class Bus:
- points: PointList = field(default_factory=PointList)
+ points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@@ -158,7 +158,7 @@ class Bus:
@sexp_type('polyline')
class Polyline:
- points: PointList = field(default_factory=PointList)
+ points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@@ -260,6 +260,7 @@ class HierarchicalLabel(TextMixin):
class Pin:
name: str = '1'
uuid: UUID = field(default_factory=UUID)
+ alternate: Named(str) = None
# Suddenly, we're doing syntax like this is yaml or something.
@@ -354,9 +355,9 @@ class SymbolInstance:
pins: List(Pin) = field(default_factory=list)
# AFAICT this property, too, is completely redundant. It ultimately just lists paths and references of at most
# three other uses of the same symbol in this schematic.
- instances: Named(List(SymbolCrosslinkProject)) = field(default_factory=list)
+ instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
_ : SEXP_END = None
- schematic: object = None
+ schematic: object = field(repr=False, default=None)
def __after_parse__(self, parent):
self.schematic = parent
@@ -495,7 +496,7 @@ class Subsheet:
_ : SEXP_END = None
sheet_name: object = field(default_factory=lambda: DrawnProperty('Sheetname', ''))
file_name: object = field(default_factory=lambda: DrawnProperty('Sheetfile', ''))
- schematic: object = None
+ schematic: object = field(repr=False, default=None)
def __after_parse__(self, parent):
self.sheet_name, self.file_name = self._properties
diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py
index d2d9d30..c197765 100644
--- a/gerbonara/cad/kicad/sexp_mapper.py
+++ b/gerbonara/cad/kicad/sexp_mapper.py
@@ -202,6 +202,24 @@ class YesNoAtom:
yield self.yes if value else self.no
+class LegacyCompatibleFlag:
+ '''Variant of YesNoAtom that accepts both the `(flag <yes/no>)` variant and the bare `flag` variant for compatibility.'''
+
+ def __init__(self, yes=Atom.yes, no=Atom.no, value_when_empty=True):
+ self.yes, self.no = yes, no
+ self.value_when_empty = value_when_empty
+
+ def __map__(self, value, parent=None):
+ if value == []:
+ return self.value_when_empty
+
+ value, = value
+ return value == self.yes
+
+ def __sexp__(self, value):
+ yield self.yes if value else self.no
+
+
class Wrap(WrapperType):
def __map__(self, value, parent=None):
value, = value
diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py
index ed93f7b..5e460be 100644
--- a/gerbonara/cad/kicad/symbols.py
+++ b/gerbonara/cad/kicad/symbols.py
@@ -18,9 +18,10 @@ from .sexp import *
from .sexp_mapper import *
from .base_types import *
from ...utils import rotate_point, Tag, arc_bounds
+from ... import __version__
from ...newstroke import Newstroke
from .schematic_colors import *
-from .primitives import center_arc_to_kicad_mid
+from .primitives import kicad_mid_to_center_arc
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
@@ -259,7 +260,7 @@ class Arc:
fill: Fill = field(default_factory=Fill)
def bounding_box(self, default=None):
- (cx, cy), r = center_arc_to_kicad_mid(self.mid, self.start, self.end)
+ (cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
x1, y1 = self.start.x, self.start.y
x2, y2 = self.mid.x-x1, self.mid.y-x2
x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2
@@ -268,7 +269,7 @@ class Arc:
def to_svg(self, colorscheme=Colorscheme.KiCad):
- (cx, cy), r = center_arc_to_kicad_mid(self.mid, self.start, self.end)
+ (cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
x1r = self.start.x - cx
y1r = self.start.y - cy
@@ -481,6 +482,7 @@ class Symbol:
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
+ exclude_from_sim: Named(YesNoAtom()) = False
in_bom: Named(YesNoAtom()) = True
on_board: Named(YesNoAtom()) = True
properties: List(Property) = field(default_factory=list)
@@ -573,7 +575,8 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
@sexp_type('kicad_symbol_lib')
class Library:
_version: Named(int, name='version') = 20211014
- generator: Named(Atom) = Atom.gerbonara
+ generator: Named(str) = Atom.gerbonara
+ generator_version: Named(str) = __version__
symbols: List(Symbol) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
diff --git a/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py
index 6ffd4e2..f581c38 100644
--- a/gerbonara/cad/primitives.py
+++ b/gerbonara/cad/primitives.py
@@ -4,7 +4,7 @@ import math
import warnings
from copy import copy
from itertools import zip_longest, chain
-from dataclasses import dataclass, field, KW_ONLY
+from dataclasses import dataclass, field, replace, KW_ONLY
from collections import defaultdict
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds
@@ -14,6 +14,9 @@ from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAper
from ..newstroke import Newstroke
+class UNDEFINED:
+ pass
+
def sgn(x):
return -1 if x < 0 else 1
@@ -115,7 +118,7 @@ class Board:
def layer_stack(self, layer_stack=None):
if layer_stack is None:
- layer_stack = LayerStack()
+ layer_stack = LayerStack(board_name='proto')
cache = {}
for obj in chain(self.objects):
@@ -319,6 +322,7 @@ class Text(Positioned):
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)
+ h = self.font_size + self.stroke_width # (max_y - min_y)
if self.h_align == 'left':
x0 = 0
@@ -329,16 +333,16 @@ class Text(Positioned):
else:
raise ValueError('h_align must be one of "left", "center", or "right".')
- if self.v_align == 'top':
- y0 = -(max_y - min_y)
+ if self.v_align == 'bottom':
+ y0 = h
elif self.v_align == 'middle':
- y0 = -(max_y - min_y)/2
- elif self.v_align == 'bottom':
+ y0 = h/2
+ elif self.v_align == 'top':
y0 = 0
else:
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
- if self.side == 'bottom':
+ if self.flip:
x0 += min_x + max_x
x_sign = -1
else:
@@ -348,7 +352,7 @@ class Text(Positioned):
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 = 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['bottom' if flip else 'top', self.layer].objects.append(obj)
@@ -365,11 +369,11 @@ class Text(Positioned):
x0 = -approx_w
if self.v_align == 'top':
- y0 = -approx_h
+ y0 = 0
elif self.v_align == 'middle':
y0 = -approx_h/2
elif self.v_align == 'bottom':
- y0 = 0
+ y0 = -approx_h
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
@@ -382,6 +386,7 @@ class PadStackAperture:
offset_x: float = 0 # in PadStack units
offset_y: float = 0
rotation: float = 0
+ invert: bool = False
@dataclass(frozen=True, slots=True)
@@ -396,20 +401,20 @@ class PadStack:
def flashes(self, x, y, rotation: float = 0, flip: bool = False):
for ap in self.apertures:
aperture = ap.aperture.rotated(ap.rotation + rotation)
- fl = Flash(ap.offset_x, ap.offset_y)
+ fl = Flash(ap.offset_x, ap.offset_y, aperture, polarity_dark=not ap.invert, unit=self.unit)
fl.rotate(rotation)
fl.offset(x, y)
- side = fl.side
+ side = ap.side
if flip:
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
- yield side, fl.layer, fl
+ yield side, ap.layer, fl
def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False):
for side, layer, flash in self.flashes(x, y, rotation, flip):
- if side == 'drill' and use == 'plated':
+ if side == 'drill' and layer == 'plated':
layer_stack.drill_pth.objects.append(flash)
- elif side == 'drill' and use == 'nonplated':
+ elif side == 'drill' and layer == 'nonplated':
layer_stack.drill_npth.objects.append(flash)
elif (side, layer) in layer_stack:
@@ -450,16 +455,36 @@ class SMDStack(PadStack):
@dataclass(frozen=True, slots=True)
+class MechanicalHoleStack(PadStack):
+ drill_dia: float
+ mask_expansion: float = 0.0
+ mask_aperture = None
+
+ @property
+ def apertures(self):
+ mask_aperture = self.mask_aperture or CircleAperture(self.drill_dia + self.mask_expansion, unit=self.unit)
+ yield PadStackAperture(mask_aperture, 'top', 'mask')
+ yield PadStackAperture(mask_aperture, 'bottom', 'mask')
+
+ @property
+ def single_sided(self):
+ return False
+
+
+@dataclass(frozen=True, slots=True)
class THTPad(PadStack):
drill_dia: float
pad_top: SMDStack
pad_bottom: SMDStack = None
- aperture_inner: Aperture = None
+ aperture_inner: Aperture = UNDEFINED
plated: bool = True
def __post_init__(self):
if self.pad_bottom is None:
object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True))
+
+ if self.aperture_inner is UNDEFINED:
+ object.__setattr__(self, 'aperture_inner', self.pad_top.aperture)
if self.pad_top.flip:
raise ValueError('top pad cannot be flipped')
@@ -472,7 +497,8 @@ class THTPad(PadStack):
def apertures(self):
yield from self.pad_top.apertures
yield from self.pad_bottom.apertures
- yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
+ if self.aperture_inner is not None:
+ yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
@property
@@ -486,7 +512,7 @@ class THTPad(PadStack):
@classmethod
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
- pad = SMDStack.circle(dia, rotation, mask_expansion, paste_expansion, paste, unit=unit)
+ pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@classmethod
@@ -538,6 +564,10 @@ class Via(FrozenPositioned):
class Pad(Positioned):
pad_stack: PadStack
+ def render(self, layer_stack, cache=None):
+ x, y, rotation, flip = self.abs_pos
+ self.pad_stack.render(layer_stack, x, y, rotation, flip)
+
@property
def single_sided(self):
return self.pad_stack.single_sided
diff --git a/gerbonara/cad/protoboard.py b/gerbonara/cad/protoboard.py
index 91b07d1..da96fb8 100644
--- a/gerbonara/cad/protoboard.py
+++ b/gerbonara/cad/protoboard.py
@@ -8,11 +8,11 @@ from copy import copy, deepcopy
import warnings
import importlib.resources
-from ..utils import MM, rotate_point
+from ..utils import MM, rotate_point, bbox_intersect
from .primitives import *
-from ..graphic_objects import Region
+from ..graphic_objects import Region, Line
from ..apertures import RectangleAperture, CircleAperture, ApertureMacroInstance
-from ..aperture_macros.parse import ApertureMacro, VariableExpression
+from ..aperture_macros.parse import ApertureMacro, ParameterExpression, VariableExpression
from ..aperture_macros import primitive as amp
from .kicad import footprints as kfp
from . import data as package_data
@@ -27,12 +27,16 @@ class ProtoBoard(Board):
if mounting_hole_dia:
mounting_hole_offset = mounting_hole_offset or mounting_hole_dia*2
- ko = mounting_hole_offset*2
+ ko = mounting_hole_offset + mounting_hole_dia*(0.5 + 0.25)
- 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))
+ stack = MechanicalHoleStack(mounting_hole_dia, unit=unit)
+ self.mounting_holes = [
+ Pad(mounting_hole_offset, mounting_hole_offset, pad_stack=stack, unit=unit),
+ Pad(w-mounting_hole_offset, mounting_hole_offset, pad_stack=stack, unit=unit),
+ Pad(mounting_hole_offset, h-mounting_hole_offset, pad_stack=stack, unit=unit),
+ Pad(w-mounting_hole_offset, h-mounting_hole_offset, pad_stack=stack, unit=unit)]
+ for hole in self.mounting_holes:
+ self.add(hole)
self.keepouts.append(((0, 0), (ko, ko)))
self.keepouts.append(((w-ko, 0), (w, ko)))
@@ -44,8 +48,15 @@ class ProtoBoard(Board):
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')
+ hole_bboxes = [hole.bounding_box(unit) for hole in self.mounting_holes]
+ for obj in self.content.generate(bbox, (True, True, True, True), self.keepouts, self.margin, True, unit):
+ if isinstance(obj, Text):
+ # It's okay for the text to go into the mounting hole keepouts, we just don't want it to overlap with
+ # the actual mounting holes.
+ if not any(bbox_intersect(obj.bounding_box(unit), hole_bbox) for hole_bbox in hole_bboxes):
+ self.add(obj, keepout_errors='ignore')
+ else:
+ self.add(obj, keepout_errors='ignore')
class PropLayout:
@@ -58,7 +69,19 @@ class PropLayout:
if len(content) != len(proportions):
raise ValueError('proportions and content must have same length')
- def generate(self, bbox, border_text, unit=MM):
+ def increment_x(self):
+ if self.direction == 'h':
+ return 0
+ else:
+ return max(obj.increment_x() for obj in self.content)
+
+ def increment_y(self):
+ if self.direction == 'v':
+ return 0
+ else:
+ return max(obj.increment_y() for obj in self.content)
+
+ def generate(self, bbox, border_text, keepouts, text_margin, two_sided, unit=MM):
for i, (bbox, child) in enumerate(self.layout_2d(bbox, unit)):
first = bool(i == 0)
last = bool(i == len(self.content)-1)
@@ -67,7 +90,7 @@ class PropLayout:
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)
+ ), keepouts, text_margin, two_sided, unit)
def fit_size(self, w, h, unit=MM):
widths = []
@@ -84,10 +107,13 @@ class PropLayout:
def layout_2d(self, bbox, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
+ total_w, total_h = w, h
actual_l = 0
target_l = 0
+ total_l = total_w if self.direction == 'h' else total_h
+ sizes = []
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
@@ -109,8 +135,34 @@ class PropLayout:
actual_l += this_h
this_w = w
+ sizes.append(((this_x, this_y), (this_w, this_h)))
+
+ # We don't want to pull in a whole bin packing implementation here, but we also don't want to be too dumb. Thus,
+ # we just take the leftover space and distribute it to the children in descending increment (grid / pitch size).
+ children_sorted = reversed(sorted(enumerate(self.content),
+ key=lambda e: e[1].increment_x() if self.direction == 'h' else e[1].increment_y()))
+
+ excess_l = total_l - actual_l
+ children_extra = [0] * len(self.content)
+ for child_i, child in children_sorted:
+ increment = child.increment_x() if self.direction=='h' else child.increment_y()
+ adjustment = increment * (excess_l//increment) if increment > 0 else excess_l
+ children_extra[child_i] += adjustment
+ excess_l -= adjustment
+
+ adjust_l = 0
+ for extra, ((this_x, this_y), (this_w, this_h)), child in zip(children_extra, sizes, self.content):
+ if self.direction == 'h':
+ this_x += adjust_l
+ this_w += extra
+ else:
+ this_y += adjust_l
+ this_h += extra
+ adjust_l += extra
+
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)
@@ -138,6 +190,12 @@ class TwoSideLayout:
if not top.single_sided or not bottom.single_sided:
warnings.warn('Two-sided pattern used on one side of a TwoSideLayout')
+ def increment_x(self):
+ return max(self.top.increment_x(), self.bottom.increment_x())
+
+ def increment_y(self):
+ return max(self.top.increment_y(), self.bottom.increment_y())
+
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)
@@ -149,10 +207,10 @@ class TwoSideLayout:
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'
+ def generate(self, bbox, border_text, keepouts, text_margin, two_sided, unit=MM):
+ yield from self.top.generate(bbox, border_text, keepouts, text_margin, False, unit)
+ for obj in self.bottom.generate(bbox, border_text, keepouts, text_margin, False, unit):
+ obj.flip = not obj.flip
yield obj
@@ -165,37 +223,284 @@ def numeric(start=1):
return gen
-def alphabetic(case='upper'):
+def alphabetic(case='upper', alphabet=None):
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
+ if alphabet is None:
+ index = string.ascii_lowercase if case == 'lower' else string.ascii_uppercase
+ else:
+ index = alphabet
+ n = len(index)
def gen():
- nonlocal index
+ nonlocal index, n
for i in itertools.count():
- if i<26:
+ if i<n:
yield index[i]
continue
- i -= 26
- if i<26*26:
- yield index[i//26] + index[i%26]
+ i -= n
+ if i<n*n:
+ yield index[i//n] + index[i%n]
continue
- i -= 26*26
- if i<26*26*26:
- yield index[i//(26*26)] + index[(i//26)%26] + index[i%26]
+ i -= n*n
+ if i<n*n*n:
+ yield index[i//(n*n)] + index[(i//n)%n] + index[i%n]
else:
- raise ValueError('row/column index out of range')
+ raise ValueError(f'row/column index {i} out of range {n**3 + n**2 + n}')
return gen
+@dataclass
+class BreadboardArea:
+ drill: float = 0.9
+ clearance: float = 0.5
+ signal_trace_width: float = 0.8
+ power_trace_width: float = 1.5
+ pitch_x: float = 2.54
+ pitch_y: float = 2.54
+ power_rail_pitch: float = 2.54
+ power_rail_space: float = 2.54
+ num_power_rails: int = 2
+ num_holes: int = 5
+ center_space: float = 5.08
+ horizontal: bool = True
+ margin: float = 0
+ font_size: float = 1.0
+ font_stroke: float = 0.2
+ unit: object = MM
+
+ def fit_size(self, w, h, unit=MM):
+ m = unit(self.margin, self.unit)
+ w = max(0, w-2*m)
+ h = max(0, h-2*m)
+
+ pitch_x = self.pitch_x
+ pitch_y = self.pitch_y
+ if self.horizontal:
+ pitch_x, pitch_y = pitch_y, pitch_x
+
+ w_mod = round((w + 5e-7) % unit(pitch_x, self.unit), 6)
+ h_mod = round((h + 5e-7) % unit(pitch_y, self.unit), 6)
+ w_fit, h_fit = round(w - w_mod, 6), round(h - h_mod, 6)
+ return w_fit + 2*m, h_fit + 2*m
+
+ @property
+ def width_across(self):
+ w = self.pitch_x * self.num_holes * 2 + self.center_space
+ if self.num_power_rails > 0:
+ # include one power rail pitch unit for the space between adjacent tiles.
+ w += 2*self.power_rail_space + (2*self.num_power_rails-1) * self.power_rail_pitch
+ return w
+
+ def increment_x(self):
+ if self.horizontal:
+ return self.pitch_y
+ else:
+ return self.width_across
+
+ def increment_y(self):
+ if self.horizontal:
+ return self.width_across
+ else:
+ return self.pitch_y
+
+ @property
+ def single_sided(self):
+ return False
+
+ def generate(self, bbox, border_text, keepouts, text_margin, two_sided, unit=MM):
+ (x, y), (w, h) = self.unit.convert_bounds_from(unit, bbox)
+ w, h = w-x-self.margin, h-y-self.margin
+ ox, oy = (y, x) if self.horizontal else (x, y)
+
+ signal_ap = CircleAperture(self.signal_trace_width, unit=self.unit)
+ power_ap = CircleAperture(self.power_trace_width, unit=self.unit)
+
+ pad_dia = min(self.pitch_x, self.pitch_y) - self.clearance
+ tht_pad = THTPad.circle(self.drill, pad_dia)
+
+ available_width = h if self.horizontal else w
+ length_along = w if self.horizontal else h
+
+ # Key:
+ # H - signal pad
+ # C - center space
+ # P - power pad
+ # R - power rail space
+
+ pitch_key = {
+ 'H': self.pitch_x,
+ 'C': self.center_space,
+ 'P': self.power_rail_pitch,
+ 'R': self.power_rail_space}
+
+ layouts = []
+
+ for i in range(self.num_holes):
+ sig = 'H' * (i+1)
+ layouts.append(sig)
+
+ layouts.append(f'{sig}C{sig}')
+
+ for i in range(self.num_power_rails):
+ pwr = 'P' * (i+1)
+ layouts.append(f'{pwr}R{sig}C{sig}')
+ layouts.append(f'{pwr}R{sig}C{sig}R{pwr}')
+
+ while len(layouts[-1]) <= available_width // self.pitch_x:
+ pre = layouts[-1]
+
+ for i in range(self.num_holes):
+ sig = 'H' * (i+1)
+ layouts.append(f'{pre}R{sig}')
+
+ for i in range(self.num_holes):
+ sig = 'H' * (i+1)
+ for i in range(self.num_power_rails):
+ pwr = 'P' * (i+1)
+ layouts.append(f'{pre}R{sig}C{sig}R{pwr}')
+ layouts.append(f'{pre}R{sig}R{pwr}')
+
+ layouts.append(f'{pre}R{sig}C{sig}')
+
+ for i in range(self.num_power_rails):
+ pwr = 'P' * (i+1)
+ layouts.append(f'{pre}R{sig}C{sig}R{pwr}')
+
+ best_layout, leftover_space = None, None
+ for layout in layouts:
+ actual_width = sum(pitch_key[e] for e in layout)
+
+ if actual_width <= available_width:
+ best_layout = layout
+ leftover_space = available_width - actual_width
+
+ if best_layout is None:
+ return # We don't have enough space to do anything
+ print(f'Chosen layout: {best_layout} with {leftover_space} left over')
+
+ rail_start = {}
+ rail_end = {}
+ n_y = round(length_along//self.pitch_y)
+ for j in range(n_y):
+ y = oy + self.margin + self.pitch_y*(j + 0.5) + (length_along - (n_y*self.pitch_y))/2
+ pos_across = ox + self.margin + leftover_space/2
+ last_e = 'R'
+ for e, group in itertools.groupby(enumerate(best_layout), key=lambda e: e[1]):
+ group = list(group)
+ num = len(group)
+ local_pitch = pitch_key[e]
+
+ points = []
+ for k, _e in group:
+ x = pos_across + local_pitch/2
+ ax, ay = (y, x) if self.horizontal else (x, y)
+ px, py = (self.pitch_y, local_pitch) if self.horizontal else (local_pitch, self.pitch_y)
+
+ if not any(bbox_intersect(ko, ((ax-px/2, ay-py/2), (ax+px/2, ay+py/2))) for ko in keepouts):
+ points.append((ax, ay))
+
+ if e == 'H':
+ yield Pad(ax, ay, pad_stack=tht_pad, unit=self.unit)
+
+ elif e == 'P':
+ yield Pad(ax, ay, pad_stack=tht_pad, unit=self.unit)
+
+ if k not in rail_start:
+ rail_start[k] = (ax, ay)
+ rail_end[k] = (ax, ay)
+
+ pos_across += local_pitch
+
+ if e == 'H':
+ if len(points) > 1:
+ yield Trace(self.signal_trace_width, points[0], points[-1], unit=self.unit)
+
+ label = f'{j+1}'
+
+ if last_e == 'R':
+ if points:
+ tx, ty = points[0]
+
+ if self.horizontal:
+ ty -= self.pitch_x/2
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit)
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit, flip=True)
+ else:
+ tx -= self.pitch_x/2
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit)
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit, flip=True)
+
+ else:
+ if points:
+ tx, ty = points[-1]
+
+ if self.horizontal:
+ ty += self.pitch_x/2
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit)
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit, flip=True)
+ else:
+ tx += self.pitch_x/2
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit)
+ yield Text(tx, ty, label, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit, flip=True)
+ last_e = e
+
+ if self.num_power_rails == 2 and best_layout.count('P') >= 2:
+ power_rail_labels = ['-', '+'] * best_layout.count('P')
+ else:
+ power_rail_labels = [e for _, e in zip(best_layout, alphabetic(alphabet='ZXYWVU')())]
+ signal_labels = alphabetic()() # yes, twice.
+
+ line_ap = CircleAperture(self.power_trace_width, unit=self.unit)
+
+ for i, e in enumerate(best_layout):
+ start = rail_start.get(i)
+ end = rail_end.get(i)
+
+ if e == 'P':
+ if start not in (None, end):
+ yield Trace(self.power_trace_width, start, end, unit=self.unit)
+ le_line = [Line(*start, *end, aperture=line_ap, unit=self.unit)]
+ yield Graphics(0, 0, top_silk=le_line, bottom_silk=le_line, unit=self.unit)
+
+ label = power_rail_labels.pop(0)
+
+ elif e == 'H':
+ label = next(signal_labels)
+ else:
+ label = None
+
+ if label:
+ tx1, ty1 = start
+ tx2, ty2 = end
+
+ if self.horizontal:
+ tx1 -= self.pitch_y/2
+ tx2 += self.pitch_y/2
+
+ yield Text(tx1, ty1, label, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit)
+ yield Text(tx1, ty1, label, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit, flip=True)
+ yield Text(tx2, ty2, label, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit)
+ yield Text(tx2, ty2, label, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit, flip=True)
+
+ else:
+ ty1 -= self.pitch_y/2
+ ty2 += self.pitch_y/2
+
+ yield Text(tx1, ty1, label, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit)
+ yield Text(tx1, ty1, label, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit, flip=True)
+ yield Text(tx2, ty2, label, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit)
+ yield Text(tx2, ty2, label, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit, flip=True)
+
+
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, margin=0, unit=MM):
+ 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=None, interval_y=None, margin=0, unit=MM):
self.pitch_x = pitch_x
self.pitch_y = pitch_y or pitch_x
self.margin = margin
@@ -205,17 +510,22 @@ class PatternProtoArea:
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.interval_y = interval_y
self.number_x_gen, self.number_y_gen = number_x_gen, number_y_gen
+ def increment_x(self):
+ return self.pitch_x
+
+ def increment_y(self):
+ return self.pitch_y
+
def fit_size(self, w, h, unit=MM):
(min_x, min_y), (max_x, max_y) = self.fit_rect(((0, 0), (max(0, w-2*self.margin), max(0, h-2*self.margin))))
return max_x-min_x + 2*self.margin, max_y-min_y + 2*self.margin
def fit_rect(self, bbox, unit=MM):
(x, y), (w, h) = bbox
- x, y = x+self.margin, y+self.margin
- w, h = w-x-self.margin, h-y-self.margin
+ 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)
@@ -225,53 +535,117 @@ class PatternProtoArea:
y = y + (h-h_fit)/2
return (x, y), (x+w_fit, y+h_fit)
- def generate(self, bbox, border_text, unit=MM):
+ def generate(self, bbox, border_text, keepouts, text_margin, two_sided, 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
+ n_x = int((w + 0.001)//unit(self.pitch_x, self.unit))
+ n_y = int((h + 0.001)//unit(self.pitch_y, self.unit))
+ off_x = (w - n_x*unit(self.pitch_x, self.unit)) / 2
+ off_y = (h - n_y*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:
+ # Center row/column numbers in available margin. Note the swapped axes below - the Y (row) numbers are
+ # centered in X direction, and vice versa.
+ _idx, max_x_num = list(zip(range(n_x), self.number_x_gen()))[-1]
+ _idx, max_y_num = list(zip(range(n_y), self.number_y_gen()))[-1]
+ bbox_test_x = Text(0, 0, max_y_num, self.font_size, self.font_stroke, 'left', 'top', unit=self.unit)
+ bbox_test_y = Text(0, 0, max_x_num, self.font_size, self.font_stroke, 'left', 'top', unit=self.unit)
+ test_w = abs(bbox_test_x.bounding_box()[1][0] - bbox_test_x.bounding_box()[0][0])
+ test_h = abs(bbox_test_y.bounding_box()[1][1] - bbox_test_y.bounding_box()[0][1])
+ text_off_x = max(0, (off_x + text_margin - test_w)) / 2
+ text_off_y = max(0, (off_y + text_margin - test_h)) / 2
+
+ test_w = abs(bbox_test_y.bounding_box()[1][0] - bbox_test_y.bounding_box()[0][0])
+ test_h = abs(bbox_test_x.bounding_box()[1][1] - bbox_test_x.bounding_box()[0][1])
+
+ interval_x, interval_y = self.interval_x, self.interval_y
+ if interval_x is None:
+ interval_x = 1 if test_w < 0.8*self.pitch_x else 5
+ if interval_y is None:
+ interval_y = 1 if test_h < 0.8*self.pitch_y else 2
+
+ for i, lno_i in list(zip(reversed(range(n_y)), self.number_y_gen())):
+ if i == 0 or i == n_y - 1 or (i+1) % 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
+ t_x = x + off_x - text_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 two_sided:
+ yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', flip=True, unit=self.unit)
if border_text[1]:
- t_x = x + w - off_x
+ t_x = x + w - off_x + text_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)
+ if two_sided:
+ yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', flip=True, 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:
+ # We print every interval'th number, as well as the first and the last numbers.
+ # The complex condition below is to avoid the corner case where interval is larger than 1, and the last
+ # interval'th number is right next to the last number, and the two could overlap. In this case, we
+ # suppress the last interval'th number.
+ if i == 0 or i == n_x - 1 or ((i+1) % interval_x == 0 and (interval_x == 1 or i != n_x-2)):
t_x = off_x + x + (i + 0.5) * self.pitch_x
if border_text[2]:
- t_y = y + off_y
+ t_y = y + off_y - text_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 two_sided:
+ yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', flip=True, unit=self.unit)
if border_text[0]:
- t_y = y + h - off_y
+ t_y = y + h - off_y + text_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 two_sided:
+ yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', flip=True, unit=self.unit)
+
+
+ for j in range(n_y):
+ for i in range(n_x):
+ x0 = off_x + x + i*self.pitch_x
+ y0 = off_y + y + j*self.pitch_y
+ x1 = x0 + self.pitch_x
+ y1 = y0 + self.pitch_y
+
+ border_n = (j == 0) or any(bbox_intersect(ko, ((x0, y0-self.pitch_y), (x1, y0))) for ko in keepouts)
+ border_s = (j == n_y-1) or any(bbox_intersect(ko, ((x0, y1), (x1, y1+self.pitch_y))) for ko in keepouts)
+ border_w = (i == 0) or any(bbox_intersect(ko, ((x0-self.pitch_x, y0), (x0, y1))) for ko in keepouts)
+ border_e = (i == n_x-1) or any(bbox_intersect(ko, ((x1, y0), (x1+self.pitch_x, y1))) for ko in keepouts)
+ border = (border_s, border_w, border_n, border_e)
+
+ print({
+ (0, 0, 0, 0): '┼',
+ (1, 0, 0, 0): '┴',
+ (0, 1, 0, 0): '├',
+ (0, 0, 1, 0): '┬',
+ (0, 0, 0, 1): '┤',
+ (1, 1, 0, 0): '└',
+ (0, 1, 1, 0): '┌',
+ (0, 0, 1, 1): '┐',
+ (1, 0, 0, 1): '┘',
+ }.get(tuple(map(int, border)), '.'), end=('' if i < n_x-1 else '\n'))
+
+ if any(bbox_intersect(ko, ((x0, y0), (x1, y1))) for ko in keepouts):
+ continue
+
+ obj = self.obj
+ if isinstance(obj, PadStack):
+ if hasattr(obj, 'grid_variant'):
+ obj = obj.grid_variant(i, j, border)
+ if obj is None:
+ continue
+
+ px = self.unit(off_x + x, unit) + (i + 0.5) * self.pitch_x
+ py = self.unit(off_y + y, unit) + (j + 0.5) * self.pitch_y
+ yield Pad(px, py, pad_stack=obj, unit=self.unit)
+ if two_sided and self.single_sided:
+ yield Pad(px, py, pad_stack=obj, flip=True, unit=self.unit)
+ continue
+
+ elif hasattr(self.obj, 'inst'):
+ inst = self.obj.inst(i, j, border)
if not inst:
continue
else:
@@ -281,6 +655,11 @@ class PatternProtoArea:
inst.y = inst.unit(off_y + y, unit) + (j + 0.5) * inst.unit(self.pitch_y, self.unit)
yield inst
+ if two_sided and self.single_sided:
+ inst = copy(inst)
+ inst.flip = not inst.flip
+ yield inst
+
@property
def single_sided(self):
return self.obj.single_sided
@@ -290,14 +669,22 @@ class EmptyProtoArea:
def __init__(self, copper_fill=False):
self.copper_fill = copper_fill
+ def increment_x(self):
+ return 0
+
+ def increment_y(self):
+ return 0
+
def fit_size(self, w, h, unit=MM):
return w, h
- def generate(self, bbox, border_text, unit=MM):
+ def generate(self, bbox, border_text, keepouts, text_margin, two_sided, 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)])
+ if two_sided:
+ group.bottom_copper = group.top_copper
group.bounding_box = lambda *args, **kwargs: None
yield group
@@ -306,110 +693,146 @@ class EmptyProtoArea:
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
+@dataclass(frozen=True, slots=True)
+class ManhattanPads(PadStack):
+ w: float = None
+ h: float = None
+ gap: float = 0.2
- p = (w-2*gap)/2
- q = (h-2*gap)/2
- small_ap = RectangleAperture(p, q, unit=unit)
+ @property
+ def single_sided(self):
+ return True
- s = min(w, h) / 2 / math.sqrt(2)
- large_ap = RectangleAperture(s, s, unit=unit).rotated(math.pi/4)
- large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, unit=unit).rotated(math.pi/4)
+ @property
+ def apertures(self):
+ w = self.w
+ h = self.h or w
- a = gap/2 + p/2
- b = gap/2 + q/2
+ p = (w-2*self.gap)/2
+ q = (h-2*self.gap)/2
+ small_ap = RectangleAperture(p, q, unit=self.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( 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
+ s = min(w, h) / 2 / math.sqrt(2)
+ large_ap = RectangleAperture(s, s, unit=self.unit).rotated(math.pi/4)
+ large_ap_neg = RectangleAperture(s+2*self.gap, s+2*self.gap, unit=self.unit).rotated(math.pi/4)
+
+ a = self.gap/2 + p/2
+ b = self.gap/2 + q/2
+
+ for layer in ('copper', 'mask'):
+ yield PadStackAperture(small_ap, 'top', layer, -a, -b)
+ yield PadStackAperture(small_ap, 'top', layer, -a, b)
+ yield PadStackAperture(small_ap, 'top', layer, a, -b)
+ yield PadStackAperture(small_ap, 'top', layer, a, b)
+ yield PadStackAperture(large_ap_neg, 'top', layer, 0, 0, invert=True)
+ yield PadStackAperture(large_ap, 'top', layer, 0, 0)
+
+
+@dataclass(frozen=True, slots=True)
+class RFGroundProto(PadStack):
+ pitch: float = 2.54
+ drill: float = 0.9
+ clearance: float = 0.3
+ via_drill: float = 0.4
+ via_dia: float = 0.8
+ pad_dia: float = None
+ trace_width: float = None
+ _: KW_ONLY = None
+ suppress_via: bool = False
+ @property
+ def single_sided(self):
+ return False
-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)
+ @property
+ def apertures(self):
+ unit = self.unit
+ pitch = self.pitch
+ trace_width, pad_dia = self.trace_width, self.pad_dia
if pad_dia is None:
- self.trace_width = trace_width = trace_width or unit(0.3, MM)
- pad_dia = pitch - trace_width - 2*clearance
+ if trace_width is None:
+ trace_width = 0.3
+ pad_dia = pitch - trace_width - 2*self.clearance
elif trace_width is None:
- trace_width = pitch - pad_dia - 2*clearance
- self.pad_dia = pad_dia
+ trace_width = pitch - pad_dia - 2*self.clearance
- via_ap = RectangleAperture(via_dia, via_dia, unit=unit).rotated(math.pi/4)
+ via_ap = RectangleAperture(self.via_dia, self.via_dia, unit=unit).rotated(math.pi/4)
pad_ap = CircleAperture(pad_dia, unit=unit)
- pad_neg_ap = CircleAperture(pad_dia+2*clearance, unit=unit)
+ pad_neg_ap = CircleAperture(pad_dia+2*self.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
+ pad_drill = ExcellonTool(self.drill, plated=True, unit=unit)
+ via_drill = ExcellonTool(self.via_drill, plated=True, unit=unit)
+
+ for side in 'top', 'bottom':
+ yield PadStackAperture(ground_ap, side, 'copper')
+ yield PadStackAperture(pad_neg_ap, side, 'copper', invert=True)
+ yield PadStackAperture(pad_ap, side, 'copper')
+ yield PadStackAperture(pad_ap, side, 'mask')
+
+ if not self.suppress_via:
+ yield PadStackAperture(via_ap, side, 'copper', pitch/2, pitch/2)
+ yield PadStackAperture(via_ap, side, 'mask', pitch/2, pitch/2)
+
+ yield PadStackAperture(pad_drill, 'drill', 'plated')
+ if not self.suppress_via:
+ yield PadStackAperture(via_drill, 'drill', 'plated', pitch/2, pitch/2)
+
+ def grid_variant(self, x, y, border):
+ border_s, border_w, border_n, border_e = border
+ if border_e or border_s:
+ return replace(self, suppress_via=True)
+ else:
+ return self
-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)
+@dataclass(frozen=True, slots=True)
+class THTFlowerProto(PadStack):
+ pitch: float = 2.54
+ drill: float = 0.9
+ diameter: float = 2.0
+ clearance: float = 0.5
+ border_s: bool = False
+ border_w: bool = False
+ border_n: bool = False
+ border_e: bool = False
+
+ @property
+ def single_sided(self):
+ return False
- if (x % 2 == 1) and (y % 2 == 1):
- return copy(self)
+ @property
+ def apertures(self):
+ p = self.diameter / 2
+ pad_dist_diag = math.sqrt(2) * (self.pitch - p) - self.drill
+ pad_dist_ortho = 2*self.pitch - self.diameter - self.drill
+ pad_dia = self.drill + max(0, min(pad_dist_diag, pad_dist_ortho) - self.clearance)
+
+ pad = THTPad.circle(self.drill, pad_dia, paste=False, unit=self.unit)
+
+ for ox, oy, brd in ((-p, 0, self.border_w), (p, 0, self.border_e), (0, -p, self.border_n), (0, p, self.border_s)):
+ if not brd:
+ for stack_ap in pad.apertures:
+ yield replace(stack_ap, offset_x=ox, offset_y=oy)
+
+ middle_ap = CircleAperture(self.diameter, unit=self.unit)
+ for side in ('top', 'bottom'):
+ for layer in ('copper', 'mask'):
+ yield PadStackAperture(middle_ap, side, layer)
+
+ def grid_variant(self, x, y, border):
+ border_s, border_w, border_n, border_e = border
+ if ((x % 2 == 0) and (y % 2 == 0)) or ((x % 2 == 1) and (y % 2 == 1)):
+ return replace(self, border_s=border_s, border_w=border_w, border_n=border_n, border_e=border_e)
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)))
+# 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):
+class PoweredProto(Graphics):
""" Cell primitive for "powered" THT breadboards. This cell type is based on regular THT pads in a 100 mil grid, but
adds small SMD pads diagonally between the THT pads. These SMD pads are interconnected with traces and vias in such
a way that every second one is inter-linked, forming two fully connected grids. Next to every THT pad you have one
@@ -425,6 +848,10 @@ class PoweredProto(ObjectGroup):
Yajima Manufacturing Corporation website: http://www.yajima-works.co.jp/index.html
"""
+ @property
+ def single_sided(self):
+ return False
+
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
@@ -465,7 +892,7 @@ class PoweredProto(ObjectGroup):
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):
+ def inst(self, x, y, border):
inst = copy(self)
if (x + y) % 2 == 0:
inst.drill_pth = inst.drill_pth[:-1]
@@ -486,7 +913,7 @@ class PoweredProto(ObjectGroup):
return inst
def bounding_box(self, unit=MM):
- x, y, rotation = self.abs_pos
+ x, y, rotation, flip = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
@@ -511,7 +938,7 @@ class SpikyProto(ObjectGroup):
self.fp_between = kfp.Footprint.load(res.joinpath('pad-between-spiked.kicad_mod').read_text(encoding='utf-8'))
self.right_pad = kfp.FootprintInstance(1.27, 0, self.fp_between, unit=MM)
- self.top_pad = kfp.FootprintInstance(0, 1.27, self.fp_between, rotation=math.pi/2, unit=MM)
+ self.top_pad = kfp.FootprintInstance(0, 1.27, self.fp_between, rotation=-math.pi/2, unit=MM)
@property
def objects(self):
@@ -521,19 +948,71 @@ class SpikyProto(ObjectGroup):
def objects(self, value):
pass
- def inst(self, x, y, border_x, border_y):
+ def inst(self, x, y, border):
+ border_s, border_w, border_n, border_e = border
inst = copy(self)
- if border_x:
+ if border_e:
inst.corner_pad = inst.right_pad = None
- if border_y:
+ if border_s:
inst.corner_pad = inst.top_pad = None
return inst
-class AlioCell(ObjectGroup):
+@dataclass(frozen=True, slots=True)
+class StarburstPad(PadStack):
+ # Starburst pattern inspired by elecfreaks' "flower" protoboard
+ pitch_x: float = 2.54
+ pitch_y: float = 2.54
+ trace_width_x: float = 1.4
+ trace_width_y: float = 1.4
+ solder_clearance: float = 0.4
+ mask_width: float = 0.5
+ drill: float = 0.9
+ annular_ring: float = 1.2
+
+ @property
+ def apertures(self):
+ var = ParameterExpression
+ # parameters: [1: pitch_x,
+ # 2: trace_width_x,
+ # 3: pitch_y,
+ # 4: trace_width_y,
+ # 5: diagonal_clearance,
+ # 6: annular_ring_width]
+ starburst_macro = ApertureMacro('STARB', 6, primitives=(
+ amp.CenterLine(MM, 1, var(1), var(2)),
+ amp.CenterLine(MM, 1, var(4), var(3)),
+ amp.VectorLine(MM, 0, var(5), -var(1)/2, -var(3)/2, var(1)/2, var(3)/2),
+ amp.VectorLine(MM, 0, var(5), var(1)/2, -var(3)/2, -var(1)/2, var(3)/2),
+ amp.Circle(MM, 1, var(6)),
+ ))
+
+ main_ap = ApertureMacroInstance(starburst_macro, (self.pitch_x - self.solder_clearance, # 1
+ self.trace_width_x, # 2
+ self.pitch_y - self.solder_clearance, # 3
+ self.trace_width_y, # 4
+ self.mask_width, # 5
+ self.annular_ring), unit=self.unit) # 6
+
+ mask_ap = ApertureMacroInstance(starburst_macro, (self.pitch_x, # 1
+ self.trace_width_x, # 2
+ self.pitch_y, # 3
+ self.trace_width_y, # 4
+ self.mask_width, # 5
+ self.annular_ring), unit=self.unit) # 6
+
+ yield PadStackAperture(main_ap, 'top', 'copper')
+ yield PadStackAperture(mask_ap, 'top', 'mask')
+ yield PadStackAperture(main_ap, 'bottom', 'copper')
+ yield PadStackAperture(mask_ap, 'bottom', 'mask')
+
+ drill = ExcellonTool(self.drill, plated=True, unit=self.unit)
+ yield PadStackAperture(drill, 'drill', 'plated', 0, 0)
+
+class AlioCell(Positioned):
""" Cell primitive for the ALio protoboard designed by arief ibrahim adha and published on hackaday.io at the URL
below. Similar to electroniceel's spiky protoboard, this layout has small-ish standard THT pads, but in between
these pads it puts a grid of SMD pads that are designed for easy solder bridging to allow for the construction of
@@ -550,82 +1029,75 @@ class AlioCell(ObjectGroup):
self.link_pad_width = link_pad_width or unit(1.1, MM)
self.link_trace_width = link_trace_width or unit(0.5, MM)
self.via_size = via_size or unit(0.4, MM)
- self.border_x, self.border_y = False, False
+ self.border_s, self.border_w, self.border_n, self.border_e = False, False, False, False
self.inst_x, self.inst_y = None, None
@property
def single_sided(self):
return False
- def inst(self, x, y, border_x, border_y):
+ def inst(self, x, y, border):
inst = copy(self)
- inst.border_x, inst.border_y = border_x, border_y
+ inst.border_s, inst.border_w, inst.border_n, inst.border_e = border
inst.inst_x, inst.inst_y = x, y
return inst
def bounding_box(self, unit):
- x, y, rotation = self.abs_pos
+ x, y, rotation, flip = self.abs_pos
# FIXME hack
return self.unit.convert_bounds_to(unit, ((x-self.pitch/2, y-self.pitch/2), (x+self.pitch/2, y+self.pitch/2)))
def render(self, layer_stack, cache=None):
- x, y, rotation = self.abs_pos
+ x, y, rotation, flip = self.abs_pos
def xf(fe):
fe = copy(fe)
fe.rotate(rotation)
fe.offset(x, y, self.unit)
return fe
- var = VariableExpression
+ var = ParameterExpression
+ foo = VariableExpression(var(2)/2 - var(1)/2 + var(4))
+ bar = VariableExpression(var(4)+var(6))
# parameters: [1: total height = pad width, 2: pitch, 3: trace width, 4: corner radius, 5: rotation, 6: clearance]
- alio_main_macro = ApertureMacro('ALIOM', (
+ alio_main_macro = ApertureMacro('ALIOM', 6, primitives=(
amp.CenterLine(MM, 1, var(2)-var(6), var(2)-var(3)-2*var(6), 0, 0, var(5)),
amp.Outline(MM, 0, 5, (
-var(2)/2, -var(2)/2,
- -var(2)/2, -(var(7)-var(8)),
- -var(7), -(var(7)-var(8)),
- -(var(7)-var(8)), -var(7),
- -(var(7)-var(8)), -var(2)/2,
+ -var(2)/2, -(foo-bar),
+ -foo, -(foo-bar),
+ -(foo-bar), -foo,
+ -(foo-bar), -var(2)/2,
-var(2)/2, -var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
- -var(2)/2, var(2)/2,
- -var(2)/2, (var(7)-var(8)),
- -var(7), (var(7)-var(8)),
- -(var(7)-var(8)), var(7),
- -(var(7)-var(8)), var(2)/2,
- -var(2)/2, var(2)/2,
+ -var(2)/2, var(2)/2,
+ -var(2)/2, (foo-bar),
+ -foo, (foo-bar),
+ -(foo-bar), foo,
+ -(foo-bar), var(2)/2,
+ -var(2)/2, var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
var(2)/2, -var(2)/2,
- var(2)/2, -(var(7)-var(8)),
- var(7), -(var(7)-var(8)),
- (var(7)-var(8)), -var(7),
- (var(7)-var(8)), -var(2)/2,
+ var(2)/2, -(foo-bar),
+ foo, -(foo-bar),
+ (foo-bar), -foo,
+ (foo-bar), -var(2)/2,
var(2)/2, -var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
- var(2)/2, var(2)/2,
- var(2)/2, (var(7)-var(8)),
- var(7), (var(7)-var(8)),
- (var(7)-var(8)), var(7),
- (var(7)-var(8)), var(2)/2,
- var(2)/2, var(2)/2,
+ var(2)/2, var(2)/2,
+ var(2)/2, (foo-bar),
+ foo, (foo-bar),
+ (foo-bar), foo,
+ (foo-bar), var(2)/2,
+ var(2)/2, var(2)/2,
), var(5)),
- amp.Circle(MM, 0, 2*var(8), -var(7), -var(7), var(5)),
- amp.Circle(MM, 0, 2*var(8), -var(7), var(7), var(5)),
- amp.Circle(MM, 0, 2*var(8), var(7), -var(7), var(5)),
- amp.Circle(MM, 0, 2*var(8), var(7), var(7), var(5)),
- ), (
- None, # 1
- None, # 2
- None, # 3
- None, # 4
- None, # 5
- None, # 6
- var(2)/2 - var(1)/2 + var(4), # 7
- var(4)+var(6), # 8
- ))
+ amp.Circle(MM, 0, 2*bar, -foo, -foo, var(5)),
+ amp.Circle(MM, 0, 2*bar, -foo, foo, var(5)),
+ amp.Circle(MM, 0, 2*bar, foo, -foo, var(5)),
+ amp.Circle(MM, 0, 2*bar, foo, foo, var(5)),
+ ))
corner_radius = (self.link_pad_width - self.link_trace_width)/3
main_ap = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1
self.pitch, # 2
@@ -643,7 +1115,7 @@ class AlioCell(ObjectGroup):
via_drill = ExcellonTool(self.via_size, plated=True, unit=self.unit)
# parameters: [1: total height = pad width, 2: total width, 3: trace width, 4: corner radius, 5: rotation]
- alio_macro = ApertureMacro('ALIOP', (
+ alio_macro = ApertureMacro('ALIOP', primitives=(
amp.CenterLine(MM, 1, var(1)-2*var(4), var(1), 0, 0, var(5)),
amp.CenterLine(MM, 1, var(1), var(1)-2*var(4), 0, 0, var(5)),
amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), -var(1)/2+var(4), var(5)),
@@ -663,19 +1135,26 @@ class AlioCell(ObjectGroup):
corner_radius, # 4
rotation+90), unit=MM) # 5
+ end_pad = RectangleAperture(self.link_trace_width, self.pitch - 2*self.clearance - self.link_pad_width, unit=self.unit)
+ end_pad_90 = end_pad.rotated(math.pi/2)
+
# all layers are identical here
for side, use in (('top', 'copper'), ('top', 'mask'), ('bottom', 'copper'), ('bottom', 'mask')):
if side == 'top':
layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap, unit=self.unit)))
- if not self.border_y:
+ if not self.border_s and not self.border_e:
layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark, unit=self.unit)))
+ if self.border_e and not self.border_s:
+ layer_stack[side, use].objects.append(xf(Flash(0, self.pitch/2, aperture=end_pad_90, unit=self.unit)))
else:
layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap_90, unit=self.unit)))
- if not self.border_x:
+ if not self.border_e and not self.border_s:
layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark_90, unit=self.unit)))
+ if self.border_s and not self.border_e:
+ layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, 0, aperture=end_pad, unit=self.unit)))
layer_stack.drill_pth.append(Flash(x, y, aperture=main_drill, unit=self.unit))
- if not (self.border_x or self.border_y):
+ if not (self.border_e or self.border_s):
layer_stack.drill_pth.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=via_drill, unit=self.unit)))
diff --git a/gerbonara/cad/protoserve.py b/gerbonara/cad/protoserve.py
index 25ef8c6..6acc802 100644
--- a/gerbonara/cad/protoserve.py
+++ b/gerbonara/cad/protoserve.py
@@ -8,6 +8,7 @@ from quart import Quart, request, Response, send_file, abort
from . import protoboard as pb
from . import protoserve_data
+from .primitives import SMDStack
from ..utils import MM, Inch
@@ -25,7 +26,7 @@ def extract_importlib(package):
else:
assert item.is_dir()
item_out.mkdir()
- stack.push((item, item_out))
+ stack.append((item, item_out))
return root
@@ -62,10 +63,10 @@ def deserialize(obj, unit):
case 'smd':
match obj['pad_shape']:
case 'rect':
- pad = pb.SMDPad.rect(0, 0, pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
+ stack = SMDStack.rect(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)
+ stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
+ return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, unit=unit)
case 'tht':
hole_dia = mil(float(obj['hole_dia']))
@@ -79,11 +80,11 @@ def deserialize(obj, unit):
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)
+ pad = pb.THTPad.rect(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)
+ pad = pb.THTPad.circle(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)
+ pad = pb.THTPad.obround(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
if oneside:
pad.pad_bottom = None
@@ -106,7 +107,8 @@ def deserialize(obj, unit):
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)
+ clearance = mil(float(obj['clearance']))
+ return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit)
case 'spiky':
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
@@ -127,6 +129,20 @@ def deserialize(obj, unit):
via_size=via_size
), margin=unit(1.5, MM), unit=unit)
+ case 'breadboard':
+ horizontal = obj.get('direction', 'v') == 'h'
+ drill = float(obj.get('hole_dia', 0.9))
+ return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit)
+
+ case 'starburst':
+ trace_width_x = float(obj.get('trace_width_x', 1.8))
+ trace_width_y = float(obj.get('trace_width_y', 1.8))
+ drill = float(obj.get('hole_dia', 0.9))
+ annular_ring = float(obj.get('annular', 1.2))
+ clearance = float(obj.get('clearance', 0.4))
+ mask_width = float(obj.get('mask_width', 0.5))
+ return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit)
+
case 'rf':
pitch = float(obj.get('pitch', 2.54))
hole_dia = float(obj['hole_dia'])
@@ -139,6 +155,7 @@ def to_board(obj):
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)))
+ margin = float(obj.get('margin', unit(2.0, 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)))
@@ -155,13 +172,14 @@ def to_board(obj):
corner_radius=corner_radius,
mounting_hole_dia=mounting_hole_dia,
mounting_hole_offset=mounting_hole_offset,
+ margin=margin,
unit=unit)
-@app.route('/preview.svg', methods=['POST'])
-async def preview():
+@app.route('/preview_<side>.svg', methods=['POST'])
+async def preview(side):
obj = await request.get_json()
board = to_board(obj)
- return Response(str(board.pretty_svg()), mimetype='image/svg+xml')
+ return Response(str(board.pretty_svg(side=side)), mimetype='image/svg+xml')
@app.route('/gerbers.zip', methods=['POST'])
async def gerbers():
diff --git a/gerbonara/cad/protoserve_data/__init__.py b/gerbonara/cad/protoserve_data/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gerbonara/cad/protoserve_data/__init__.py
diff --git a/gerbonara/cad/protoserve_data/protoserve.html b/gerbonara/cad/protoserve_data/protoserve.html
index c42ce6c..4da027a 100644
--- a/gerbonara/cad/protoserve_data/protoserve.html
+++ b/gerbonara/cad/protoserve_data/protoserve.html
@@ -177,11 +177,14 @@ input[type="text"]:focus:valid {
position: relative;
grid-area: main;
padding: 20px;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ align-items: stretch;
}
-#preview-image {
- width: 100%;
- height: 100%;
+#preview > img {
+ flex-grow: 1;
object-fit: contain;
}
@@ -280,6 +283,12 @@ input[type="text"]:focus:valid {
<span class="unit us">inch</span>
</label>
+ <label>Margin
+ <input type="text" placeholder="margin" name="margin" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
+ <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/>
@@ -316,7 +325,8 @@ input[type="text"]:focus:valid {
</form>
</div>
<div id="preview">
- <img id="preview-image" alt="Automatically generated preview image"/>
+ <img id="preview-image-top" alt="Automatically generated top side preview image"/>
+ <img id="preview-image-bottom" alt="Automatically generated bottom side preview image"/>
<div id="preview-message"></div>
</div>
<div id="links">
@@ -401,6 +411,8 @@ input[type="text"]:focus:valid {
<a href="#" data-placeholder="rf" class="double-sided-only">RF THT area</a>
<a href="#" data-placeholder="spiky" class="double-sided-only">Spiky hybrid area</a>
<a href="#" data-placeholder="alio" class="double-sided-only">ALio hybrid area</a>
+ <a href="#" data-placeholder="starburst" class="double-sided-only">THT starburst area</a>
+ <a href="#" data-placeholder="breadboard" class="double-sided-only">Permanent breadboard area</a>
</div>
</div>
</template>
@@ -468,7 +480,7 @@ input[type="text"]:focus:valid {
<span class="unit us">mil</span>
</label>
<label>Plating
- <select name="plating" value="through">
+ <select name="plating" value="plated">
<option value="plated">Double-sided, through-plated</option>
<option value="nonplated">Double-sided, non-plated</option>
<option value="singleside">Single-sided, non-plated</option>
@@ -494,6 +506,34 @@ input[type="text"]:focus:valid {
</div>
</template>
+ <template id="tpl-g-breadboard">
+ <div data-type="breadboard" class="group breadboard">
+ <h4>Permanent breadboard 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" pattern="[0-9]+\.?[0-9]*"/>
+ </label>
+
+ <h5>Area Settings</h5>
+ <label>Direction
+ <select name="direction" value="v">
+ <option value="v">Vertical</option>
+ <option value="h">Horizontal</option>
+ </select>
+ </label>
+ <label>Clearance
+ <input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
+ <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" pattern="[0-9]+\.?[0-9]*"/>
+ <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>
@@ -699,6 +739,58 @@ input[type="text"]:focus:valid {
</div>
</template>
+ <template id="tpl-g-starburst">
+ <div data-type="starburst" class="group starburst">
+ <h4>Starburst 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" pattern="[0-9]+\.?[0-9]*"/>
+ </label>
+
+ <h5>Area Settings</h5>
+ <label>Pitch X
+ <input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
+ <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" pattern="[0-9]+\.?[0-9]*"/>
+ <span class="unit metric">mm</span>
+ <span class="unit us">mil</span>
+ </label>
+ <label>Drill diameter
+ <input type="text" name="drill" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
+ <span class="unit metric">mm</span>
+ <span class="unit us">mil</span>
+ </label>
+ <label>Annular ring
+ <input type="text" name="annular" placeholder="length" value="1.2" pattern="[0-9]+\.?[0-9]*"/>
+ <span class="unit metric">mm</span>
+ <span class="unit us">mil</span>
+ </label>
+ <label>Pad clearance
+ <input type="text" name="clearance" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
+ <span class="unit metric">mm</span>
+ <span class="unit us">mil</span>
+ </label>
+ <label>Soldermask wall
+ <input type="text" name="mask_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
+ <span class="unit metric">mm</span>
+ <span class="unit us">mil</span>
+ </label>
+ <label>Trace width X
+ <input type="text" name="trace_width_x" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
+ <span class="unit metric">mm</span>
+ <span class="unit us">mil</span>
+ </label>
+ <label>Trace width Y
+ <input type="text" name="trace_width_y" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
+ <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');
@@ -985,26 +1077,43 @@ input[type="text"]:focus:valid {
}
}
- let previewBlobURL = null;
+ let previewTopBlobURL = null;
+ let previewBotBlobURL = null;
previewReloader = new RateLimiter(async () => {
if (document.querySelector('form').checkValidity()) {
document.querySelector('#preview-message').textContent = 'Reloading...';
document.querySelector('#preview-message').classList.add('loading');
- const response = await fetch('preview.svg', {
+
+ const response_top = await fetch('preview_top.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);
+ const data_top = await response_top.blob();
+ if (previewTopBlobURL) {
+ URL.revokeObjectURL(previewTopBlobURL);
}
- previewBlobURL = URL.createObjectURL(data);
- document.querySelector('#preview-image').src = previewBlobURL;
+ previewTopBlobURL = URL.createObjectURL(data_top);
+ document.querySelector('#preview-image-top').src = previewTopBlobURL;
+
document.querySelector('#preview-message').textContent = '';
document.querySelector('#preview-message').classList.remove('loading');
+
+ const response_bot = await fetch('preview_bottom.svg', {
+ method: 'POST',
+ mode: 'same-origin',
+ cache: 'no-cache',
+ headers: {'Content-Type': 'application/json'},
+ body: serialize(),
+ });
+ const data_bot = await response_bot.blob();
+ if (previewBotBlobURL) {
+ URL.revokeObjectURL(previewBotBlobURL);
+ }
+ previewBotBlobURL = URL.createObjectURL(data_bot);
+ document.querySelector('#preview-image-bottom').src = previewBotBlobURL;
} else {
document.querySelector('#preview-message').classList.add('loading');
document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.';