diff options
Diffstat (limited to 'gerbonara/cad/kicad')
-rw-r--r-- | gerbonara/cad/kicad/base_types.py | 69 | ||||
-rw-r--r-- | gerbonara/cad/kicad/footprints.py | 70 | ||||
-rw-r--r-- | gerbonara/cad/kicad/graphical_primitives.py | 91 | ||||
-rw-r--r-- | gerbonara/cad/kicad/pcb.py | 117 | ||||
-rw-r--r-- | gerbonara/cad/kicad/primitives.py | 32 | ||||
-rw-r--r-- | gerbonara/cad/kicad/schematic.py | 10 | ||||
-rw-r--r-- | gerbonara/cad/kicad/sexp_mapper.py | 18 |
7 files changed, 259 insertions, 148 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index a4eba70..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,6 +38,16 @@ 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) @@ -112,6 +123,14 @@ 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: @@ -259,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') @@ -281,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 @@ -316,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: OmitDefault(Named(YesNoAtom())) = False - italic: OmitDefault(Named(YesNoAtom())) = False + bold: OmitDefault(Named(LegacyCompatibleFlag())) = False + italic: OmitDefault(Named(LegacyCompatibleFlag())) = False line_spacing: Named(float) = None @@ -345,8 +394,8 @@ class Justify: @sexp_type('effects') class TextEffect: font: FontSpec = field(default_factory=FontSpec) - hide: OmitDefault(Named(YesNoAtom())) = False justify: OmitDefault(Justify) = field(default_factory=Justify) + hide: OmitDefault(Named(LegacyCompatibleFlag())) = False class TextMixin: @@ -523,13 +572,13 @@ class DrawnProperty(TextMixin): key: str = None value: str = None id: Named(int) = None - at: AtPos = field(default_factory=AtPos) - unlocked: Named(YesNoAtom()) = True + at: AtPos = None + unlocked: OmitDefault(Named(YesNoAtom())) = True layer: Named(str) = None - hide: Named(YesNoAtom()) = False - uuid: UUID = field(default_factory=UUID) + 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 cb7b69d..1d7ee08 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -55,7 +55,7 @@ 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 @@ -243,7 +243,7 @@ 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 @@ -253,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: @@ -268,12 +268,12 @@ 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 @@ -314,7 +314,7 @@ class Dimension: 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 @@ -334,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 @@ -376,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 @@ -395,7 +389,7 @@ class Pad: 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 @@ -411,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) @@ -619,6 +613,7 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517] class Footprint: name: str = None _version: Named(int, name='version') = 20221018 + uuid: UUID = field(default_factory=UUID) generator: Named(str) = Atom.gerbonara generator_version: Named(str) = __version__ locked: Flag() = False @@ -657,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: @@ -708,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} @@ -734,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 @@ -818,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'): @@ -828,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): @@ -868,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 @@ -891,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 @@ -970,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 767b763..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) @@ -250,7 +250,8 @@ class Via: keep_end_layers: 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 65566d0..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') @@ -178,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 0a2f4de..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) @@ -357,7 +357,7 @@ class SymbolInstance: # three other uses of the same symbol in this schematic. 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 @@ -496,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 |