Compare commits

..

No commits in common. "main" and "v1.3.0" have entirely different histories.
main ... v1.3.0

574 changed files with 1572 additions and 139108 deletions

2
.gitignore vendored
View file

@ -3,5 +3,3 @@ gerbonara_test_failures
__pycache__
.tox
docs/_build/
build
dist

View file

@ -14,7 +14,8 @@ build:archlinux:
GIT_SUBMODULE_STRATEGY: none
script:
- git config --global --add safe.directory "$CI_PROJECT_DIR"
- uv build
- pip3 install --user --break-system-packages wheel setuptools
- python3 setup.py sdist bdist_wheel
artifacts:
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
paths:

View file

@ -60,7 +60,6 @@ layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
These built-in rules should work with common settings in a wide variety of CAD tools.
.. option:: --warnings [default|ignore|once]

View file

@ -71,7 +71,7 @@ Then, you are ready to read and write gerber files:
from gerbonara import LayerStack
stack = LayerStack.open('output/gerber')
stack = LayerStack.from_directory('output/gerber')
w, h = stack.outline.size('mm')
print(f'Board size is {w:.1f} mm x {h:.1f} mm')

View file

@ -10,7 +10,7 @@ from gerbonara.utils import MM
from gerbonara.utils import rotate_point
def highlight_outline(input_dir, output_dir):
stack = LayerStack.open(input_dir)
stack = LayerStack.from_directory(input_dir)
outline = []
for obj in stack.outline.objects:
@ -28,6 +28,7 @@ def highlight_outline(input_dir, output_dir):
marker_nx, marker_ny = math.sin(marker_angle), math.cos(marker_angle)
ap = CircleAperture(0.1, unit=MM)
stack['top silk'].apertures.append(ap)
for line in outline:
cx, cy = (line.x1 + line.x2)/2, (line.y1 + line.y2)/2

View file

@ -7,5 +7,5 @@ if __name__ == '__main__':
args = parser.parse_args()
import gerbonara
print(gerbonara.LayerStack.open(args.input))
print(gerbonara.LayerStack.from_directory(args.input))

View file

@ -2,7 +2,6 @@
import math
from gerbonara.utils import MM
from gerbonara.graphic_objects import Arc
from gerbonara.graphic_objects import rotate_point
@ -23,8 +22,7 @@ def approx_test():
x1, y1 = rotate_point(0, -1, start_angle*eps)
x2, y2 = rotate_point(x1, y1, sweep_angle*eps*(-1 if clockwise else 1))
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None,
polarity_dark=True, unit=MM)
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None, polarity_dark=True)
lines = arc.approximate(max_error=max_error)
print(f'<path style="fill: {color}; stroke: none;" d="M {cx} {cy} L {lines[0].x1} {lines[0].y1}', end=' ')

View file

@ -30,6 +30,5 @@ from .excellon import ExcellonFile
from .ipc356 import Netlist
from .layers import LayerStack
from .utils import MM, Inch
from importlib.metadata import version
__version__ = version('gerbonara')
__version__ = '1.3.0'

View file

@ -62,7 +62,7 @@ class Expression:
return expr(other) / self
def __neg__(self):
return NegatedExpression(self).optimized()
return NegatedExpression(self)
def __pos__(self):
return self
@ -180,9 +180,6 @@ class ConstantExpression(Expression):
@dataclass(frozen=True, slots=True)
class VariableExpression(Expression):
''' An expression that encapsulates some other complex expression and will replace all occurences of it with a newly
allocated variable at export time.
'''
expr: Expression
def optimized(self, variable_binding={}):
@ -204,7 +201,6 @@ class VariableExpression(Expression):
@dataclass(frozen=True, slots=True)
class ParameterExpression(Expression):
''' An expression that refers to a macro variable or parameter '''
number: int
def optimized(self, variable_binding={}):
@ -339,12 +335,6 @@ class OperatorExpression(Expression):
# -x [*/] -y == x [*/] y
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)):
rv = op(l, r)
# -x [*/] y == -(x [*/] y)
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r):
rv = NegatedExpression(op(l, r))
# x [*/] -y == -(x [*/] y)
case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)):
rv = NegatedExpression(op(l, r))
# x + -y == x - y
case (l, operator.add, NegatedExpression(r)):
rv = l-r

View file

@ -0,0 +1,257 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
from dataclasses import dataclass, field, replace
import operator
import re
import ast
import copy
import warnings
import math
from . import primitive as ap
from .expression import *
from ..utils import MM
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
def rad_to_deg(x):
return (x / math.pi) * 180
def _map_expression(node, variables={}, parameters=set()):
if isinstance(node, ast.Num):
return ConstantExpression(node.n)
elif isinstance(node, ast.BinOp):
op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
return OperatorExpression(op_map[type(node.op)],
_map_expression(node.left, variables, parameters),
_map_expression(node.right, variables, parameters))
elif isinstance(node, ast.UnaryOp):
if type(node.op) == ast.UAdd:
return _map_expression(node.operand, variables, parameters)
else:
return NegatedExpression(_map_expression(node.operand, variables, parameters))
elif isinstance(node, ast.Name):
num = int(node.id[3:]) # node.id has format var[0-9]+
if num in variables:
return VariableExpression(variables[num])
else:
parameters.add(num)
return ParameterExpression(num)
else:
raise SyntaxError('Invalid aperture macro expression')
def _parse_expression(expr, variables, parameters):
expr = expr.lower().replace('x', '*')
expr = re.sub(r'\$([0-9]+)', r'var\1', expr)
try:
parsed = ast.parse(expr, mode='eval').body
except SyntaxError as e:
raise SyntaxError('Invalid aperture macro expression') from e
return _map_expression(parsed, variables, parameters)
@dataclass(frozen=True, slots=True)
class ApertureMacro:
name: str = field(default=None, hash=False, compare=False)
num_parameters: int = 0
primitives: tuple = ()
comments: tuple = field(default=(), hash=False, compare=False)
def __post_init__(self):
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
# We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance.
self._reset_name()
def _reset_name(self):
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
@classmethod
def parse_macro(kls, macro_name, body, unit):
comments = []
variables = {}
parameters = set()
primitives = []
blocks = body.split('*')
for block in blocks:
if not (block := block.strip()): # empty block
continue
if block.startswith('0 '): # comment
comments.append(block[2:])
continue
block = re.sub(r'\s', '', block)
if block[0] == '$': # variable definition
try:
name, _, expr = block.partition('=')
number = int(name[1:])
if number in variables:
warnings.warn(f'Re-definition of aperture macro variable ${number} inside macro. Previous definition of ${number} was ${variables[number]}.')
variables[number] = _parse_expression(expr, variables, parameters)
except Exception as e:
raise SyntaxError(f'Error parsing variable definition {block!r}') from e
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg, variables, parameters) for arg in args ]
try:
primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args))
except KeyError as e:
raise SyntaxError(f'Unknown aperture macro primitive code {int(primitive)}')
return kls(macro_name, max(parameters, default=0), tuple(primitives), tuple(comments))
def __str__(self):
return f'<Aperture macro {self.name}, primitives {self.primitives}>'
def __repr__(self):
return str(self)
def dilated(self, offset, unit=MM):
new_primitives = []
for primitive in self.primitives:
try:
if primitive.exposure.calculate():
new_primitives += primitive.dilated(offset, unit)
except IndexError:
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
pass
return replace(self, primitives=tuple(new_primitives))
def substitute_params(self, params, unit=None, macro_name=None):
params = dict(enumerate(params, start=1))
return replace(self,
num_parameters=0,
name=macro_name,
primitives=tuple(p.substitute_params(params, unit) for p in self.primitives),
comments=(f'Fully substituted instance of {self.name} macro',
f'Original parameters: {"X".join(map(str, params.values())) if params else "none"}'))
def to_gerber(self, settings):
""" Serialize this macro's content (without the name) into Gerber using the given file unit """
comments = [ f'0 {c.replace("*", "_").replace("%", "_")}' for c in self.comments ]
subexpression_variables = {}
def register_variable(expr):
expr_str = expr.to_gerber(register_variable, settings.unit)
if expr_str not in subexpression_variables:
subexpression_variables[expr_str] = self.num_parameters + 1 + len(subexpression_variables)
return subexpression_variables[expr_str]
primitive_defs = [prim.to_gerber(register_variable, settings) for prim in self.primitives]
variable_defs = [f'${num}={expr_str}' for expr_str, num in subexpression_variables.items()]
return '*\n'.join(comments + variable_defs + primitive_defs)
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
parameters = dict(enumerate(parameters, start=1))
for primitive in self.primitives:
yield from primitive.to_graphic_primitives(offset, rotation, parameters, unit, polarity_dark)
def rotated(self, angle):
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
return replace(self, primitives=tuple(
replace(primitive, rotation=primitive.rotation - rad_to_deg(angle)) for primitive in self.primitives))
def scaled(self, scale):
return replace(self, primitives=tuple(
primitive.scaled(scale) for primitive in self.primitives))
var = ParameterExpression
deg_per_rad = 180 / math.pi
class GenericMacros:
_generic_hole = lambda n: (ap.Circle('mm', 0, var(n), 0, 0),)
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
# API.
circle = ApertureMacro('GNC', 4, (
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
*_generic_hole(2)))
rect = ApertureMacro('GNR', 5, (
ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad),
*_generic_hole(3)))
# params: width, height, corner radius, *hole, rotation
rounded_rect = ApertureMacro('GRR', 6, (
ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad),
ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad),
ap.Circle('mm', 1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), var(6) * -deg_per_rad),
*_generic_hole(4)))
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
isosceles_trapezoid = ApertureMacro('GTR', 6, (
ap.Outline('mm', 1, 4,
(var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
var(6) * -deg_per_rad),
*_generic_hole(4)))
# params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation
rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, (
ap.Outline('mm', 1, 4,
(var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
var(7) * -deg_per_rad),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,
var(1)/-2+var(3)/2, var(2)/2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/-2+var(3)/2, var(2)/2,
var(1)/2-var(3)/2, var(2)/2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/2-var(3)/2, var(2)/2,
var(1)/2, var(2)/-2,),
ap.VectorLine('mm', 1, var(4)*2,
var(1)/2, var(2)/-2,
var(1)/-2, var(2)/-2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/-2, var(2)/-2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/-2+var(3)/2, var(2)/2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/2-var(3)/2, var(2)/2,),
ap.Circle('mm', 1, var(4)*2,
var(1)/2, var(2)/-2,),
*_generic_hole(5)))
# w must be larger than h
# params: width, height, *hole, rotation
obround = ApertureMacro('GNO', 5, (
ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad),
ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad),
*_generic_hole(3) ))
polygon = ApertureMacro('GNP', 4, (
ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad),
ap.Circle('mm', 0, var(4), 0, 0)))
if __name__ == '__main__':
import sys
#for line in sys.stdin:
#expr = _parse_expression(line.strip())
#print(expr, '->', expr.optimized())
for primitive in parse_macro(sys.stdin.read(), 'mm'):
print(primitive)

View file

@ -105,10 +105,6 @@ class Circle(Primitive):
with self.Calculator(self, variable_binding, unit) as calc:
x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
x, y = x+offset[0], y+offset[1]
if math.isclose(calc.diameter, 0):
return []
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
@ -148,9 +144,6 @@ class VectorLine(Primitive):
center_x, center_y = center_x+offset[0], center_y+offset[1]
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
if math.isclose(calc.width, 0):
return []
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
@ -189,9 +182,6 @@ class CenterLine(Primitive):
x, y = x+offset[0], y+offset[1]
w, h = calc.width, calc.height
if math.isclose(calc.width, 0) or math.isclose(calc.height, 0):
return []
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
@ -227,8 +217,7 @@ class Polygon(Primitive):
rotation += deg_to_rad(calc.rotation)
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
print('xy', calc.x, calc.y)
return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation,
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilated(self, offset, unit):
@ -262,9 +251,6 @@ class Moire(Primitive):
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
if math.isclose(calc.d_outer, 0):
return []
pitch = calc.line_thickness + calc.gap_w
for i in range(int(round(calc.num_circles))):
yield gp.Circle(x, y, calc.d_outer/2 - i*pitch, polarity_dark=True)
@ -294,7 +280,7 @@ class Moire(Primitive):
@dataclass(frozen=True, slots=True)
class Thermal(Primitive):
code = 7
# Note: Thermal primitives according to spec don't have an exposure variable
exposure : Expression
# center x/y
x : UnitExpression
y : UnitExpression
@ -309,16 +295,13 @@ class Thermal(Primitive):
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
dark = True
if math.isclose(calc.d_outer, 0):
return []
dark = (bool(calc.exposure) == polarity_dark)
return [
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
gp.Rectangle(x, y, calc.d_outer, calc.gap_w, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, calc.gap_w, calc.d_outer, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
]
def dilate(self, offset, unit):
@ -400,10 +383,6 @@ class Outline(Primitive):
bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ]
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
bound_radii = [None] * len(bound_coords)
if len(bound_coords) < 3:
return []
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
def dilated(self, offset, unit):

View file

@ -21,6 +21,7 @@ import math
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
from functools import lru_cache
from .aperture_macros.parse import GenericMacros
from .utils import LengthUnit, MM, Inch, sum_bounds
from . import graphic_primitives as gp
@ -159,8 +160,7 @@ class ExcellonTool(Aperture):
return self
def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
def _params(self, unit=None):
return (self.unit.convert_to(unit, self.diameter),)
@ -205,9 +205,7 @@ class CircleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros
return GenericMacros.circle(MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit))
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
def _params(self, unit=None):
return _strip_right(
@ -262,11 +260,12 @@ class RectangleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros
return GenericMacros.rect(MM(self.w, self.unit),
MM(self.h, self.unit),
MM(self.hole_dia, self.unit),
rotation)
return ApertureMacroInstance(GenericMacros.rect,
(MM(self.w, self.unit),
MM(self.h, self.unit),
MM(self.hole_dia, self.unit) or 0,
0,
rotation))
def _params(self, unit=None):
return _strip_right(
@ -330,11 +329,12 @@ class ObroundAperture(Aperture):
rotation -= -math.pi/2
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
from .aperture_macros.parse import GenericMacros
return GenericMacros.obround(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
rotation)
return ApertureMacroInstance(GenericMacros.obround,
(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
0,
rotation))
def _params(self, unit=None):
return _strip_right(
@ -390,18 +390,12 @@ class PolygonAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self):
from .aperture_macros.parse import GenericMacros
return GenericMacros.polygon(self.n_vertices,
MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit),
self.rotation)
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
def _params(self, unit=None):
rotation = self.rotation % (2*math.pi / self.n_vertices)
if math.isclose(rotation, 0, abs_tol=1e-6):
rotation = None
else:
rotation = math.degrees(rotation)
if self.hole_dia is not None:
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
@ -462,7 +456,7 @@ class ApertureMacroInstance(Aperture):
# We do this because here we do not have information about which parameter has which physical units.
parameters = self.parameters
if len(parameters) > self.macro.num_parameters:
warnings.warn(f'Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
warnings.warn('Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
parameters = parameters[:self.macro.num_parameters]
return tuple(parameters)

View file

@ -9,8 +9,7 @@ from itertools import cycle
from .sexp import *
from .sexp_mapper import *
from ...newstroke import Newstroke
from ...utils import rotate_point, sum_bounds, Tag, MM
from ...layers import LayerStack
from ...utils import rotate_point, Tag, MM
from ... import apertures as ap
from ... import graphic_objects as go
@ -38,16 +37,6 @@ 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)
@ -67,7 +56,6 @@ class UUID:
@sexp_type('group')
class Group:
locked: Flag() = False
name: str = ""
id: Named(str) = None
uuid: UUID = field(default_factory=UUID)
@ -124,18 +112,6 @@ class Stroke:
return attrs
@sexp_type('fill')
class Fill:
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background, Atom.color)) = Atom.none
color: Color = None
class WidthMixin:
def __post_init__(self):
if self.width is not None:
self.stroke = Stroke(self.width)
class Dasher:
def __init__(self, obj):
if obj.stroke:
@ -283,14 +259,7 @@ class XYCoord:
@sexp_type('pts')
class PointList:
@classmethod
def __map__(kls, obj, parent=None, path=''):
_tag, *values = obj
return [map_sexp(XYCoord, elem, parent=parent, path=path) for elem in values]
@classmethod
def __sexp__(kls, value):
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
xy : List(XYCoord) = field(default_factory=list)
@sexp_type('arc')
@ -303,38 +272,15 @@ class Arc:
@sexp_type('pts')
class ArcPointList:
@classmethod
def __map__(kls, obj, parent=None, path=''):
def __map__(kls, obj, parent=None):
_tag, *values = obj
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), elem, parent=parent, path=path) for elem in values]
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), 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('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
@ -370,8 +316,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(LegacyCompatibleFlag())) = False
italic: OmitDefault(Named(LegacyCompatibleFlag())) = False
bold: OmitDefault(Named(YesNoAtom())) = False
italic: OmitDefault(Named(YesNoAtom())) = False
line_spacing: Named(float) = None
@ -399,8 +345,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:
@ -577,13 +523,13 @@ class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
at: AtPos = None
unlocked: OmitDefault(Named(YesNoAtom())) = True
at: AtPos = field(default_factory=AtPos)
unlocked: Named(YesNoAtom()) = True
layer: Named(str) = None
hide: OmitDefault(Named(YesNoAtom())) = False
uuid: UUID = None
hide: Named(YesNoAtom()) = False
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: OmitDefault(TextEffect) = field(default_factory=TextEffect)
effects: TextEffect = field(default_factory=TextEffect)
_ : SEXP_END = None
parent: object = None
@ -600,14 +546,6 @@ class DrawnProperty(TextMixin):
self.value = value
@sexp_type('chamfer')
class Chamfer:
top_left: Flag() = False
top_right: Flag() = False
bottom_left: Flag() = False
bottom_right: Flag() = False
if __name__ == '__main__':
class Foo:
pass

View file

@ -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: OmitDefault(Named(YesNoAtom())) = False
unlocked: Flag() = False
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
hide: Flag() = False
@ -75,7 +75,6 @@ class TextBox:
text: str = None
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
margins: Rename(gr.Margins) = None
pts: PointList = None
angle: Named(float) = 0.0
layer: Named(str) = None
@ -122,7 +121,7 @@ class Rectangle:
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: gr.FillMode = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False
tstamp: Timestamp = None
@ -156,7 +155,7 @@ class Circle:
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: gr.FillMode = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False
tstamp: Timestamp = None
@ -190,7 +189,6 @@ class Arc:
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
width: Named(float) = None
angle: Named(float) = None
stroke: Stroke = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
@ -245,23 +243,23 @@ class Arc:
@sexp_type('fp_poly')
class Polygon:
pts: PointList = field(default_factory=list)
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
stroke: Stroke = None
fill: gr.FillMode = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False
tstamp: Timestamp = None
def render(self, variables=None, cache=None):
if len(self.pts) < 2:
if len(self.pts.xy) < 2:
return
dasher = Dasher(self)
start = self.pts[0]
start = self.pts.xy[0]
dasher.move(start.x, start.y)
for point in self.pts[1:]:
for point in self.pts.xy[1:]:
dasher.line(point.x, point.y)
if dasher.width > 0:
@ -270,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], unit=MM)
yield go.Region([(pt.x, -pt.y) for pt in self.pts.xy], unit=MM)
@sexp_type('fp_curve')
class Curve:
pts: PointList = field(default_factory=list)
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
@ -287,6 +285,47 @@ class Curve:
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
@sexp_type('format')
class DimensionFormat:
prefix: Named(str) = None
suffix: Named(str) = None
units: Named(int) = 3
units_format: Named(int) = 0
precision: Named(int) = 3
override_value: Named(str) = None
suppress_zeros: Flag() = False
@sexp_type('style')
class DimensionStyle:
thickness: Named(float) = None
arrow_length: Named(float) = None
text_position_mode: Named(int) = 0
extension_height: Named(float) = None
text_frame: Named(int) = 0
extension_offset: Named(str) = None
keep_text_aligned: Flag() = False
@sexp_type('dimension')
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)
height: Named(float) = None
orientation: Named(int) = 0
leader_length: Named(float) = None
gr_text: Named(Text) = None
format: DimensionFormat = field(default_factory=DimensionFormat)
style: DimensionStyle = field(default_factory=DimensionStyle)
def render(self, variables=None, cache=None):
raise NotImplementedError()
@sexp_type('drill')
class Drill:
oval: Flag() = False
@ -295,6 +334,12 @@ 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
@ -311,7 +356,7 @@ class CustomPadPrimitives:
polygons: List(gr.Polygon) = field(default_factory=list)
curves: List(gr.Curve) = field(default_factory=list)
width: Named(float) = None
fill: gr.FillMode = True
fill: Named(YesNoAtom()) = True
def all(self):
yield from self.lines
@ -322,8 +367,16 @@ class CustomPadPrimitives:
yield from self.curves
@sexp_type('chamfer')
class Chamfer:
top_left: Flag() = False
top_right: Flag() = False
bottom_left: Flag() = False
bottom_right: Flag() = False
@sexp_type('pad')
class Pad(NetMixin):
class Pad:
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
@ -335,7 +388,6 @@ class Pad(NetMixin):
properties: List(Property) = field(default_factory=list)
remove_unused_layers: Named(YesNoAtom()) = False
keep_end_layers: Named(YesNoAtom()) = False
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
uuid: UUID = field(default_factory=UUID)
rect_delta: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None
@ -343,12 +395,11 @@ class Pad(NetMixin):
thermal_bridge_width: Named(float) = 0.5
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
net: Net = None
net: NetDef = None
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
pinfunction: Named(str) = None
teardrops: gr.TeardropSpec = None
die_length: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None
@ -358,10 +409,9 @@ class Pad(NetMixin):
thermal_width: Named(float) = None
thermal_gap: Named(float) = None
options: OmitDefault(CustomPadOptions) = None
padstack: gr.PadStack = None
primitives: OmitDefault(CustomPadPrimitives) = None
_: SEXP_END = None
footprint: object = field(repr=False, default=None)
footprint: object = None
def __after_parse__(self, parent=None):
self.layers = unfuck_layers(self.layers)
@ -423,11 +473,11 @@ class Pad(NetMixin):
elif self.shape == Atom.rect:
if margin > 0:
return GenericMacros.rounded_rect(self.size.x+2*margin,
self.size.y+2*margin,
margin,
0, # no hole
rotation)
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
(self.size.x+2*margin, self.size.y+2*margin,
margin,
0, 0, # no hole
rotation), unit=MM)
else:
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation)
@ -454,29 +504,28 @@ class Pad(NetMixin):
# Note: KiCad already uses MM units, so no conversion needed here.
alpha = math.atan(y / dy) if dy > 0 else 0
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
y+2*margin,
2*dy,
0, # no hole
-rotation + math.pi)
return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
(x+dy+2*margin*math.cos(alpha), y+2*margin,
2*dy,
0, 0, # no hole
-rotation + math.pi), unit=MM)
else:
return GenericMacros.rounded_isosceles_trapezoid(x+dy,
y,
2*dy,
margin,
0, # no hole
-rotation + math.pi)
return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid,
(x+dy, y,
2*dy, margin,
0, 0, # no hole
-rotation + math.pi), unit=MM)
elif self.shape == Atom.roundrect:
x, y = self.size.x, self.size.y
r = min(x, y) * self.roundrect_rratio
if margin > -r:
return GenericMacros.rounded_rect(x+2*margin,
y+2*margin,
r+margin,
0, # no hole
rotation)
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
(x+2*margin, y+2*margin,
r+margin,
0, 0, # no hole
rotation), unit=MM)
else:
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)
@ -561,22 +610,15 @@ class Model:
hide: Flag() = False
at: Named(XYZCoord) = field(default_factory=XYZCoord)
offset: Named(XYZCoord) = field(default_factory=XYZCoord)
opacity: Named(float) = None
scale: Named(XYZCoord) = field(default_factory=XYZCoord)
rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
@sexp_type('component_classes')
class FootprintComponentClasses:
classes: List(Named(str, name='class')) = field(default_factory=list)
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('footprint')
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
@ -588,14 +630,12 @@ class Footprint:
descr: Named(str) = None
tags: Named(str) = None
properties: List(DrawnProperty) = field(default_factory=list)
component_classes: FootprintComponentClasses = None
path: Named(str) = None
sheetname: Named(str) = None
sheetfile: Named(str) = None
autoplace_cost90: Named(float) = None
autoplace_cost180: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin_ratio: Named(float) = None
solder_paste_margin: Named(float) = None
solder_paste_ratio: Named(float) = None
clearance: Named(float) = None
@ -613,15 +653,15 @@ class Footprint:
arcs: List(Arc) = field(default_factory=list)
polygons: List(Polygon) = field(default_factory=list)
curves: List(Curve) = field(default_factory=list)
dimensions: List(gr.Dimension) = field(default_factory=list)
dimensions: List(Dimension) = field(default_factory=list)
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
board: object = field(repr=False, default=None)
_bounding_box: tuple = None
board: object = None
def __after_parse__(self, parent):
for pad in self.pads:
@ -668,10 +708,6 @@ 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}
@ -698,14 +734,6 @@ 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
@ -790,7 +818,7 @@ class Footprint:
self.layer = flip_layer(self.layer)
for obj in self.objects():
if getattr(obj, 'layer', None) is not None:
if hasattr(obj, 'layer'):
obj.layer = flip_layer(obj.layer)
if hasattr(obj, 'layers'):
@ -800,9 +828,8 @@ class Footprint:
obj.effects.justify.mirror = not obj.effects.justify.mirror
for obj in self.properties:
if obj.layer is not None:
obj.effects.justify.mirror = not obj.effects.justify.mirror
obj.layer = flip_layer(obj.layer)
obj.effects.justify.mirror = not obj.effects.justify.mirror
obj.layer = flip_layer(obj.layer)
@property
def single_sided(self):
@ -841,20 +868,19 @@ 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:
if prop.at is not None:
prop.at.rotation = (prop.at.rotation + math.degrees(angle)) % 360
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
@ -865,8 +891,7 @@ class Footprint:
pad.at.rotation = (pad.at.rotation + delta) % 360
for prop in self.properties:
if prop.at is not None:
prop.at.rotation = (prop.at.rotation + delta) % 360
prop.at.rotation = (prop.at.rotation + delta) % 360
for text in self.texts:
text.at.rotation = (text.at.rotation + delta) % 360
@ -886,14 +911,11 @@ class Footprint:
(self.zones if zones else []),
self.groups if groups else [])
def render(self, layer_stack, layer_map=None, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
x += self.at.x
y += self.at.y
rotation += math.radians(self.at.rotation)
if layer_map is None:
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
for obj in self.objects(pads=False, text=text, zones=False, groups=False):
if not (layer := layer_map.get(obj.layer)):
continue
@ -948,9 +970,10 @@ class Footprint:
layer_stack.drill_pth.append(fe)
def bounding_box(self, unit=MM):
if not hasattr(self, '_bounding_box'):
if not self._bounding_box:
stack = LayerStack()
self.render(stack, layer_map=None, x=0, y=0, rotation=0, flip=False, text=False, variables={})
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
@ -975,7 +998,9 @@ class FootprintInstance(Positioned):
if self.value is not None:
variables['VALUE'] = str(self.value)
self.sexp.render(layer_stack, layer_map=None,
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
self.sexp.render(layer_stack, layer_map,
x=x, y=y, rotation=rotation,
flip=flip,
text=(not self.hide_text),

View file

@ -1,8 +1,6 @@
import string
import math
import base64
import textwrap
from .sexp import *
from .base_types import *
@ -11,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, arc_bounds
from ...utils import rotate_point, MM
@sexp_type('layer')
class TextLayer:
@ -20,12 +18,10 @@ class TextLayer:
@sexp_type('gr_text')
class Text(TextMixin, BBoxMixin):
locked: Flag() = False
class Text(TextMixin):
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
@ -35,19 +31,16 @@ class Text(TextMixin, BBoxMixin):
@sexp_type('gr_text_box')
class TextBox(BBoxMixin):
class TextBox:
locked: Flag() = False
text: str = ''
start: Named(XYCoord) = None
end: Named(XYCoord) = None
margins: Margins = None
pts: PointList = field(default_factory=list)
pts: PointList = field(default_factory=PointList)
angle: OmitDefault(Named(float)) = 0.0
layer: Named(str) = ""
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
@ -60,7 +53,7 @@ class TextBox(BBoxMixin):
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], unit=MM)
reg = go.Region([(p.x, -p.y) for p in poly.pts.xy], unit=MM)
if self.stroke:
if self.stroke.type not in (None, Atom.default, Atom.solid):
@ -76,15 +69,13 @@ class TextBox(BBoxMixin):
@sexp_type('gr_line')
class Line(WidthMixin):
locked: Flag() = False
class Line:
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):
@ -107,26 +98,6 @@ class Line(WidthMixin):
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('target')
class Target(WidthMixin):
shape: AtomChoice(Atom.x, Atom.plus) = 'plus'
at: AtPos = field(default_factory=AtPos)
size: Rename(XYCoord) = field(default_factory=XYCoord)
width: Named(float) = None
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
raise NotImplementedError('Target objects are not implemented yet')
@sexp_type('fill')
class FillMode:
@ -134,7 +105,7 @@ class FillMode:
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
@classmethod
def __map__(kls, obj, parent=None, path=''):
def __map__(kls, obj, parent=None):
return obj[1] in (Atom.solid, Atom.yes)
@classmethod
@ -142,15 +113,13 @@ class FillMode:
yield [Atom.fill, Atom.solid if value else Atom.none]
@sexp_type('gr_rect')
class Rectangle(BBoxMixin, WidthMixin):
locked: Flag() = False
class Rectangle:
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):
@ -161,9 +130,9 @@ class Rectangle(BBoxMixin, WidthMixin):
if self.fill:
yield rect
if (w := self.stroke.width if self.stroke else self.width):
if self.width:
# FIXME stroke support
yield from rect.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
@property
def top_left(self):
@ -176,24 +145,21 @@ class Rectangle(BBoxMixin, WidthMixin):
@sexp_type('gr_circle')
class Circle(BBoxMixin, WidthMixin):
locked: Flag() = False
class Circle:
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))
w = self.stroke.width if self.stroke else self.width
aperture = ap.CircleAperture(w or 0, unit=MM)
aperture = ap.CircleAperture(self.width or 0, unit=MM)
arc = go.Arc.from_circle(self.center.x, -self.center.y, r, aperture=aperture, unit=MM)
if w:
if self.width:
# FIXME stroke support
yield arc
@ -204,22 +170,15 @@ class Circle(BBoxMixin, WidthMixin):
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(WidthMixin, BBoxMixin):
locked: Flag() = False
class Arc:
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = 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
@ -233,35 +192,35 @@ class Arc(WidthMixin, BBoxMixin):
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):
if not (w := self.stroke.width if self.stroke else self.width):
# FIXME stroke support
if not self.width:
return
aperture = ap.CircleAperture(w, unit=MM)
aperture = ap.CircleAperture(self.width, unit=MM)
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
(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)
(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)
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(BBoxMixin, WidthMixin):
class Polygon:
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):
@ -277,50 +236,41 @@ class Polygon(BBoxMixin, WidthMixin):
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, clockwise = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
centers.append((not clockwise, (cx, -cy)))
(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)))
reg = go.Region(points, centers, unit=MM)
reg.close()
w = self.stroke.width if self.stroke else self.width
# FIXME stroke support
if w and w >= 0.005:
yield from reg.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
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 self.fill:
yield reg
def offset(self, x=0, y=0):
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]
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
@sexp_type('gr_curve')
class Curve(BBoxMixin, WidthMixin):
locked: Flag() = False
pts: PointList = field(default_factory=list)
class Curve:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
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 =[pt.with_offset(x, y) for pt in self.pts]
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
@sexp_type('gr_bbox')
class AnnotationBBox:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
width: Named(float) = None
fill: FillMode = False
def render(self, variables=None):
return []
@ -339,7 +289,6 @@ class DimensionFormat:
precision: Named(int) = 7
override_value: Named(str) = None
suppress_zeros: Flag() = False
suppress_zeroes: Flag() = False
@sexp_type('style')
@ -347,36 +296,19 @@ class DimensionStyle:
thickness: Named(float) = 0.1
arrow_length: Named(float) = 1.27
text_position_mode: Named(int) = 0
arrow_direction: Named(AtomChoice(Atom.inward, Atom.outward)) = None
extension_height: Named(float) = None
text_frame: Named(float) = None
extension_offset: Named(float) = None
keep_text_aligned: Flag() = False
@sexp_type('data')
class Base64Blob:
@classmethod
def __map__(kls, obj, parent=None, path=''):
_data, *content = obj
for x in content[:10]:
print(str(x))
return base64.b64decode(''.join(map(str, content)))
@classmethod
def __sexp__(kls, value):
encoded = base64.b64encode(value).decode()
yield [Atom.data, *textwrap.wrap(encoded, 76)]
@sexp_type('image')
class Image:
at: AtPos = field(default_factory=AtPos)
scale: Named(float) = None
layer: Named(str) = None
locked: Flag() = False
uuid: UUID = field(default_factory=UUID)
data: Base64Blob = ''
data: str = ''
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
@ -384,15 +316,12 @@ class Image:
@sexp_type('dimension')
class Dimension:
value: float = None
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=list)
pts: PointList = field(default_factory=PointList)
height: Named(float) = None
width: Named(float) = None
orientation: Named(int) = None
leader_length: Named(float) = None
gr_text: Text = None
@ -403,60 +332,5 @@ class Dimension:
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
def offset(self, x=0, y=0):
self.pts = [pt.with_offset(x, y) for pt in self.pts]
@sexp_type('options')
class PadStackLayerOptions:
anchor: AtomChoice(Atom.rect, Atom.circle) = Atom.circle
@sexp_type('primitives')
class PadStackPrimitives:
vectors: Rename(Line, name='gr_vector') = field(default_factory=list)
lines: List(Line) = field(default_factory=list)
bboxes: List(AnnotationBBox) = field(default_factory=list)
arcs: List(Arc) = field(default_factory=list)
circles: List(Circle) = field(default_factory=list)
curves: List(Curve) = field(default_factory=list)
polygons:List(Polygon) = field(default_factory=list)
@sexp_type('layer')
class PadStackLayer:
layer: str = ''
shape: Named(AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom)) = Atom.circle
size: Rename(XYCoord) = field(default_factory=XYCoord)
rect_delta: Rename(XYCoord) = None
offset: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
primitives: PadStackPrimitives = None
options: PadStackLayerOptions = None
thermal_bridge_angle: Named(float) = None
thermal_gap: Named(float) = None
thermal_bridge_width: Named(float) = None
clearance: Named(float) = None
zone_connect: Named(int) = None
@sexp_type('padstack')
class PadStack:
mode: Named(AtomChoice('front_inner_back', 'custom')) = Atom.front_inner_back
layers: List(PadStackLayer) = field(default_factory=list)
@sexp_type('teardrops')
class TeardropSpec:
best_length_ratio: Named(float) = 1.0
max_length: Named(float) = 2.0
best_width_ratio: Named(float) = 1.0
max_width: Named(float) = 2.0
curve_points: Named(int) = 0
filter_ratio: Named(float) = 0.9
enabled: Named(YesNoAtom()) = True
allow_two_segments: Named(YesNoAtom()) = True
prefer_zone_connections: Named(YesNoAtom()) = True
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])

View file

@ -59,22 +59,13 @@ def gn_layer_to_kicad(layer, flip=False):
@sexp_type('general')
class GeneralSection:
thickness: Named(float) = 1.60
legacy_teardrops: Named(YesNoAtom()) = False
drawings: Named(int) = None
tracks: Named(int) = None
zones: Named(int) = None
modules: Named(int) = None
nets: Named(int) = None
links: Named(int) = None
no_connects: Named(int) = None
area: Named(Array(float)) = None
@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.auxiliary) = Atom.signal
layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user) = Atom.signal
custom_name: str = None
@ -100,29 +91,76 @@ class StackupSettings:
castellated_pads: Named(YesNoAtom()) = None
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
dashed_line_dash_ratio: Named(float) = 12.0
dashed_line_gap_ratio: Named(float) = 3.0
svguseinch: Named(TFBool) = False
svgprecision: Named(float) = 4
excludeedgelayer: Named(TFBool) = False
plotframeref: Named(TFBool) = False
viasonmask: Named(TFBool) = False
mode: Named(int) = 1
useauxorigin: Named(TFBool) = 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
outputformat: Named(int) = 1
mirror: Named(TFBool) = False
drillshape: Named(int) = 0
scaleselection: Named(int) = 1
outputdirectory: Named(str) = "gerber"
@sexp_type('setup')
class BoardSetup:
@classmethod
def __map__(kls, obj, parent=None, path=''):
return obj
stackup: OmitDefault(StackupSettings) = field(default_factory=StackupSettings)
pad_to_mask_clearance: Named(float) = None
solder_mask_min_width: Named(float) = None
pad_to_past_clearance: Named(float) = None
pad_to_paste_clearance_ratio: Named(float) = None
aux_axis_origin: Rename(XYCoord) = None
grid_origin: Rename(XYCoord) = None
export_settings: ExportSettings = field(default_factory=ExportSettings)
@classmethod
def __sexp__(kls, value):
yield value
@sexp_type('net')
class Net:
index: int = 0
name: str = ''
@sexp_type('segment')
class TrackSegment(BBoxMixin):
class TrackSegment:
start: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
width: Named(float) = 0.5
locked: Flag() = False
layer: Named(str) = 'F.Cu'
extra_layers: Named(Array(str), name='layers') = field(default_factory=list)
solder_mask_margin: Named(float) = None
locked: Flag() = False
net: Named(int) = 0
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
tstamp: Timestamp = field(default_factory=Timestamp)
@classmethod
def from_footprint_line(kls, line, flip=False):
@ -133,15 +171,6 @@ class TrackSegment(BBoxMixin):
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
def __after_parse__(self, parent):
if self.extra_layers:
self.layer, *self.extra_layers = self.extra_layers
def __before_sexp__(self):
if self.extra_layers:
self.extra_layers.insert(0, self.layer)
self.layer = None
@property
def layer_mask(self):
return layer_mask([self.layer])
@ -166,7 +195,7 @@ class TrackSegment(BBoxMixin):
@sexp_type('arc')
class TrackArc(BBoxMixin):
class TrackArc:
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
@ -174,15 +203,14 @@ class TrackArc(BBoxMixin):
layer: Named(str) = 'F.Cu'
locked: Flag() = False
net: Named(int) = 0
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
tstamp: Timestamp = field(default_factory=Timestamp)
_: 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.center is None else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
@property
@ -210,31 +238,19 @@ class TrackArc(BBoxMixin):
self.end = self.end.with_offset(x, y)
@sexp_type('tenting')
class Tenting:
front: Flag() = False
back: Flag() = False
none: Flag() = False
@sexp_type('via')
class Via(BBoxMixin):
class Via:
via_type: AtomChoice(Atom.blind, Atom.micro) = None
locked: Flag() = False
at: Rename(XYCoord) = field(default_factory=XYCoord)
size: Named(float) = 0.8
drill: Named(float) = 0.4
layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu'])
teardrops: gr.TeardropSpec = None
tenting: Tenting = None
padstack: gr.PadStack = None
remove_unused_layers: Flag() = False
keep_end_layers: Flag() = False
free: Named(YesNoAtom()) = False
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
net: Named(int) = 0
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
tstamp: Timestamp = field(default_factory=Timestamp)
@classmethod
def from_pad(kls, pad):
@ -287,70 +303,22 @@ class Via(BBoxMixin):
self.at = self.at.with_offset(x, y)
@sexp_type('net_class')
class LegacyNetclass:
name: str = ''
description: str = ''
clearance: Named(float) = None
trace_width: Named(float) = None
via_dia: Named(float) = None
via_drill: Named(float) = None
uvia_dia: Named(float) = None
uvia_drill: Named(float) = None
diff_pair_width: Named(float) = None
diff_pair_gap: Named(float) = None
nets: Rename(List(Named(str)), name='add_net') = field(default_factory=list)
@sexp_type('generated')
class GeneratedPatterns:
type: Named(Atom) = ''
name: Named(str) = ''
layer: Named(str) = ''
locked: Flag() = False
members: Named(Array(Atom), name='members') = field(default_factory=list)
_ : SEXP_END = None
params: dict = field(default_factory=dict)
def __catchall__(self, sexp_value, path=''):
key, value = sexp_value
self.params[key] = value
@classmethod
def __sexp__(kls, value):
return [kls.name_atom,
['type', value.type],
['name', value.name],
['layer', value.layer],
['locked', ('true' if value.locked else 'false')],
*[[k, v] for k, v in value.params.items()],
['members', *value.members]]
SUPPORTED_FILE_FORMAT_VERSIONS = [20200119, 20200512, 20210108, 20211014, 20220621, 20221018, 20230517, 20240706, 20240922, 20241229]
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('kicad_pcb')
class Board:
_version: Named(int, name='version') = 20230517
generator: Named(str) = Atom.gerbonara
generator_version: Named(str) = Atom.gerbonara
legacy_generator: Named(Array(str), name='host') = None
generator: Named(Atom) = Atom.gerbonara
general: GeneralSection = None
paper: PageSettings = None
legacy_page: Rename(PageSettings, 'page') = None
title_block: TitleBlock = None
page: PageSettings = None
layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list)
setup: BoardSetup = field(default_factory=BoardSetup)
properties: List(Property) = field(default_factory=list)
nets: List(Net) = field(default_factory=list)
legacy_netclasses: List(LegacyNetclass) = field(default_factory=list)
footprints: List(Footprint) = field(default_factory=list)
legacy_footprints: Rename(List(Footprint), 'module') = field(default_factory=list)
# Graphical elements
texts: List(gr.Text) = field(default_factory=list)
text_boxes: List(gr.TextBox) = field(default_factory=list)
lines: List(gr.Line) = field(default_factory=list)
targets: List(gr.Target) = field(default_factory=list)
rectangles: List(gr.Rectangle) = field(default_factory=list)
circles: List(gr.Circle) = field(default_factory=list)
arcs: List(gr.Arc) = field(default_factory=list)
@ -365,11 +333,10 @@ class Board:
# Other stuff
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
generated_patterns: List(GeneratedPatterns) = 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
@ -408,15 +375,15 @@ class Board:
(47, 'F.CrtYd', 'user', 'F.Courtyard'),
(48, 'B.Fab', 'user', None),
(49, 'F.Fab', '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)]]
(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)]]
def rebuild_trace_index(self):
@ -532,8 +499,6 @@ class Board:
fp.board = self
self.nets = {net.index: net.name for net in self.nets}
if self.legacy_page:
self.paper, self.legacy_page = self.legacy_page, None
def __before_sexp__(self):
@ -695,11 +660,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.value):
if value and not match_filter(value, fp.properties.get('value', '')):
continue
if reference and not match_filter(reference, fp.reference):
if reference and not match_filter(reference, fp.properties.get('reference', '')):
continue
if net and not any(pad.net and match_filter(net, pad.net.name) for pad in fp.pads):
if net and not any(match_filter(net, pad.net.name) for pad in fp.pads):
continue
if sheetname and not match_filter(sheetname, fp.sheetname):
continue
@ -815,6 +780,15 @@ 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

View file

@ -38,7 +38,7 @@ def layer_mask(layers):
case 'B.Cu':
mask |= 1<<31
case _:
if (m := re.match(fr'In([0-9]+)\.Cu', layer)):
if (m := re.match(f'In([0-9]+)\.Cu', layer)):
mask |= 1<<int(m.group(1))
return mask
@ -62,8 +62,6 @@ 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.
"""
@ -83,7 +81,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, det < 0
return (cx, cy), radius
@sexp_type('hatch')
@ -94,7 +92,7 @@ class Hatch:
@sexp_type('connect_pads')
class PadConnection:
type: AtomChoice(Atom.yes, Atom.thru_hole_only, Atom.full, Atom.no) = None
type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
clearance: Named(float) = 0
@ -120,7 +118,6 @@ class ZoneFill:
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
radius: Named(float) = 0.125
island_removal_mode: Named(int) = None
island_area_min: Named(float) = None
hatch_thickness: Named(float) = None
@ -136,39 +133,22 @@ class ZoneFill:
class FillPolygon:
layer: Named(str) = ""
island: Wrap(Flag()) = False
pts: ArcPointList = field(default_factory=list)
pts: PointList = field(default_factory=PointList)
@sexp_type('fill_segments')
class FillSegment:
layer: Named(str) = ""
pts: ArcPointList = field(default_factory=list)
pts: PointList = field(default_factory=PointList)
@sexp_type('polygon')
class ZonePolygon:
pts: ArcPointList = field(default_factory=list)
@sexp_type('placement')
class ZonePlacement:
enabled: Named(YesNoAtom()) = False
sheetname: Named(str) = ''
@sexp_type('teardrop')
class ZoneTeardropSpec:
type: Named(AtomChoice(Atom.padvia, Atom.track_end)) = Atom.padvia
@sexp_type('attr')
class ZoneAttr:
teardrop: ZoneTeardropSpec = None
pts: PointList = field(default_factory=PointList)
@sexp_type('zone')
class Zone:
locked: Flag() = False
net: Named(int) = 0
net_name: Named(str) = ""
layer: Named(str) = None
@ -178,12 +158,10 @@ class Zone:
name: Named(str) = None
hatch: Hatch = None
priority: OmitDefault(Named(int)) = 0
attr: ZoneAttr = None
connect_pads: PadConnection = field(default_factory=PadConnection)
min_thickness: Named(float) = 0.254
filled_areas_thickness: Named(YesNoAtom()) = True
keepout: ZoneKeepout = None
placement: ZonePlacement = None
fill: ZoneFill = field(default_factory=ZoneFill)
polygon: ZonePolygon = field(default_factory=ZonePolygon)
fill_polygons: List(FillPolygon) = field(default_factory=list)
@ -200,26 +178,10 @@ 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=list)
pts: PointList = field(default_factory=PointList)
@sexp_type('render_cache')
@ -229,39 +191,4 @@ class RenderCache:
polygons: List(RenderCachePolygon) = field(default_factory=list)
@sexp_type('margins')
class Margins:
left: float = 0.0
top: float = 0.0
right: float = 0.0
bottom: float = 0.0
@sexp_type('comment')
class TitleComment:
@classmethod
def __map__(kls, obj, parent=None, path=''):
lines = []
for lineno, content in zip(obj[1::2], obj[2::2]):
while lineno > len(lines):
lines.append('')
lines[lineno-1] = content
@classmethod
def __sexp__(kls, value):
l = [Atom.comment]
for i, line in enumerate(value.splitlines(), start=1):
l.append(i)
l.append(line.rstrip('\n'))
return l
@sexp_type('title_block')
class TitleBlock:
title: Named(str) = ''
date: Named(str) = ''
rev: Named(str) = ''
company: Named(str) = ''
comment: TitleComment = None

View file

@ -19,7 +19,6 @@ from .symbols import Symbol
from . import graphical_primitives as gr
from .. import primitives as cad_pr
from ... import __version__
from ... import graphic_primitives as gp
from ... import graphic_objects as go
@ -85,12 +84,6 @@ class NoConnect:
fill='none', stroke_width='0.254', stroke=colorscheme.no_connect)
@sexp_type('bus_alias')
class BusAlias:
name: str = ''
members: Named(Array(str)) = field(default_factory=list)
@sexp_type('bus_entry')
class BusEntry:
at: AtPos = field(default_factory=AtPos)
@ -139,7 +132,7 @@ def _polyline_bounds(self):
@sexp_type('wire')
class Wire:
points: PointList = field(default_factory=list)
points: PointList = field(default_factory=PointList)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@ -152,7 +145,7 @@ class Wire:
@sexp_type('bus')
class Bus:
points: PointList = field(default_factory=list)
points: PointList = field(default_factory=PointList)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@ -165,9 +158,8 @@ class Bus:
@sexp_type('polyline')
class Polyline:
points: PointList = field(default_factory=list)
points: PointList = field(default_factory=PointList)
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
uuid: UUID = field(default_factory=UUID)
def bounding_box(self, default=None):
@ -177,23 +169,33 @@ class Polyline:
yield _polyline_svg(self, colorscheme.lines)
@sexp_type('circle')
class Circle:
center: Rename(XYCoord) = field(default_factory=XYCoord)
radius: Named(float) = 0.0
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
@sexp_type('text')
class Text(TextMixin):
text: str = ''
exclude_from_sim: Named(YesNoAtom()) = True
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.text)
@sexp_type('rectangle')
class Rectangle:
start: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
@sexp_type('label')
class LocalLabel(TextMixin):
text: str = ''
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Wrap(Flag()) = False
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
@property
def _text_offset(self):
return (0, -2*self.line_width)
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.labels)
def label_shape_path_d(shape, w, h):
l, r = {
@ -218,36 +220,16 @@ def label_shape_path_d(shape, w, h):
return d + f'L {e+r:.3f} {0:.3f} L {e:.3f} {r:.3f} Z'
@dataclass
class TextLabel(TextMixin):
@sexp_type('global_label')
class GlobalLabel(TextMixin):
text: str = ''
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.dot, Atom.round, Atom.diamond, Atom.rectangle)) = Atom.passive
exclude_from_sim: Named(YesNoAtom()) = False
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Named(YesNoAtom()) = False
fields_autoplaced: Wrap(Flag()) = False
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
properties: List(DrawnProperty) = field(default_factory=list)
properties: List(Property) = field(default_factory=list)
@sexp_type('text')
class Text(TextLabel):
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.text)
@sexp_type('label')
class LocalLabel(TextLabel):
@property
def _text_offset(self):
return (0, -2*self.line_width)
def to_svg(self, colorscheme=Colorscheme.KiCad):
yield from TextMixin.to_svg(self, colorscheme.labels)
@sexp_type('global_label')
class GlobalLabel(TextLabel):
def to_svg(self, colorscheme=Colorscheme.KiCad):
text = super(TextMixin, self).to_svg(colorscheme.labels),
text.attrs['transform'] = f'translate({self.size*0.6:.3f} 0)'
@ -258,7 +240,14 @@ class GlobalLabel(TextLabel):
@sexp_type('hierarchical_label')
class HierarchicalLabel(TextLabel):
class HierarchicalLabel(TextMixin):
text: str = ''
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Wrap(Flag()) = False
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
def to_svg(self, colorscheme=Colorscheme.KiCad):
text, = TextMixin.to_svg(self, colorscheme.labels),
text.attrs['transform'] = f'translate({self.size*1.2:.3f} 0)'
@ -267,15 +256,6 @@ class HierarchicalLabel(TextLabel):
yield Tag('g', children=[frame, text])
@sexp_type('netclass_flag')
class NetclassFlag(TextLabel):
length: Named(float) = 2.54
def to_svg(self, colorscheme=Colorscheme.KiCad):
# FIXME
yield from TextMixin.to_svg(self, colorscheme.text)
@sexp_type('pin')
class Pin:
name: str = '1'
@ -289,8 +269,6 @@ class SymbolCrosslinkSheet:
path: str = ''
reference: Named(str) = ''
unit: Named(int) = 1
value: OmitDefault(Named(str)) = None
footprint: OmitDefault(Named(str)) = None
@sexp_type('project')
@ -309,7 +287,6 @@ class MirrorFlags:
class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
@ -359,14 +336,6 @@ class DrawnProperty(TextMixin):
yield from TextMixin.to_svg(self, colorscheme.values)
@sexp_type('default_instance')
class DefaultSymbolInstance:
reference: Named(str) = ''
unit: Named(int) = 1
value: Named(str) = ''
footprint: Named(str) = ''
@sexp_type('symbol')
class SymbolInstance:
name: str = None
@ -379,9 +348,8 @@ class SymbolInstance:
in_bom: Named(YesNoAtom()) = True
on_board: Named(YesNoAtom()) = True
dnp: Named(YesNoAtom()) = True
fields_autoplaced: Named(YesNoAtom()) = True
fields_autoplaced: Wrap(Flag()) = True
uuid: UUID = field(default_factory=UUID)
default_instance: DefaultSymbolInstance = None
properties: List(DrawnProperty) = field(default_factory=list)
# AFAICT this property is completely redundant.
pins: List(Pin) = field(default_factory=list)
@ -389,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 = field(repr=False, default=None)
schematic: object = None
def __after_parse__(self, parent):
self.schematic = parent
@ -517,11 +485,7 @@ class SubsheetFill:
class Subsheet:
at: Rename(XYCoord) = field(default_factory=XYCoord)
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54))
exclude_from_sim: Named(YesNoAtom()) = False
in_bom: Named(YesNoAtom()) = False
on_board: Named(YesNoAtom()) = False
dnp: Named(YesNoAtom()) = False
fields_autoplaced: Named(YesNoAtom()) = True
fields_autoplaced: Wrap(Flag()) = True
stroke: Stroke = field(default_factory=Stroke)
fill: SubsheetFill = field(default_factory=SubsheetFill)
uuid: UUID = field(default_factory=UUID)
@ -532,10 +496,10 @@ 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 = field(repr=False, default=None)
schematic: object = None
def __after_parse__(self, parent):
self.sheet_name, self.file_name, *_extra_params = self._properties
self.sheet_name, self.file_name = self._properties
self.schematic = parent
def __before_sexp__(self):
@ -580,28 +544,6 @@ class Subsheet:
**self.stroke.svg_attrs(colorscheme.lines))
@sexp_type('rule_area')
class RuleArea:
polyline: Polyline = None
@sexp_type('text_box')
class TextBox(TextMixin):
text: str = None
exclude_from_sim: Named(YesNoAtom()) = False
at: AtPos = field(default_factory=AtPos)
size: Rename(XYCoord) = None
margins: Rename(gr.Margins) = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
fill: OmitDefault(Fill) = None
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
def render(self, variables={}, cache=None):
yield from gr.TextBox.render(self, variables=variables)
@sexp_type('lib_symbols')
class LocalLibrary:
symbols: List(Symbol) = field(default_factory=list)
@ -611,39 +553,26 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20230620]
@sexp_type('kicad_sch')
class Schematic:
_version: Named(int, name='version') = 20230620
generator: Named(str) = 'gerbonara'
generator_version: Named(str) = __version__
legacy_generator: Named(Array(str), name='host') = None
generator: Named(Atom) = Atom.gerbonara
uuid: UUID = field(default_factory=UUID)
page_settings: PageSettings = field(default_factory=PageSettings)
legacy_page: Named(Array(int), name='page') = None
legacy_paper: Named(str, name='paper') = None
title_block: TitleBlock = None
# The doc says this is expected, but eeschema barfs when it's there.
# path: SheetPath = field(default_factory=SheetPath)
lib_symbols: LocalLibrary = field(default_factory=list)
junctions: List(Junction) = field(default_factory=list)
no_connects: List(NoConnect) = field(default_factory=list)
rule_areas: List(RuleArea) = field(default_factory=list)
netclass_flags: List(NetclassFlag) = field(default_factory=list)
bus_aliases: List(BusAlias) = field(default_factory=list)
bus_entries: List(BusEntry) = field(default_factory=list)
wires: List(Wire) = field(default_factory=list)
buses: List(Bus) = field(default_factory=list)
images: List(gr.Image) = field(default_factory=list)
polylines: List(Polyline) = field(default_factory=list)
circles: List(Circle) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
texts: List(Text) = field(default_factory=list)
text_boxes: List(TextBox) = field(default_factory=list)
local_labels: List(LocalLabel) = field(default_factory=list)
global_labels: List(GlobalLabel) = field(default_factory=list)
hierarchical_labels: List(HierarchicalLabel) = field(default_factory=list)
symbols: List(SymbolInstance) = field(default_factory=list)
subsheets: List(Subsheet) = field(default_factory=list)
sheet_instances: Named(Array(SubsheetCrosslinkSheet)) = field(default_factory=list)
symbol_instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
sheet_instances: Named(List(SubsheetCrosslinkSheet)) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None

View file

@ -64,7 +64,7 @@ term_regex = r"""(?mx)
(\))|
([+-]?\d+\.\d+(?=[\s\)]))|
(\-?\d+(?=[\s\)]))|
([^"\s()][^"\s)]*)
([^0-9"\s()][^"\s)]*)
)"""

View file

@ -28,10 +28,6 @@ class AtomChoice:
def __sexp__(self, value):
yield value
def __str__(self):
choices = '|'.join(map(str, self.choices))
return f'AtomChoice({choices})'
class Flag:
def __init__(self, atom=None, invert=None):
@ -52,11 +48,6 @@ class Flag:
def __sexp__(self, value):
if bool(value) == (not self.invert):
yield self.atom
def __str__(self):
if self.invert is not None:
return f'Flag({self.atom}/{self.invert})'
return f'Flag({self.atom})'
def sexp(t, v):
@ -85,7 +76,7 @@ class MappingError(TypeError):
super().__init__(msg)
self.t, self.sexp = t, sexp
def map_sexp(t, v, parent=None, path=''):
def map_sexp(t, v, parent=None):
try:
if t is not Atom and hasattr(t, '__map__'):
return t.__map__(v, parent=parent)
@ -102,7 +93,7 @@ def map_sexp(t, v, parent=None, path=''):
elif isinstance(t, list):
t, = t
return [map_sexp(t, elem, parent=parent, path=f'{path}/{t}') for elem in v]
return [map_sexp(t, elem, parent=parent) for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
@ -111,7 +102,7 @@ def map_sexp(t, v, parent=None, path=''):
raise e
except Exception as e:
raise MappingError(f'Error at {path} trying to map {textwrap.shorten(str(v), width=60)} into type {t}', t, v) from e
raise MappingError(f'Error trying to map {textwrap.shorten(str(v), width=120)} into type {t}', t, v) from e
class WrapperType:
@ -120,8 +111,7 @@ class WrapperType:
def __bind_field__(self, field):
self.field = field
if self.next_type is not Atom:
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
def __atoms__(self):
if hasattr(self, 'name_atom'):
@ -143,12 +133,12 @@ class Named(WrapperType):
if self.name_atom is None:
self.name_atom = Atom(field.name)
def __map__(self, obj, parent=None, path=''):
def __map__(self, obj, parent=None):
k, *obj = obj
if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
return map_sexp(self.next_type, [*obj], parent=parent, path=f'{path}/{self.name_atom}')
return map_sexp(self.next_type, [*obj], parent=parent)
else:
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
value = sexp(self.next_type, value)
@ -160,9 +150,6 @@ class Named(WrapperType):
yield [self.name_atom, *value]
def __str__(self):
return f'Named={self.name_atom}({self.next_type})'
class Rename(WrapperType):
def __init__(self, next_type, name=None):
@ -175,8 +162,8 @@ class Rename(WrapperType):
if hasattr(self.next_type, '__bind_field__'):
self.next_type.__bind_field__(field)
def __map__(self, obj, parent=None, path=''):
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
def __map__(self, obj, parent=None):
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
value, = sexp(self.next_type, value)
@ -186,9 +173,6 @@ class Rename(WrapperType):
key, *rest = value
yield [self.name_atom, *rest]
def __str__(self):
return f'Rename={self.name_atom}({self.next_type})'
class OmitDefault(WrapperType):
def __bind_field__(self, field):
@ -198,42 +182,19 @@ class OmitDefault(WrapperType):
else:
self.default = field.default
def __map__(self, obj, parent=None, path=''):
return map_sexp(self.next_type, obj, parent=parent, path=path)
def __map__(self, obj, parent=None):
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
if value != self.default:
yield from sexp(self.next_type, value)
def __str__(self):
return f'OmitDefault({self.field})'
class YesNoAtom:
def __init__(self, yes=Atom.yes, no=Atom.no):
self.yes, self.no = yes, no
def __map__(self, value, parent=None):
if not value: # compatibility with legacy flag style
return False
value, = value
return value == self.yes
def __sexp__(self, value):
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
@ -242,50 +203,41 @@ class LegacyCompatibleFlag:
class Wrap(WrapperType):
def __map__(self, value, parent=None, path=''):
def __map__(self, value, parent=None):
value, = value
return map_sexp(self.next_type, value, parent=parent, path=path)
return map_sexp(self.next_type, value, parent=parent)
def __sexp__(self, value):
for inner in sexp(self.next_type, value):
yield [inner]
def __str__(self):
return f'Wrap({self.next_type})'
class Array(WrapperType):
def __map__(self, value, parent=None, path=''):
return [map_sexp(self.next_type, [elem], parent=parent, path=path) for elem in value]
def __map__(self, value, parent=None):
return [map_sexp(self.next_type, [elem], parent=parent) for elem in value]
def __sexp__(self, value):
for e in value:
yield from sexp(self.next_type, e)
def __str__(self):
return f'Array({self.next_type})'
class Untagged(WrapperType):
def __map__(self, value, parent=None, path=''):
def __map__(self, value, parent=None):
value, = value
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent, path=path)
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent)
def __sexp__(self, value):
for inner in sexp(self.next_type, value):
_tag, *rest = inner
yield rest
def __str__(self):
return f'Untagged({self.next_type})'
class List(WrapperType):
def __bind_field__(self, field):
self.attr = field.name
def __map__(self, value, parent, path=''):
def __map__(self, value, parent):
l = getattr(parent, self.attr, [])
mapped = map_sexp(self.next_type, value, parent=parent, path=f'{path}/{self.attr}')
mapped = map_sexp(self.next_type, value, parent=parent)
l.append(mapped)
setattr(parent, self.attr, l)
@ -293,9 +245,6 @@ class List(WrapperType):
for elem in value:
yield from sexp(self.next_type, elem)
def __str__(self):
return f'List@{self.attr}({self.next_type})'
class _SexpTemplate:
@staticmethod
@ -303,32 +252,22 @@ class _SexpTemplate:
return [kls.name_atom]
@staticmethod
def __map__(kls, value, *args, parent=None, path='', **kwargs):
def __map__(kls, value, *args, parent=None, **kwargs):
positional = iter(kls.positional)
inst = kls(*args, **kwargs)
for v in value[1:]: # skip key
if isinstance(v, Atom) and v in kls.keys:
name, etype = kls.keys[v]
mapped = map_sexp(etype, [v], parent=inst, path=f'{path}/{kls.name_atom}')
mapped = map_sexp(etype, [v], parent=inst)
if mapped is not None:
setattr(inst, name, mapped)
elif isinstance(v, list):
key = v[0]
if key in kls.keys:
name, etype = kls.keys[key]
mapped = map_sexp(etype, v, parent=inst, path=f'{path}/{kls.name_atom}')
if mapped is not None:
setattr(inst, name, mapped)
elif hasattr(inst, '__catchall__'):
inst.__catchall__(v, path=f'{path}/{kls.name_atom}')
else:
#print('class has keys:')
#print('\n'.join(map(str, kls.keys)))
raise TypeError(f'Unhandled keyed argument {v!r} while parsing {kls}')
name, etype = kls.keys[v[0]]
mapped = map_sexp(etype, v, parent=inst)
if mapped is not None:
setattr(inst, name, mapped)
else:
try:

View file

@ -21,12 +21,12 @@ from ...utils import rotate_point, Tag, arc_bounds
from ... import __version__
from ...newstroke import Newstroke
from .schematic_colors import *
from .primitives import kicad_mid_to_center_arc, Margins
from .primitives import kicad_mid_to_center_arc
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
Atom.no_connect, Atom.unconnected)
Atom.no_connect)
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
@ -52,7 +52,7 @@ class Pin:
style: PIN_STYLE = Atom.line
at: AtPos = field(default_factory=AtPos)
length: Named(float) = 2.54
hide: OmitDefault(Named(YesNoAtom())) = False
hide: Flag() = False
name: Rename(StyledText) = field(default_factory=StyledText)
number: Rename(StyledText) = field(default_factory=StyledText)
alternates: List(AltFunction) = field(default_factory=list)
@ -251,19 +251,11 @@ class Circle:
**self.stroke.svg_attrs(colorscheme.lines))
@sexp_type('radius')
class ArcRadius:
at: AtPos = field(default_factory=AtPos)
length: Named(float) = 0.0
angles: Rename(XYCoord) = field(default_factory=XYCoord)
@sexp_type('arc')
class Arc:
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
radius: ArcRadius = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
@ -404,12 +396,10 @@ class Rectangle:
@sexp_type('property')
class Property(TextMixin):
private: Flag() = False
name: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
show_name: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
# Alias value for text mixin
@ -427,24 +417,13 @@ class Property(TextMixin):
@sexp_type('pin_numbers')
class PinNumberSpec:
hide: Named(YesNoAtom()) = False
hide: Flag() = False
@sexp_type('pin_names')
class PinNameSpec:
offset: OmitDefault(Named(float)) = 0.508
hide: OmitDefault(Named(YesNoAtom())) = False
@sexp_type('text_box')
class TextBox:
text: str = ''
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
at: AtPos = field(default_factory=AtPos)
size: Rename(XYCoord) = field(default_factory=XYCoord)
margins: Margins = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
effects: TextEffect = field(default_factory=TextEffect)
hide: Flag() = False
@sexp_type('symbol')
@ -455,7 +434,6 @@ class Unit:
polylines: List(Polyline) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
texts: List(Text) = field(default_factory=list)
text_boxes: List(TextBox) = field(default_factory=list)
pins: List(Pin) = field(default_factory=list)
unit_name: Named(str) = None
_ : SEXP_END = None
@ -509,7 +487,6 @@ class Symbol:
on_board: Named(YesNoAtom()) = True
properties: List(Property) = field(default_factory=list)
units: List(Unit) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
_ : SEXP_END = None
library = None
name: str = None

View file

@ -425,30 +425,28 @@ class BreadboardArea:
label = f'{j+1}'
if last_e == 'R':
if points:
tx, ty = points[0]
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)
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]
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)
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:
@ -1172,7 +1170,7 @@ def convert_to_mm(value, unit):
raise ValueError(f'Invalid unit {unit}, allowed units are mm, cm, in, and mil.')
_VALUE_RE = re.compile(r'([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)')
_VALUE_RE = re.compile('([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)')
def eval_value(value, total_length=None):
if not isinstance(value, str):
return None

View file

@ -26,7 +26,7 @@ def extract_importlib(package):
else:
assert item.is_dir()
item_out.mkdir()
stack.append((item, item_out))
stack.push((item, item_out))
return root
@ -190,10 +190,7 @@ async def gerbers():
board.layer_stack().save_to_zipfile(f)
return Response(f.read_bytes(), mimetype='image/svg+xml')
def main():
app.run()
if __name__ == '__main__':
main()
app.run()

View file

@ -26,7 +26,7 @@ import shutil
from pathlib import Path
from functools import cached_property
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg
from . import graphic_primitives as gp
from . import graphic_objects as go
@ -60,9 +60,6 @@ class FileSettings:
#: If you want to export the macros with their original formulaic expressions (which is completely fine by the
#: Gerber standard, btw), set this parameter to ``False`` before exporting.
calculate_out_all_aperture_macros: bool = True
#: Internal field used to communicate if only decimal coordinates were found inside an Excellon file, or if it
#: contained at least some coordinates in fixed-width notation.
_file_has_fixed_width_coordinates: bool = False
# input validation
def __setattr__(self, name, value):
@ -351,24 +348,6 @@ class CamFile:
return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default)
def convex_hull(self, tol=0.01, unit=None):
unit = unit or self.unit
points = []
for obj in self.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def to_excellon(self):
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
raise NotImplementedError()

View file

@ -23,10 +23,8 @@ import dataclasses
import re
import warnings
import json
import sys
import itertools
import webbrowser
import warnings
from pathlib import Path
from .utils import MM, Inch
@ -39,18 +37,6 @@ from .cad.kicad import tmtheme
from .cad import protoserve
def _showwarning(message, category, filename, lineno, file=None, line=None):
if file is None:
file = sys.stderr
filename = Path(filename)
gerbonara_module_install_location = Path(__file__).parent.parent
if filename.is_relative_to(gerbonara_module_install_location):
filename = filename.relative_to(gerbonara_module_install_location)
print(f'{filename}:{lineno}: {message}', file=file)
warnings.showwarning = _showwarning
def _print_version(ctx, param, value):
if value and not ctx.resilient_parsing:
click.echo(f'Version {__version__}')
@ -338,9 +324,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
scheme instead of keeping the old file names.''')
@click.argument('transform')
@click.argument('inpath')
@click.argument('outpath', type=click.Path(path_type=Path))
def transform(transform, units, output_format, inpath, outpath, format_warnings, input_map, use_builtin_name_rules,
output_naming_scheme, number_format, force_zip):
@click.argument('outpath')
def transform(transform, units, output_format, inpath, outpath,
format_warnings, input_map, use_builtin_name_rules, output_naming_scheme):
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
@ -355,26 +341,16 @@ def transform(transform, units, output_format, inpath, outpath, format_warnings,
with warnings.catch_warnings():
warnings.simplefilter(format_warnings)
if force_zip:
stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
stack = lyr.LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules)
else:
stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
stack = lyr.LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules)
_apply_transform(transform, units, stack)
output_format = None if output_format == 'reuse' else FileSettings.defaults()
if number_format:
if output_format is None:
output_format = FileSettings.defaults()
a, _, b = number_format.partition('.')
output_format.number_format = (int(a), int(b))
if outpath.is_file() or outpath.suffix.lower() == '.zip':
stack.save_to_zipfile(outpath, naming_scheme=output_naming_scheme or {},
gerber_settings=output_format,
excellon_settings=dataclasses.replace(output_format, zeros=None))
else:
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
gerber_settings=output_format,
excellon_settings=dataclasses.replace(output_format, zeros=None))
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
gerber_settings=output_format,
excellon_settings=dataclasses.replace(output_format, zeros=None))
@cli.command()
@ -455,7 +431,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
help='''Enable or disable file format warnings during parsing (default: on)''')
@click.option('--units', type=Unit(), default='metric', help='Output bounding box in this unit (default: millimeter)')
@click.option('--units', type=Unit(), help='Output bounding box in this unit (default: millimeter)')
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
@click.option('--input-units', type=Unit(), help='Override units of input file')
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')

View file

@ -30,10 +30,9 @@ from pathlib import Path
from .cam import CamFile, FileSettings
from .graphic_objects import Flash, Line, Arc
from .apertures import ExcellonTool, CircleAperture
from .apertures import ExcellonTool
from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
class ExcellonContext:
""" Internal helper class used for tracking graphics state when writing Excellon. """
@ -269,19 +268,17 @@ class ExcellonFile(CamFile):
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
return self
def to_gerber(self, errors='raise'):
def to_gerber(self, errros='raise'):
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
from .rs274x import GerberFile
out = GerberFile()
out.comments = self.comments
apertures = {}
for obj in self.objects:
if not (ap := apertures.get(obj.tool)):
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter, unit=obj.aperture.unit)
if not (ap := apertures[obj.tool]):
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter)
out.objects.append(dataclasses.replace(obj, aperture=ap))
return out
@property
def generator(self):
@ -328,7 +325,7 @@ class ExcellonFile(CamFile):
for fn in 'nc_param.txt', 'ncdrill.log':
if (param_file := filename.parent / fn).is_file():
settings = parse_allegro_ncparam(param_file.read_text())
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}', SyntaxWarning)
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
break
# Parse Zuken log file for settings
@ -336,7 +333,7 @@ class ExcellonFile(CamFile):
logfile = filename.with_suffix('.fdl')
if logfile.is_file():
settings = parse_zuken_logfile(logfile.read_text())
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}', SyntaxWarning)
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}')
if external_tools is None:
# Parse allegro log files for tools.
@ -379,7 +376,7 @@ class ExcellonFile(CamFile):
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
if mixed_plating:
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning)
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
defined_tools = {}
tool_indices = {}
@ -569,8 +566,6 @@ class ExcellonParser(object):
self.filename = None
self.external_tools = external_tools or {}
self.found_kicad_format_comment = False
self.allegro_eof_toolchange_hack = False
self.allegro_eof_toolchange_hack_index = 1
def warn(self, msg):
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
@ -611,25 +606,18 @@ class ExcellonParser(object):
exprs = RegexMatcher()
# NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
@exprs.match(r';(?P<index1_prefix>T(?P<index1>[0-9]+))?\s+Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
@exprs.match(r';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
def parse_allegro_tooldef(self, match):
# NOTE: We ignore the given tolerances here since they are non-standard.
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
self.generator_hints.append('allegro')
index = int(match['index2'])
if match['index1'] and index != int(match['index1']): # index1 has leading zeros, index2 not.
if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!')
if index in self.tools:
self.warn('Re-definition of tool index {index}, overwriting old definition.')
if not match['index1_prefix']:
# This is a really nasty orcad file without tool change commands, that instead just puts all holes in order
# of the hole size definitions with M00's in between.
self.allegro_eof_toolchange_hack = True
# NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a
# problem, please raise an issue on our issue tracker, explain why you need this and provide an example file.
is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
@ -642,19 +630,13 @@ class ExcellonParser(object):
else:
unit = MM
if self.settings.unit is None:
self.settings.unit = unit
elif unit != self.settings.unit:
if unit != self.settings.unit:
self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the '
'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, '
'please raise an issue on our issue tracker.')
self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
if self.allegro_eof_toolchange_hack and self.active_tool is None:
self.active_tool = self.tools[index]
# Searching Github I found that EasyEDA has two different variants of the unit specification here.
@exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
def parse_easyeda_tooldef(self, match):
@ -771,12 +753,6 @@ class ExcellonParser(object):
def handle_end_of_program(self, match):
if self.program_state in (None, ProgramState.HEADER):
self.warn('M30 statement found before end of header.')
if self.allegro_eof_toolchange_hack:
self.allegro_eof_toolchange_hack_index = min(max(self.tools), self.allegro_eof_toolchange_hack_index + 1)
self.active_tool = self.tools[self.allegro_eof_toolchange_hack_index]
return
self.program_state = ProgramState.FINISHED
# TODO: maybe add warning if this is followed by other commands.
@ -786,17 +762,14 @@ class ExcellonParser(object):
def do_move(self, coord_groups):
x_s, x, y_s, y = coord_groups
if (x is not None and '.' not in x) or (y is not None and '.' not in y):
self.settings._file_has_fixed_width_coordinates = True
if self.settings.number_format == (None, None):
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
if x != '00':
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
'it, because Allegro does not include this critical information in their Excellon output. If you '
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'FileSettings object from excellon.parse_allegro_ncparam.')
if self.settings.number_format == (None, None) and '.' not in x:
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
if x != '00':
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
'it, because Allegro does not include this critical information in their Excellon output. If you '
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'FileSettings object from excellon.parse_allegro_ncparam.')
x = self.settings.parse_gerber_value(x)
if x_s:
@ -890,17 +863,12 @@ class ExcellonParser(object):
# from https://math.stackexchange.com/a/1781546
if a_s:
raise ValueError('Negative arc radius given')
r = self.settings.parse_gerber_value(a)
r = settings.parse_gerber_value(a)
x1, y1 = start
x2, y2 = end
dx, dy = (x2-x1)/2, (y2-y1)/2
x0, y0 = x1+dx, y1+dy
d = math.hypot(dx, dy)
if d == 0:
raise ValueError('Arc radius notation requires distinct start and end points')
if r < d:
raise ValueError('Arc radius too small for endpoint distance')
f = math.sqrt(r**2 - d**2) / d
f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
if clockwise:
cx = x0 + f*dy
cy = y0 - f*dx
@ -910,16 +878,16 @@ class ExcellonParser(object):
i, j = cx-start[0], cy-start[1]
else: # explicit center given
i = self.settings.parse_gerber_value(i) or 0
i = settings.parse_gerber_value(i)
if i_s:
i = -i
j = self.settings.parse_gerber_value(j) or 0
j = settings.parse_gerber_value(j)
if j_s:
j = -j
j = -i
self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit))
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?')
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
def parse_easyeda_format(self, match):
metric = match[1] in ('METRIC', 'M71')
@ -932,10 +900,7 @@ class ExcellonParser(object):
# This is used by newer autodesk eagles, fritzing and diptrace
if match[3]:
integer, _, fractional = match[3][1:].partition('.')
if integer.strip('0') or fractional.strip('0'):
self.settings.number_format = int(integer), int(fractional)
else:
self.settings.number_format = len(integer), len(fractional)
self.settings.number_format = len(integer), len(fractional)
elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment:
self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
@ -961,10 +926,10 @@ class ExcellonParser(object):
@exprs.match('(FMAT|VER),?([0-9]*)')
def handle_command_format(self, match):
if match[1] == 'FMAT':
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the
# same coordinate and routing statements that we already support, so rejecting the header unconditionally
# needlessly breaks otherwise parseable files.
if match[2] not in ('', '1', '2'):
# We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
# please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
# file.
if match[2] not in ('', '2'):
raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
else: # VER
@ -993,19 +958,6 @@ class ExcellonParser(object):
else:
self.warn('Bare coordinate after end of file')
@exprs.match(xy_coord + 'G85' + xy_coord)
def handle_g85_slot(self, match):
if self.program_state == ProgramState.HEADER:
return
self.do_move(match.groups()[:4])
start, end = self.do_move(match.groups()[4:])
if not self.ensure_active_tool():
return
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
@exprs.match(r'DETECT,ON|ATC,ON|M06')
def parse_zuken_legacy_statements(self, match):
self.generator_hints.append('zuken')

View file

@ -307,7 +307,6 @@ class Region(GraphicObject):
def _offset(self, dx, dy):
self.outline = [ (x+dx, y+dy) for x, y in self.outline ]
self.arc_centers = [ (c[0], (c[1][0]+dx, c[1][1]+dy)) if c else None for c in self.arc_centers ]
def _rotate(self, angle, cx=0, cy=0):
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
@ -323,7 +322,7 @@ class Region(GraphicObject):
def close(self):
if self.outline and self.outline[-1] != self.outline[0]:
self.outline.append(self.outline[0])
self.outline.append(self.outline[-1])
if self.arc_centers:
self.arc_centers.append((None, (None, None)))
@ -337,9 +336,8 @@ class Region(GraphicObject):
], unit=unit)
@classmethod
def from_arc_poly(kls, arc_poly, polarity_dark=None, unit=MM):
polarity = arc_poly.polarity_dark if polarity_dark is None else polarity_dark
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity, unit=unit)
def from_arc_poly(kls, arc_poly, polarity_dark=True, unit=MM):
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity_dark, unit=unit)
def append(self, obj):
if obj.unit != self.unit:

View file

@ -62,12 +62,6 @@ class GraphicPrimitive:
raise NotImplementedError()
def is_zero_size(self):
""" Return whether this primitive is zero size
:rtype: bool
"""
@dataclass(frozen=True)
class Circle(GraphicPrimitive):
@ -87,11 +81,7 @@ class Circle(GraphicPrimitive):
def to_arc_poly(self):
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
[(True, (self.x, self.y)), (True, (self.x, self.y))],
polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.r, 0)
[(True, (self.x, self.y)), (True, (self.x, self.y))])
@dataclass(frozen=True)
@ -130,14 +120,14 @@ class ArcPoly(GraphicPrimitive):
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
outline = []
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
for p1, p2, (clockwise, center) in self.segments():
if clockwise is None:
outline.append((x1, y1))
outline.append(p1)
else:
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
max_error=max_error, clip_max_error=clip_max_error))
outline.pop() # remove arc end point
return type(self)(outline, polarity_dark=self.polarity_dark)
return type(self)(outline)
def bounding_box(self):
bbox = (None, None), (None, None)
@ -189,20 +179,6 @@ class ArcPoly(GraphicPrimitive):
return self
def is_zero_size(self):
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is not None: # arc
if math.isclose(cx, x1) and math.isclose(cy, y1):
continue
if math.isclose(x1, x2) and math.isclose(y1, y2):
return False
if math.isclose(polygon_area(self.outline), 0):
return True
return False
@dataclass(frozen=True)
class Line(GraphicPrimitive):
""" Straight line with round end caps. """
@ -242,33 +218,24 @@ class Line(GraphicPrimitive):
color = fg if self.polarity_dark else bg
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round')
fill='none', stroke=color, stroke_width=str(width))
def to_arc_poly(self):
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
if math.isclose(l, 0):
# degenerate case: a zero-length line becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx, dy = self.x2-self.x1, self.y2-self.y1
nx, ny = -dy/l, dx/l
rx, ry = nx*self.width/2, ny*self.width/2
return ArcPoly([
(self.x2+rx, self.y2+ry),
(self.x2-rx, self.y2-ry),
(self.x1-rx, self.y1-ry),
(self.x1+rx, self.y1+ry),
(self.x1-rx, self.y1-ry),
(self.x2-rx, self.y2-ry),
(self.x2+rx, self.y2+ry),
], [
(True, (self.x2, self.y2)),
None,
(True, (self.x1, self.y1)),
None,
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
(True, (self.x2, self.y2)),
None,
])
@dataclass(frozen=True)
@ -309,35 +276,25 @@ class Arc(GraphicPrimitive):
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}',
fill='none', stroke=color, stroke_width=width, stroke_linecap='round')
fill='none', stroke=color, stroke_width=width)
def to_arc_poly(self):
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
if math.isclose(r, 0):
# degenerate case: a zero-radius arc becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
return ArcPoly([ # vertices
(self.x1+nx1, self.y1+ny1),
(self.x1-nx1, self.y1-ny1),
(self.x2-nx2, self.y2-ny2),
(self.x2+nx2, self.y2+ny2),
], [ # arc segments (direction, center)
(not self.clockwise, (self.x1, self.y1)),
return ArcPoly([
(self.x1+nx1, self.y1+nx1),
(self.x1-nx1, self.y1-nx1),
(self.x2-nx2, self.y2-nx2),
(self.x2+nx2, self.y2+nx2),
], [
(self.clockwise, (self.x1, self.y1)),
(self.clockwise, (self.cx, self.cy)),
(self.clockwise, (self.x2, self.y2)),
(not self.clockwise, (self.cx, self.cy)),
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return False # an arc with identical start and end points is defined as a circle
(self.clockwise, (self.cx, self.cy)),
])
@dataclass(frozen=True)
@ -366,7 +323,7 @@ class Rectangle(GraphicPrimitive):
(x - (cw+sh), y + (ch+sw)),
(x + (cw+sh), y + (ch+sw)),
(x + (cw+sh), y - (ch+sw)),
], polarity_dark=self.polarity_dark)
])
def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg
@ -374,6 +331,3 @@ class Rectangle(GraphicPrimitive):
return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
**svg_rotation(self.rotation, self.x, self.y), fill=color)
def is_zero_size(self):
return math.isclose(self.w, 0) or math.isclose(self.h, 0)

View file

@ -82,7 +82,6 @@ MATCH_RULES = {
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
'header regex': [['sufficient', r'top .*|bottom .*', r'G04 DipTrace [.-0-9a-z]*\*']],
},
'target': {
@ -152,25 +151,22 @@ MATCH_RULES = {
'allegro': {
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
'drill plated': r'.*\.(drl)',
'drill nonplated': r'.*\.(rou)',
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
'autoguess': r'.*\.art',
'drill mech': r'.*\.(drl|rou)',
'generic gerber': r'.*\.art',
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
'header regex': [['required,sufficient', r'.*\.art', r'G04 File Origin:\s+Cadence Allegro [0-9]+\.[0-9]+[-a-zA-Z0-9]*']],
},
'pads': {
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
'autoguess': r'.*\.pho',
'drill plated': r'.*\.drl',
'generic gerber': r'.*\.pho',
'drill mech': r'.*\.drl',
},
'zuken': {
'autoguess': r'.*\.fph',
'generic gerber': r'.*\.fph',
'gerber params': r'.*\.fpl',
'drill unknown': r'.*\.fdr',
'drill mech': r'.*\.fdr',
'excellon params': r'.*\.fdl',
'other netlist': r'.*\.ipc',
'ipc-2581': r'.*\.xml',

View file

@ -39,7 +39,6 @@ from .cam import FileSettings, LazyCamFile
from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
from . import graphic_objects as go
from . import apertures as ap
from . import graphic_primitives as gp
@ -66,27 +65,27 @@ DEFAULT_COLORS = {
class NamingScheme:
kicad = {
'top copper': '{board_name}-F_Cu.gbr',
'top mask': '{board_name}-F_Mask.gbr',
'top silk': '{board_name}-F_SilkS.gbr',
'top paste': '{board_name}-F_Paste.gbr',
'bottom copper': '{board_name}-B_Cu.gbr',
'bottom mask': '{board_name}-B_Mask.gbr',
'bottom silk': '{board_name}-B_SilkS.gbr',
'bottom paste': '{board_name}-B_Paste.gbr',
'inner copper': '{board_name}-In{layer_number}_Cu.gbr',
'mechanical outline': '{board_name}-Edge_Cuts.gbr',
'top copper': '{board_name}-F.Cu.gbr',
'top mask': '{board_name}-F.Mask.gbr',
'top silk': '{board_name}-F.SilkS.gbr',
'top paste': '{board_name}-F.Paste.gbr',
'bottom copper': '{board_name}-B.Cu.gbr',
'bottom mask': '{board_name}-B.Mask.gbr',
'bottom silk': '{board_name}-B.SilkS.gbr',
'bottom paste': '{board_name}-B.Paste.gbr',
'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
'mechanical outline': '{board_name}-Edge.Cuts.gbr',
'drill unknown': '{board_name}.drl',
'drill plated': '{board_name}-PTH.drl',
'drill nonplated': '{board_name}-NPTH.drl',
'other comments': '{board_name}-Cmts_User.gbr',
'other drawings': '{board_name}-Dwgs_User.gbr',
'top fabrication': '{board_name}-F_Fab.gbr',
'bottom fabrication': '{board_name}-B_Fab.gbr',
'top adhesive': '{board_name}-F_Adhes.gbr',
'bottom adhesive': '{board_name}-B_Adhes.gbr',
'top courtyard': '{board_name}-F_CrtYd.gbr',
'bottom courtyard': '{board_name}-B_CrtYd.gbr',
'other comments': '{board_name}-Cmts.User.gbr',
'other drawings': '{board_name}-Dwgs.User.gbr',
'top fabrication': '{board_name}-F.Fab.gbr',
'bottom fabrication': '{board_name}-B.Fab.gbr',
'top adhesive': '{board_name}-F.Adhes.gbr',
'bottom adhesive': '{board_name}-B.Adhes.gbr',
'top courtyard': '{board_name}-F.CrtYd.gbr',
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
'other netlist': '{board_name}.d356',
}
@ -113,61 +112,31 @@ class NamingScheme:
}
def apply_rules(filenames, rules):
certain = False
gen = {}
already_matched = set()
header_regex = rules.pop('header regex', [])
header_regex_matched = [False] * len(header_regex)
file_headers = {}
def get_header(path):
if path not in file_headers:
with open(path) as f:
file_headers[path] = f.read(16384)
return file_headers[path]
for layer, regex in rules.items():
for fn in filenames:
if fn in already_matched:
continue
target = None
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
if layer == 'inner copper':
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
else:
target = layer
gen[target] = gen.get(target, []) + [fn]
already_matched.add(fn)
for i, (match_type, layer_match, header_match) in enumerate(header_regex):
if re.fullmatch(layer_match, fn.name, re.IGNORECASE) or (
target is not None and re.fullmatch(layer_match, target, re.IGNORECASE)):
if re.search(header_match, get_header(fn)):
if 'sufficient' in match_type:
certain = True
header_regex_matched[i] = True
if any('required' in match_type and not match
for match, (match_type, *_) in zip(header_regex_matched, header_regex)):
return False, {}
return certain, gen
def _best_match(filenames):
def _match_files(filenames):
matches = {}
for generator, rules in MATCH_RULES.items():
certain, candidate = apply_rules(filenames, rules)
already_matched = set()
gen = {}
matches[generator] = gen
for layer, regex in rules.items():
for fn in filenames:
if fn in already_matched:
continue
if certain:
return generator, candidate
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
if layer == 'inner copper':
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
else:
target = layer
matches[generator] = candidate
gen[target] = gen.get(target, []) + [fn]
already_matched.add(fn)
return matches
def _best_match(filenames):
matches = _match_files(filenames)
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
generator, files = matches[-1]
return generator, files
@ -274,7 +243,7 @@ def _layername_autoguesser(fn):
elif re.search('film', fn):
use = 'copper'
elif re.search('out(line)?|board.?geom(etry)?', fn):
elif re.search('out(line)?', fn):
use = 'outline'
side = 'mechanical'
@ -304,9 +273,6 @@ def _sort_layername(val):
assert side.startswith('inner_')
return int(side[len('inner_'):])
def convex_hull_to_lines(points, unit=MM):
for (x1, y1), (x2, y2) in zip(points, points[1:] + points):
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(unit(0.1, MM), unit=unit), unit=unit)
class LayerStack:
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
@ -419,7 +385,7 @@ class LayerStack:
with ZipFile(file) as f:
f.extractall(path=tmp_indir)
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy)
inst.tmpdir = tmpdir
inst.original_path = Path(original_path or file)
inst.was_zipped = True
@ -455,7 +421,6 @@ class LayerStack:
given value.
:rtype: :py:class:`LayerStack`
"""
print_layermap = False
if autoguess:
generator, filemap = _best_match(files)
@ -480,51 +445,14 @@ class LayerStack:
filemap[layer].remove(fn)
filemap[layer] = filemap.get(layer, []) + [fn]
if 'autoguess' in filemap:
warnings.warn(f'This generator ({generator}) often exports ambiguous filenames. Falling back to autoguesser for some files. Use at your own peril. Autoguessed files: {", ".join(f.name for f in filemap["autoguess"])}')
print_layermap = True
autoguess_filenames = filemap.pop('autoguess')
matched = set()
for key, values in _do_autoguess(autoguess_filenames).items():
filemap[key] = filemap.get(key, []) + values
matched |= set(values)
if generator == 'allegro':
# Allegro gerbers often contain the inner layers with completely random filenames and no indication of
# layer ordering except for drawings in the mechanical files. We fall back to alphabetic ordering.
for fn in autoguess_filenames:
if fn not in matched:
with open(fn) as f:
header = f.read(16384)
if re.search(r'G04 Layer:\s*ETCH/.*\*', header):
filemap['unknown copper'] = filemap.get('unknown copper', []) + [fn]
if (unk := filemap.pop('unknown copper', None)):
unk = sorted(unk, key=str)
if 'top copper' not in filemap:
filemap['top copper'], *unk = [unk]
if 'bottom copper' not in filemap:
*unk, filemap['bottom copper'] = [unk]
i = 1
while unk and i < 128:
key = f'inner_{i:02d} copper'
if key not in filemap:
filemap[key] = [unk.pop(0)]
i += 1
if sum(len(files) for files in filemap.values()) < 6 and autoguess:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
generator = None
print_layermap = True
filemap = _do_autoguess(files)
if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
excellon_settings, external_tools = None, None
automatch_drill_scale = False
if generator == 'geda':
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
@ -542,22 +470,16 @@ class LayerStack:
if (external_tools := parse_allegro_logfile(file.read_text())):
break
del filemap['excellon params']
else:
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
# We'll run an automatic scale matching later.
excellon_settings = FileSettings(number_format=(2, 4))
automatch_drill_scale = True
print('remaining filemap')
import pprint
pprint.pprint(filemap)
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
if len(filemap) < 6:
raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available
elif generator == 'zuken':
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
if len(filemap) < 6:
raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available
@ -581,12 +503,7 @@ class LayerStack:
else:
excellon_settings = None
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})'
for key, value in filemap.items()
if len(value) > 1 and\
not 'drill' in key and\
not 'excellon' in key and\
not key == 'other unknown']
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
if ambiguous:
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
@ -595,11 +512,8 @@ class LayerStack:
netlist = None
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
for key, paths in filemap.items():
if len(paths) > 1 and\
not 'drill' in key and\
not 'excellon' in key and\
not key == 'other unknown':
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(map(str, value))}')
if len(paths) > 1 and not 'drill' in key:
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
for path in paths:
id_result = identify_file(path.read_text())
@ -660,72 +574,9 @@ class LayerStack:
board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name)
if automatch_drill_scale:
top_copper = layers[('top', 'copper')].to_excellon(errors='ignore', holes_only=True)
# precision is matching precision in mm
def map_coords(obj, precision=0.01, scale=1):
obj = obj.converted(MM)
return round(obj.x*scale/precision), round(obj.y*scale/precision)
aper_coords = {map_coords(obj) for obj in top_copper.drills()}
for drill_file in [drill_pth, drill_npth, *drill_layers]:
if not drill_file or not drill_pth.import_settings._file_has_fixed_width_coordinates:
continue
scale_matches = {}
for exp in range(-6, 6):
scale = 10**exp
hole_coords = {map_coords(obj, scale=scale) for obj in drill_file.drills()}
scale_matches[scale] = len(aper_coords - hole_coords), len(hole_coords - aper_coords)
scales_out = [(max(a, b), scale) for scale, (a, b) in scale_matches.items()]
_matches, scale = sorted(scales_out)[0]
warnings.warn(f'Performing automatic alignment of poorly exported drill layer. Scale matching results: {scale_matches}. Chosen scale: {scale}')
# Note: This is only used with allegro files, which use decimal points and explicit units in their tool
# definitions. Thus, we only scale object coordinates, and not apertures.
for obj in drill_file.objects:
obj.scale(scale)
stack = kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
if print_layermap:
warnings.warn('Auto-guessed layer map:\n' + stack.format_layer_map())
return stack
def format_layer_map(self):
lines = []
def print_layer(prefix, file):
nonlocal lines
if file is None:
lines.append(f'{prefix} <not found>')
else:
lines.append(f'{prefix} {file.original_path.name} {file}')
lines.append(' Drill files:')
print_layer(' Plated holes:', self.drill_pth)
print_layer(' Nonplated holes:', self.drill_npth)
for i, l in enumerate(self._drill_layers):
print_layer(f' Additional drill layer {i}:', l)
print_layer(' Board outline:', self.get('mechanical outline'))
lines.append(' Soldermask:')
print_layer(' Top:', self.get('top mask'))
print_layer(' Bottom:', self.get('bottom mask'))
lines.append(' Silkscreen:')
print_layer(' Top:', self.get('top silk'))
print_layer(' Bottom:', self.get('bottom silk'))
lines.append(' Copper:')
for (side, _use), layer in self.copper_layers:
print_layer(f' {side}:', layer)
return '\n'.join(lines)
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
gerber_settings=None, excellon_settings=None):
""" Save this board into a zip file at the given path. For other options, see
@ -738,7 +589,10 @@ class LayerStack:
:param prefix: Store output files under the given prefix inside the zip file
"""
if path.is_file() and not overwrite_existing:
if path.is_file():
if overwrite_existing:
path.unlink()
else:
raise ValueError('output zip file already exists and overwrite_existing is False')
if gerber_settings and not excellon_settings:
@ -1012,7 +866,7 @@ class LayerStack:
if use == 'mask':
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white'))
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})',
fill=default_fill, stroke=default_stroke, **stroke_attrs, fill_rule='evenodd',
fill=default_fill, stroke=default_stroke, **stroke_attrs,
**inkscape_attrs(f'{side} {use}'), transform=layer_transform))
for i, layer in enumerate(self.drill_layers):
@ -1025,7 +879,7 @@ class LayerStack:
id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'),
transform=layer_transform))
sc_y, tl_y = 1, 0
sc_y, tl_y = -1, (bounds[0][1] + bounds[1][1])
if side == 'bottom':
sc_x, tl_x = -1, (bounds[0][0] + bounds[1][0])
else:
@ -1260,6 +1114,22 @@ class LayerStack:
polys.append(' '.join(poly.path_d()) + ' Z')
return ' '.join(polys)
def outline_convex_hull(self, tol=0.01, unit=MM):
points = []
for obj in self.outline.instance.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def outline_polygons(self, tol=0.01, unit=MM):
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
@ -1274,17 +1144,8 @@ class LayerStack:
:param tol: :py:obj:`float` setting the tolerance below which two points are considered equal
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). SVG document unit. Default: mm
"""
if not self.outline:
warnings.warn("Board has no outline layer, or the outline layer could not be identified by file name. Using the copper layers' convex hull instead.")
points = sum((layer.instance.convex_hull(tol, unit) for (_side, _use), layer in self.copper_layers), start=[])
yield list(convex_hull_to_lines(convex_hull(points), unit))
return
maybe_allegro_hint = '' if self.generator != 'allegro' else ' This file looks like it was generated by Allegro/OrCAD. These tools produce quite mal-formed gerbers, and often export text on the outline layer. If you generated this file yourself, maybe try twiddling with the export settings.'
polygons = []
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
lines = [ prim for prim in lines if not prim.is_zero_size() ]
by_x = sorted([ (obj.x1, obj) for obj in lines ] + [ (obj.x2, obj) for obj in lines ], key=lambda x: x[0])
dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
@ -1311,14 +1172,13 @@ class LayerStack:
j = 0 if d1 < d2 else 1
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
return
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
return self.outline_convex_hull(tol, unit)
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(cur, i)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
return
warnings.warn(f'three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
return self.outline_convex_hull(tol, unit)
joins[(cur, i)] = (nearest, j)
joins[(nearest, j)] = (cur, i)

View file

@ -21,7 +21,6 @@
import re
import math
import copy
import warnings
from pathlib import Path
import dataclasses
@ -152,7 +151,7 @@ class GerberFile(CamFile):
self.map_apertures(lookup)
def to_excellon(self, plated=None, errors='raise', holes_only=False):
def to_excellon(self, plated=None, errors='raise'):
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
@ -160,10 +159,7 @@ class GerberFile(CamFile):
new_objs = []
new_tools = {}
for obj in self.objects:
if holes_only and not isinstance(obj, go.Flash):
continue
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.')
@ -375,8 +371,8 @@ class GerberFile(CamFile):
def invert_polarity(self):
""" Invert the polarity (color) of each object in this file. """
for obj in self.objects:
obj.polarity_dark = not obj.polarity_dark
obj.polarity_dark = not p.polarity_dark
class GraphicsState:
""" Internal class used to track Gerber processing state during import and export.
@ -463,7 +459,7 @@ class GraphicsState:
obj = go.Flash(*self.map_coord(*self.point), self.aperture,
polarity_dark=self._polarity_dark,
unit=self.unit,
attrs=copy.copy(self.object_attrs))
attrs=self.object_attrs)
return obj
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
@ -489,13 +485,13 @@ class GraphicsState:
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
else:
if i is None and j is None:
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
else:
if i is None:
@ -512,7 +508,7 @@ class GraphicsState:
if not multi_quadrant:
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
clockwise=clockwise, aperture=(self.aperture if aperture else None),
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
else:
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
@ -525,7 +521,7 @@ class GraphicsState:
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
clockwise=clockwise, aperture=aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
arcs = sorted(arcs, key=lambda a: a.numeric_error())
@ -599,8 +595,6 @@ class GerberParser:
NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
MAX_STEP_REPEAT_INSTANCES = 100000
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
STATEMENT_REGEXES = {
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
@ -629,7 +623,6 @@ class GerberParser:
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
'siemens_garbage': r'^ICAS$',
'step_repeat': fr'^SR(?P<coords>X(?P<X>[0-9]+)Y(?P<Y>[0-9]+)I(?P<I>{DECIMAL})J(?P<J>{DECIMAL}))?$',
'old_unit':r'(?P<mode>G7[01])',
'old_notation': r'(?P<mode>G9[01])',
'ignored': r"(?P<stmt>M01)",
@ -649,8 +642,6 @@ class GerberParser:
self.aperture_map = {}
self.aperture_macros = {}
self.current_region = None
self.step_repeat_coords = None
self.step_repeat_objects = None
self.eof_found = False
self.multi_quadrant_mode = None # used only for syntax checking
self.macros = {}
@ -793,10 +784,7 @@ class GerberParser:
# in multi-quadrant mode this may return None if start and end point of the arc are the same.
obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode)
if obj is not None:
if self.step_repeat_objects:
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
self.target.objects.append(obj)
else:
obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode)
if obj is not None:
@ -807,21 +795,14 @@ class GerberParser:
if self.current_region:
# Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
# it does not make a graphical difference, and it makes the implementation slightly easier.
if self.step_repeat_objects:
self.step_repeat_objects.append(self.current_region)
else:
self.target.objects.append(self.current_region)
self.target.objects.append(self.current_region)
self.current_region = go.Region(
polarity_dark=self.graphics_state.polarity_dark,
unit=self.file_settings.unit)
elif op == '3':
if self.current_region is None:
obj = self.graphics_state.flash(x, y)
if self.step_repeat_objects:
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
self.target.objects.append(self.graphics_state.flash(x, y))
else:
raise SyntaxError('DO3 flash statement inside region')
@ -862,11 +843,6 @@ class GerberParser:
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
# Polygon aperture rotation is specified in degrees, but radians are easier to work with
if match['shape'] == 'P':
if len(modifiers) > 2:
modifiers[2] = math.radians(modifiers[2])
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
original_number=number)
@ -1083,40 +1059,11 @@ class GerberParser:
else:
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else ()
target[match['name']] = tuple(match['value'].split(','))
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle')
def _parse_step_repeat(self, match):
if match['coords']:
if self.step_repeat_coords:
raise SyntaxError('SR step-repeat called inside ongoing SR step-repeat')
x, y = int(match['X']), int(match['Y'])
i, j = float(match['I']), float(match['J'])
if x < 1 or y < 1:
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
raise SyntaxError('SR step-repeat expands to too many instances')
self.step_repeat_coords = (x, y, i, j)
self.step_repeat_objects = []
else:
x, y, i, j = self.step_repeat_coords
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
raise SyntaxError('SR step-repeat expands to too many objects')
for obj in self.step_repeat_objects:
for nx in range(x):
for ny in range(y):
new_obj = copy.copy(obj)
new_obj.offset(i * nx, j * ny)
self.target.objects.append(new_obj)
self.step_repeat_coords = None
self.step_repeat_objects = None
def _parse_eof(self, match):
self.eof_found = True

View file

@ -0,0 +1,79 @@
import os
from pathlib import Path
import tqdm
import multiprocessing.pool
import subprocess
from itertools import chain
import pytest
from .image_support import ImageDifference, run_cargo_cmd, bulk_populate_kicad_fp_export_cache
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
diff = left if isinstance(left, ImageDifference) else right
return [
f'Image difference assertion failed.',
f' Calculated difference: {diff}',
f' Histogram: {diff.histogram}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)
fail_dir = Path('gerbonara_test_failures')
def pytest_sessionstart(session):
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
def pytest_configure(config):
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
lib_dir = Path(lib_dir).expanduser()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
print('Checking and bulk re-building KiCad footprint library cache')
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
lib_dirs = list(lib_dir.glob('*.pretty'))
res = list(tqdm.tqdm(pool.imap(lambda path: bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
def pytest_addoption(parser):
parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
def pytest_generate_tests(metafunc):
if 'kicad_library_file' in metafunc.fixturenames:
if not (library_files := metafunc.config.getoption('symbol_library', None)):
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
lib_dir = Path(lib_dir).expanduser()
library_files = list(lib_dir.glob('*.kicad_sym'))
else:
raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given to run kicad symbol tests.')
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
if 'kicad_mod_file' in metafunc.fixturenames:
if not (mod_files := metafunc.config.getoption('footprint_files', None)):
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
lib_dir = Path(lib_dir).expanduser()
mod_files = list(lib_dir.glob('*.pretty/*.kicad_mod'))
else:
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given to run kicad footprint tests.')
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 556 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

@ -0,0 +1,352 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Based on https://github.com/tracespace/tracespace
#
import subprocess
from pathlib import Path
import tempfile
import textwrap
import os
import sys
import stat
import random
import statistics
from functools import total_ordering
import shutil
import bs4
from contextlib import contextmanager
import hashlib
import tqdm
import numpy as np
from PIL import Image
cachedir = Path(__file__).parent / 'image_cache'
cachedir.mkdir(exist_ok=True)
@total_ordering
class ImageDifference:
def __init__(self, value, histogram):
self.value = value
self.histogram = histogram
def __float__(self):
return float(self.value)
def __eq__(self, other):
return float(self) == float(other)
def __lt__(self, other):
return float(self) < float(other)
def __str__(self):
return str(float(self))
@total_ordering
class Histogram:
def __init__(self, value, size):
self.value, self.size = value, size
def __eq__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value == other).all()
def __lt__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value <= other).all()
def __getitem__(self, index):
return self.value[index]
def __str__(self):
return f'{list(self.value)} size={self.size}'
def run_cargo_cmd(cmd, args, **kwargs):
if cmd.upper() in os.environ:
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
try:
return subprocess.run([cmd, *args], **kwargs)
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
def svg_to_png(in_svg, out_png, dpi=100, bg=None):
params = f'{dpi}{bg}'.encode()
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.png'
if not cachefile.is_file():
bg = 'black' if bg is None else bg
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
shutil.copy(cachefile, out_png)
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
params = f'{origin}{size}{fg}{bg}'.encode()
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.svg'
if not cachefile.is_file():
print(f'Building cache for {Path(in_gbr).name}')
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
# and project file color settings.
# TODO: File issue upstream.
with tempfile.NamedTemporaryFile('w') as f:
if override_unit_spec:
units, zeros, digits = override_unit_spec
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
units = 0 if units == 'inch' else 1
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
(list 'autodetect 'Boolean 0)
(list 'zero_suppression 'Enum {zeros})
(list 'units 'Enum {units})
(list 'digits 'Integer {digits})
))''')
else:
unit_spec = ''
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
f.flush()
if override_unit_spec:
shutil.copy(f.name, '/tmp/foo.gbv')
x, y = origin
w, h = size
cmd = ['gerbv', '-x', export_format,
'--border=0',
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
f'--background={bg}',
f'--foreground={fg}',
'-o', str(cachefile), '-p', f.name]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
print(f'Re-using cache for {Path(in_gbr).name}')
shutil.copy(cachefile, out_svg)
def kicad_fp_export(mod_file, out_svg):
mod_file = Path(mod_file)
if mod_file.suffix.lower() != '.kicad_mod':
raise ValueError("KiCad footprint file must have .kicad_mod extension for kicad-cli to do it's thing")
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.svg'
if not cachefile.is_file():
print(f'Building cache for {mod_file.name}')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
pretty_dir = mod_file.parent
fp_name = mod_file.name[:-len('.kicad_mod')]
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
'registry.hub.docker.com/kicad/kicad:nightly',
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out_file = Path(tmpdir) / f'{fp_name}.svg'
shutil.copy(out_file, cachefile)
else:
print(f'Re-using cache for {mod_file.name}')
shutil.copy(cachefile, out_svg)
def bulk_populate_kicad_fp_export_cache(pretty_dir):
def cachefile(mod_file):
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
return cachedir / f'{digest}.svg'
mod_files = list(pretty_dir.glob('*.kicad_mod'))
hit_rate = statistics.mean([int(cachefile(fn).is_file())
for fn in random.sample(mod_files, min(len(mod_files), 50))])
if hit_rate < 0.9:
#tqdm.tqdm.write(f'Modfile cache is out of date (hit rate {hit_rate*100:.0f}%), re-building entire cache in bulk')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
'registry.hub.docker.com/kicad/kicad:nightly',
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', f'/{pretty_dir.name}']
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
for fn in mod_files:
out_file = Path(tmpdir) / fn.with_suffix('.svg').name
if not out_file.is_file():
tqdm.tqdm.write(f'Output file {out_file} is missing while bulk re-building cache for {pretty_dir}.')
else:
shutil.copy(out_file, cachefile(fn))
@contextmanager
def svg_soup(filename):
with open(filename, 'r') as f:
soup = bs4.BeautifulSoup(f.read(), 'xml')
yield soup
with open(filename, 'w') as f:
f.write(str(soup))
def cleanup_gerbv_svg(soup):
width = soup.svg["width"]
height = soup.svg["height"]
width = width[:-2] if width.endswith('pt') else width
height = height[:-2] if height.endswith('pt') else height
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
for group in soup.find_all('g'):
# gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit
# handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it
# seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders
# aperture macros into a new surface, which for some reason gets clipped by Cairo to the given
# canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here.
#
# Apart from being graphically broken, this additionally causes very bad rendering performance.
del group['clip-path']
def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
with svg_soup(ref_svg.name) as soup:
if svg_transform is not None:
svg = soup.svg
children = list(svg.children)
g = soup.new_tag('g', attrs={'transform': svg_transform})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup)
with svg_soup(act_svg.name) as soup:
cleanup_gerbv_svg(soup)
return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
print(f'=== {var} ===')
print(Path(locals()[var].name).read_text().splitlines()[1])
with svg_soup(ref1_svg.name) as soup1:
if svg_transform1 is not None:
svg = soup1.svg
children = list(svg.children)
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup1)
with svg_soup(ref2_svg.name) as soup2:
if svg_transform2 is not None:
svg = soup2.svg
children = list(svg.children)
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup2)
defs1 = soup1.find('defs')
if not defs1:
defs1 = soup1.new_tag('defs')
soup1.find('svg').insert(0, defs1)
defs2 = soup2.find('defs')
if defs2:
defs2 = defs2.extract()
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
# iterating because we modify the tree in the loop body.
for c in list(defs2.contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
defs1.append(c)
for use in soup2.find_all('use', recursive=True):
if (href := use.get('xlink:href', '')).startswith('#'):
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
svg1 = soup1.find('svg')
for c in list(soup2.find('svg').contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
svg1.append(c)
if composite_out:
shutil.copyfile(ref1_svg.name, composite_out)
with svg_soup(act_svg.name) as soup:
cleanup_gerbv_svg(soup)
return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
def svg_difference(reference, actual, diff_out=None, background=None, dpi=100):
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
svg_to_png(reference, ref_png.name, bg=background, dpi=dpi)
svg_to_png(actual, act_png.name, bg=background, dpi=dpi)
return image_difference(ref_png.name, act_png.name, diff_out=diff_out)
def image_difference(reference, actual, diff_out=None):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
# TODO blur images here before comparison to mitigate aliasing issue
delta = np.abs(out - ref).astype(float) / 255
if diff_out:
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
return (ImageDifference(delta.mean(), hist),
ImageDifference(delta.max(), hist),
Histogram(hist, out.size))

Some files were not shown because too many files have changed in this diff Show more