diff options
Diffstat (limited to 'gerbonara/cad')
-rw-r--r-- | gerbonara/cad/data/__init__.py | 0 | ||||
-rw-r--r-- | gerbonara/cad/kicad/__init__.py | 1 | ||||
-rw-r--r-- | gerbonara/cad/kicad/base_types.py | 108 | ||||
-rw-r--r-- | gerbonara/cad/kicad/footprints.py | 100 | ||||
-rw-r--r-- | gerbonara/cad/kicad/graphical_primitives.py | 91 | ||||
-rw-r--r-- | gerbonara/cad/kicad/pcb.py | 119 | ||||
-rw-r--r-- | gerbonara/cad/kicad/primitives.py | 33 | ||||
-rw-r--r-- | gerbonara/cad/kicad/schematic.py | 13 | ||||
-rw-r--r-- | gerbonara/cad/kicad/sexp_mapper.py | 18 | ||||
-rw-r--r-- | gerbonara/cad/kicad/symbols.py | 11 | ||||
-rw-r--r-- | gerbonara/cad/primitives.py | 66 | ||||
-rw-r--r-- | gerbonara/cad/protoboard.py | 869 | ||||
-rw-r--r-- | gerbonara/cad/protoserve.py | 40 | ||||
-rw-r--r-- | gerbonara/cad/protoserve_data/__init__.py | 0 | ||||
-rw-r--r-- | gerbonara/cad/protoserve_data/protoserve.html | 133 |
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.'; |